Wearable streams: movement & shape scores
Schema deep-dive · living document · #24 in the reading sequence
| Tables | streams.movement_score[1], streams.shape_score[2], streams.shape_contribution[3] |
| Owner service | balance (sole writer; Python/FastAPI/asyncpg, no ORM) |
| Locator | natural key - (profile_id, as_of) / (profile_id, shape, window_days); shape_contribution keys on a serial id and is reseeded wholesale (§4) |
| Last updated | 2026-08-28 |
| Companion | previous: Compaction pipeline (#23) · next: Findings & profile (#25) · surfaced on the member app's Balance Grid + movement chart |
1. Scope and usage
This is the derived scoring layer of the wearable-streams pipeline. Page #23 ended with the compaction outputs: streams.episode (one bout of behaviour) and streams.rollup (per (profile, episode_type, day, metric) aggregates). This page is what the engine makes of those rollups: a daily movement score and a per-shape shape score, both a number in 0..1 that the app draws as a tile or a ring.
movement_score is the honest one. Per (profile, day) it stores a recency-weighted 7-day MET-minutes total, the member's own 28-day weekly target, the ratio between them, a clamped 0..1 score, and a components jsonb that records every input so the number can be re-derived by hand[4]. It is the output of #1362's movement blob and it is well populated - 471 live rows across 15 profiles.
shape_score is the grid cell: per (profile, shape, window_days) an absolute and a relative score for each of the five life shapes (movement, sleep, nutrition, mind, social)[5]. For the movement shape it copies movement_score.score; for the other four it runs the v0 formula (folded rollup value / baseline mean) that the file labels "product-owned placeholder"[6]. 165 live rows. The formula is a seam, not the contract: the row shape (absolute / relative / calibrating) carries any 0..1 measure, so replacing a shape's formula changes no column (§9). Read the calibrating column alongside the number - live, only movement and sleep leave calibrating (§6).
shape_contribution is the model's headroom for typed contributions. It is a small global registry (7 seed rows[7], not 7 per profile) mapping an episode-type to a shape it projects onto: a workout counts toward movement, a mindfulness session half-counts toward mind. It carries DR-4.18's typed-contribution model[8], and the code that reads it (contributions_for_episode / fold) is complete and covered by tests[9]. The live path is the simpler one: compute_shape_scores folds straight from streams.rollup, so no shape_score row today is derived from a contribution tuple. Pointing the fold at contributions_for_episode is a change inside one function, with the table and its rules already in place (§9).
2. Boundaries and relationships
| A score is not… | That concern lives in | Join |
|---|---|---|
| the raw sample or the episode | event_raw / episode - the compaction inputs (#21, #23) | profile_id; the score reads rollups derived from them |
| the daily aggregate | streams.rollup - compute_met_rollup writes the met_minutes rollup the movement score sums[10] | (profile_id, day, metric='met_minutes') |
| the target it is scored against | streams.baseline - movement_baseline reads/writes the met_minutes_weekly baseline row[11] | (profile_id, metric='met_minutes_weekly', window_days=28) |
| the MET value per activity | streams.met_activity / activity_alias (#22) - the Compendium-2011 registry resolve_met walks[12] | episode_type / raw text |
| the narrative about the score | finding / insight (#25) - the detectors read the same rollups; scores and findings do not read each other | profile_id, shape |
| the member | streams.profile - party_locator is the only cross-service key; both score tables FK it ON DELETE CASCADE | profile_id → profile.id → party_locator |
| a contribution used by the score | shape_contribution is the projection registry contribution.py reads; the live fold reads rollup values directly (§1, §9) | none on the live path |
There is no locator on any of these tables. movement_score is keyed by (profile_id, as_of), shape_score by (profile_id, shape, window_days), and shape_contribution by a bare serial id with no content key - which is why its seeder wipes and re-inserts (§4). The only stable cross-service identity in this layer is profile.party_locator (a PTY- value), reached through the profile_id FK.
3. Structure
DDL[1][2][3]. There is no Go model and no ORM: the tables are raw SQL inside alembic migrations, and the behaviour lives in src/balance/engine/*.py.
movement_score
| Field | Type | Req | Notes |
|---|---|---|---|
profile_id | uuid | ✓ | PK part; FK profile(id) ON DELETE CASCADE |
as_of | date | ✓ | PK part; the day the score is for (not computed_at) |
score | numeric | clamp(ratio, 0, 1); NULL when there is no baseline yet | |
ratio | numeric | weekly_met_minutes / baseline_weekly_target, unclamped (can exceed 1) | |
weekly_met_minutes | numeric | ✓ | the recency-weighted 7-day weekly-equivalent; NOT NULL, 0.0 when no data (§9) |
baseline_weekly_target | numeric | the member's own target; NULL when baseline_kind='none' | |
baseline_kind | text | ✓ | none / measured / provisional - no DB CHECK (§4) |
calibrating | boolean | ✓ | true unless baseline_kind='measured' |
components | jsonb | ✓ | DEFAULT '{}'; the full derivation blob (§6) - daily series, weights, baseline, spec version |
computed_at | timestamptz | ✓ | DEFAULT now(); when the row was last written |
shape_score
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK, gen_random_uuid() |
profile_id | uuid | ✓ | FK profile(id) ON DELETE CASCADE |
shape | text | ✓ | one of movement / sleep / nutrition / mind / social |
window_days | integer | ✓ | the drill window; caller-supplied, so odd values (32, 91, 130) exist (§9) |
absolute | double precision | folded value / baseline mean, clamped 0..1; NULL while calibrating | |
relative | double precision | absolute / mean(absolute of non-calibrating shapes); NULL when it stands alone | |
calibrating | boolean | ✓ | DEFAULT true; the honesty flag - stays true until the shape has floor data |
computed_at | timestamptz | ✓ | DEFAULT now() |
UNIQUE (profile_id, shape, window_days) |
shape_contribution
| Field | Type | Req | Notes |
|---|---|---|---|
id | integer | ✓ | PK, serial (shape_contribution_id_seq) - synthetic, not content-derived |
source_episode_type | text | the episode type that projects (nullable when the source is metric-based) | |
source_metric | text | the metric that projects (nullable when episode-type-based) | |
predicate | jsonb | optional qualifier predicate (all 7 live rows are NULL) | |
contributes_to | text | ✓ | target shape; indexed |
contribution_type | text | ✓ | magnitude / count / quality |
contribution_metric | text | which metric's value flows | |
weight | double precision | ✓ | DEFAULT 1.0 (one live row is 0.5: mindfulness_practice) |
dedup_key | text | projection-dedup grouping (e.g. movement:workout) |
Field-by-field: what and why
movement_score.score vs ratio. ratio is the raw weekly_met_minutes / baseline_weekly_target, kept unclamped so the app can say "you are at 7.8x your target" honestly; score is that ratio clamped to 0..1 for the ring[4]. Both are NULL together when baseline_kind='none' (no target to divide by). Live, that is exactly what happens: of 471 rows, 252 are none with NULL score, 123 are measured with a score, 96 are provisional with a score.
weekly_met_minutes. Not a simple 7-day sum. compute_movement_score takes the last 7 days of met_minutes rollup, weights each by a fixed recency vector [1.0, 0.9, 0.75, 0.75, 0.6, 0.6, 0.4] (offset 0 = as_of), divides by the weight sum, and multiplies by 7 to get a weekly-equivalent[13]. Each day's met_minutes is itself MET(activity) x active_minutes summed over that day's movement episodes[10], with everyday_movement explicitly barred from earning MET (the steps guard)[14].
baseline_kind and calibrating. The target is chosen by movement_baseline[11]. If the member has >=14 wearable days OR >=5 self-report sessions in the trailing 28 days[15], the baseline is measured: the 28-day weekly average, persisted to streams.baseline and recalibrated on an 8-weekly (56-day) cadence. Otherwise it is provisional: the member's own declared 28-day average, clamped to the WHO band 500..1000 MET-min/week (D-45 #1372 rec B) - never a population average. calibrating is kind != 'measured'. This is the whole honesty posture of the movement blob: a number the member is told is still warming up until it is backed by real measured history.
shape_score.absolute is two formulas in a trench coat. For the four non-movement shapes, absolute = clamp(folded_rollup_value / baseline_mean, 0, 1), where the fold and metric come from a v0 per-shape measure table (movement=sum active_minutes, sleep=mean sleep_duration, nutrition=count meals, mind=mean mood_score, social=count social_contacts)[16]. For the movement shape the loop short-circuits: it calls compute_movement_score and takes its score as the absolute, so the grid cell and the movement blob never disagree[17]. relative normalises absolute against the mean of all non-calibrating shapes for that profile+window[18], so a shape compares itself only against shapes trustworthy enough to compare against.
shape_contribution is a projection map, not a per-score breakdown. Each row says "episodes of type X contribute a contribution_type tuple to shape Y, carrying metric Z at weight W"[3]. It is a small global registry (7 rows total, not 7-per-profile), the DR-4.18 design for letting one episode count toward more than one shape without double-counting. contributions_for_episode reads it, builds the home + projected tuples, and dedups to one per shape[8]; fold reduces the tuples per shape[19]. Both are in place and tested; compute_shape_scores switches on to them when the product needs a per-component breakdown (§9).
4. Invariants
| Invariant | Enforced by |
|---|---|
One movement_score per (profile, day) | DB PK (profile_id, as_of)[1]; the writer upserts ON CONFLICT[20] |
One shape_score per (profile, shape, window) | DB UNIQUE (profile_id, shape, window_days)[2]; writer upserts[18] |
| A score belongs to a real profile | DB FK to streams.profile ON DELETE CASCADE (delete the member, the scores vanish) |
score / absolute in 0..1 | Application - max(0, min(1, ratio))[21] and the shape clamp[22]; no DB CHECK, and NULL is allowed (calibrating / no baseline) |
calibrating = (baseline_kind != 'measured') for movement | Application[23]; for other shapes, calibrating until the shape has anomaly-floor data[24][25] |
baseline_kind is one of none/measured/provisional | Convention - the code only ever writes those three strings; nothing in the DB forbids a fourth |
weekly_met_minutes is never NULL | DB NOT NULL, but satisfied by 0.0 when the member has no MET data (§9) |
| A shape never scores against a calibrating peer | Application - relative denominator is the mean over non-calibrating shapes only[18] |
shape_contribution has one rule per (source, target) | Nothing - id is a bare serial with no content key; the seeder guarantees uniqueness by DELETE + re-INSERT in one tx[26] |
| Row changes captured to CDC | Debezium publication dbz_balance on all three tables (live \d) |
5. Lifecycle
These tables have no status machine - a score is computed, upserted, and overwritten on the next pass. The lifecycle worth drawing is what triggers a recompute and the order of the pass that writes the two score tables.
Every arrow is a real call. recompute_profile is the single engine entry point: it compacts pending raw first, rolls up each day in the window, derives the met_minutes rollup, then computes shape scores[27]. Inside compute_shape_scores, the movement shape calls compute_movement_score, which is the only writer of movement_score[17]; every shape (including movement) then gets a shape_score row[18].
The three triggers:
- Nightly.
_daily_pass(APScheduler cron, default0 4 * * *) recomputes every profile with a live episode in the last 90 days, or pending raw waiting to be compacted[28]. This is why the live data clusters atcomputed_at~04:00 UTC. - On ingest. After a wearable sync or a chat-logged episode,
schedule_on_ingestsubmits a debounced compact+recompute (default 15s after the last push, coalesced so a chunked sync fires once)[29]. The pillar chat/turnroute calls it after storing an episode[30]. - On delete. The incognito-delete route drops the affected days' rollups and calls
recompute_profileto rebuild the derived state[31].
6. Populated example: PTY movement score, walked from the episodes
A live movement_score row - profile 60a6e383-721b-46f4-8ae8-bddb59f9776d, as_of = 2026-08-21. measured, non-calibrating, and the ratio is below 1 so score = ratio uncut. The components blob is real:
{
"baseline": { "kind": "measured", "window_days": 28, "weekly_target": 1963.0625 },
"spec_version": "movement-blob-2026-07-08",
"recency_weights": [1.0, 0.9, 0.75, 0.75, 0.6, 0.6, 0.4],
"daily_met_minutes": {
"2026-08-21": 0.0, "2026-08-20": 0.0, "2026-08-19": 0.0,
"2026-08-18": 105.0, "2026-08-17": 0.0, "2026-08-16": 98.0, "2026-08-15": 108.5
},
"weekly_equivalent": 253.32999999999998
}Stored columns: score = 0.129048, ratio = 0.129048, weekly_met_minutes = 253.33, baseline_weekly_target = 1963.0625, baseline_kind = measured, calibrating = false.
Where each daily MET-minute came from
The daily_met_minutes numbers are met_minutes rollups, and each rollup is MET x active_minutes over that day's walking episodes. walking resolves to MET 3.5 in the Compendium-2011 registry[12]:
| Day | Live walking episodes (active_minutes) | MET x minutes | rollup met_minutes |
|---|---|---|---|
| 2026-08-15 | 13 + 18 = 31 min | 3.5 x 31 | 108.5 ✓ |
| 2026-08-16 | 28 min | 3.5 x 28 | 98.0 ✓ |
| 2026-08-18 | 30 min (see below) | 3.5 x 30 | 105.0 |
The arithmetic, by hand
| Step | Read by | What actually happens |
|---|---|---|
| weight the 7 days | compute_movement_score[13] | 105x0.75 + 98x0.6 + 108.5x0.4 = 180.95 (the three zero days drop out) |
| weekly-equivalent | same | 180.95 / 5.0 (weight sum) x 7 = 253.33 = weekly_met_minutes |
| divide by target | movement_baseline measured[11] | 253.33 / 1963.06 = 0.12905 = ratio |
| clamp | score line[21] | clamp(0.129, 0, 1) = 0.129 (no clamping needed) |
The target 1963.06 is the profile's stored met_minutes_weekly baseline (n = 18 days, computed 2026-07-27), read straight through because it is inside the 56-day recalibration window[11].
The matching shape cell
The same pass wrote a shape_score for this profile:
shape=movement window_days=30 absolute=0.12904836 relative=1.0 calibrating=falseabsolute is identical to movement_score.score because the movement branch copies it[17]. relative = 1.0 because at window 30 this profile's other four shapes (sleep, nutrition, mind, social) are all calibrating with NULL absolute, so movement is the only member of the non-calibrating set and normalises to itself[18]. Live rows confirm it - the grid at window 30 for this profile is one real cell and four blanks.
No shape_contribution row was consulted to produce any of this: compute_shape_scores folded the met_minutes rollup directly. The registry rules that say workout -> movement (magnitude, active_minutes) and step_day -> movement (count, steps) are the breakdown the same cell gains when the fold routes through contribution.py (§9).
The honesty wart this example also shows
2026-08-18 = 105.0 traces to three now-deleted duplicate walking episodes, each 30 minutes. The met_minutes = 105 rollup was written on 2026-08-18 when a live 30-minute walk existed; all three walking episodes for that day are now deleted_at-stamped, but compute_met_rollup only upserts positive rows and never retracts one whose source episodes vanished[10]. So the 2026-08-21 score still counts 105 MET-minutes of walking that no longer has a live episode behind it (§9).
Where it surfaces: GET /shapes returns the grid and folds the latest movement_score into the movement cell's breakdown[32]; GET /shapes/{shape} drills into one shape_score + its findings[33].
7. Who references the scores
| Where | Column / mechanism | Meaning there |
|---|---|---|
GET /shapes (Balance Grid) | fetch_grid reads shape_score + latest movement_score breakdown[34] | the five tiles; movement carries its ratio + target + baseline_kind |
GET /shapes/{shape} (drill) | reads the one shape_score row + baseline + finding[33] | the shape detail card |
GET /movement/history (chart) | reads rollup (met_minutes / active_minutes / steps), not movement_score[35] | the activity time-series; the score's inputs, drawn per day[36] |
| wearable demo tiles | _scores_from_recent_window computes from rollup directly, bypassing both score tables[37] | instant post-import tiles, independent of the calibrating gate |
| analytics / CDC | Debezium dbz_balance streams all three tables | downstream BI; no in-DB consumer |
shape_contribution | contributions_for_episode / fold, exercised by the contribution tests[9] | the DR-4.18 model; activates when compute_shape_scores folds through it (§9) |
None of these are FKs into the score tables - they are same-service reads keyed by profile_id. The scores are a read-model: written by the engine, read by the surfaces, never mutated by a caller.
8. Design determinations
- Recency-weighted 7-day score vs a 28-day recalibrating baseline - #1362; the fixed weight vector and the measured/provisional/none baseline split are the movement blob's core shape[4].
- The baseline is the member's own data, never a population average - measured from >=14 wearable days / >=5 self-report sessions, else provisional clamped to the WHO band. D-45 (#1372)[15].
- MET pinned to Compendium 2011 with provenance - the registry carries
compendium_code+compendium_version, andeveryday_movementis barred from earning MET. D-43 (#1370), D-44 (#1371)[38]. calibratingis a first-class output, not a UI afterthought - stored on both tables, defaultingtrue, cleared only by measured history / floor data. The product refuses to present a confident score it cannot back[6].- The movement shape defers to the movement blob - rather than run the
v0folded-active-minutes formula, the movement cell copiesmovement_score.score, so grid and blob can never diverge[17]. - Scoring is batch, not real-time -
schedule_on_ingestdebounces and defers, the nightly pass does the bulk; build-spec D2's explicit call[29]. - The scoring formula is a seam, and the successor is already modelled -
shapes.pyandconfig.pyboth label thev0measure "product-owned", so the number a shape reports is owned by product and changeable without touching the row. The typed-contribution model (DR-4.18,shape_contribution+contribution.py) is the richer successor, modelled and tested ahead of the product stage that needs a per-component breakdown[8]. Adopting it is configuration and one wiring change, not a migration.
9. Caveats and extensibility
Group and individual. These tables are purely personal: keyed by profile_id, scored against the member's own baseline, with no scheme, employer, or cohort dimension anywhere. A group member and a direct-to-consumer member produce identical score rows; group-ness lives in authorisation and the party graph, never here. No schema change is needed for either cover model - which is also why there is no population-average code path to build (determination 2).
Where to make the change, when the need lands. The scoring layer was modelled a stage ahead of the product: the score row is generic over (profile, shape, window), the formula behind it is a product-owned measure rather than a column, and the typed-contribution registry that will carry per-component breakdowns already exists with its reader. Each row below names the real surface to touch.
| When we need ... | What to add | Where |
|---|---|---|
| a sixth shape or pillar scored (care, finance, ...) | one V0_MEASURES entry + its DEFAULT_MEASURE_ID, and the shape name in the BASELINE_SHAPES tuple | engine/config.py[16] + engine/shapes.py[39]. shape_score is generic per (profile, shape, window_days), so no migration[2]. care is already reserved: shape_measure returns None for it, so it never scores until given a measure |
| the contribution breakdown surfaced to members ("what made this cell") | fold contributions_for_episode output instead of the raw rollup value, and persist the tuples behind the cell | engine/shapes.py[5] reading engine/contribution.py[8][19]. The projection rules, dedup and weights are already modelled in shape_contribution[3]; one episode then counts toward several shapes without double-counting |
one shape's v0 measure replaced by a calibrated formula | a new ShapeMeasure (metric + fold + contribution type), or a GrowthBook balance.shapes.<shape>.measure override to switch between them per environment | engine/config.py[16]. The shape_score row is unchanged by a formula change: absolute / relative / calibrating carry any 0..1 measure[2] |
| a new activity to earn MET | a met_activity row (Compendium code, MET band, aliases) in the registry seed | scripts/seed_registries.py upsert_met_activity[40]; resolve_met picks it up by id / alias with no code change[12] and compute_met_rollup scores it on the next pass[10] |
| a threshold retuned without a deploy | a GrowthBook flag value | recency window, per-(shape, detector) floors, the WHO band, the daily cron, and each shape's measure + weight are all get_flag reads[25][16] |
| a new drill window on the grid | nothing - the window is part of the key | UNIQUE (profile_id, shape, window_days)[2] mints a row family per window the client asks for |
Facts a reader should carry:
weekly_met_minutesNOT NULL is satisfied by0.0. 252 of 471 rows (baseline_kind='none') carryweekly_met_minutes = 0.0and NULL score - a populated column that means "no data yet", not "zero activity".window_daysis caller-controlled, so the key space is ragged. Each distinct window a client asks for mints its own row family: live windows include 30 (default, 75 rows), 130 (45), and one-offs like 32, 33, 91, 93, 95, 120 from?window=strings. There is no canonical-window constraint, which is what makes a new window free (row above) and the census untidy.- Two score generations coexist.
movement_scoreis a fully-recorded, re-derivable blob;shape_score.absolutefor the other four shapes is thev0measure with no stored breakdown. A reader should know which shape they are looking at to know how much the number carries behind it. - Most shape cells are still
calibrating. Live, movement and sleep showcalibrating=false; mind, nutrition and social rows are calibrating with NULL absolute, because thev0measure needs 10 days of anomaly-floor data those shapes do not yet have. Thecalibratingflag exists to say exactly this, and the cells fill as the data arrives (determination 4). shape_contributionholds 7 global rules, reseeded wholesale. Populated byseed_registries.py(DELETE + INSERT in one tx)[26], read bycontribution.pyand its tests[9]. Do not read the 7 rows as the breakdown of a liveshape_score- no score row is derived from them yet.
Known defect, and the fix:
- Rollups are not retracted when their source episodes are deleted.
compute_met_rollupupserts positivemet_minutesrows and never deletes one whose episodes were later soft-deleted, so amovement_scorecan count activity that has no live episode behind it - the2026-08-18 = 105walked in §6[10]. Fix: makecompute_met_rollupwrite the day's computed total unconditionally, including a0(or delete the row) when no live episode carries MET for that day, inengine/movement.py[10]. The incognito-delete path already drops the affected days' rollups before recomputing[31] and is the shape to copy; ordinary supersession and soft-delete do not.
References
Code links are pinned to commit 08e65216 on main (2026-08-28); the file is the anchor if lines drift. Pins are checked mechanically by docs/site/scripts/check-code-refs.py.
balance/migrations/versions/0012_movement_met.py#L41-movement_scoreCREATE TABLE, PK(profile_id, as_of)0007_derived.py#L79-shape_scoreCREATE TABLE, UNIQUE(profile_id, shape, window_days)0003_registries.py#L46-shape_contributionCREATE TABLE + indexesengine/movement.py#L120-compute_movement_score: the whole blobengine/shapes.py#L44-compute_shape_scores: per-shape absolute + relative + persistengine/shapes.py#L1- module docstring: "v0 placeholder formula (product-owned)"data/registries/shape_contribution.json#L1- the 7 seed rows (the entire live population)engine/contribution.py#L34-contributions_for_episode: readsshape_contribution, dedups per shapetests/test_contribution.py#L8- the only live importer ofcontributions_for_episode/foldengine/movement.py#L29-compute_met_rollup:MET x active_minutes, upsert-only (no retract)engine/movement.py#L85-movement_baseline: measured (stored, 56-day recal) vs provisional (WHO-clamped)engine/met.py#L39-resolve_met: registry -> alias -> fuzzy -> episode_type -> category -> defaultengine/movement.py#L124- the recency-weighted weekly-equivalentengine/met.py#L10-NO_MET_TYPES = {"everyday_movement"}, the steps guardengine/movement.py#L67-_meets_measured_minimums: >=14 wearable days OR >=5 self-report sessionsengine/config.py#L21-V0_MEASURES+shape_measure;carereturns Noneengine/shapes.py#L50- movement branch: absolute =movement_score.scoreengine/shapes.py#L78- relative normalisation over non-calibrating shapes + upsertengine/contribution.py#L73-fold: magnitude/count/quality reductionengine/movement.py#L144-movement_scoreupsert ON CONFLICT(profile_id, as_of)engine/movement.py#L133-score = clamp(ratio, 0, 1), NULL when ratio is NULLengine/shapes.py#L69- shapeabsoluteclamp to0..1engine/movement.py#L130-calibrating = kind != "measured"engine/shapes.py#L59- non-movement calibrating gate viahas_floor_dataengine/floors.py#L24-(shape, detector)floors, GrowthBook-overridablescripts/seed_registries.py#L93-reseed_shape_contributions: DELETE + INSERT (no natural key)engine/recompute.py#L20-recompute_profile: compact -> rollup -> met rollup -> shape scoresengine/scheduler.py#L25-_daily_pass: nightly recompute of active profilesengine/scheduler.py#L126-schedule_on_ingest: debounced, coalesced compact+recomputesurfaces/pillar_routes.py#L143- pillar chat/turntriggers rollup +schedule_on_ingestsurfaces/incognito_routes.py#L52- incognito delete drops rollups then recomputesengine/shapes.py#L113-fetch_gridfolds the latestmovement_scoreinto the movement cellsurfaces/visualise_routes.py#L40-GET /shapes/{shape}: readsshape_score+ baseline + findingssurfaces/visualise_routes.py#L26-GET /shapes: the Balance Gridsurfaces/movement_routes.py#L75-GET /movement/history(reads rollup, not the score)read/movement_history.py#L40- the movement chart builder overstreams.rollupsurfaces/wearable_routes.py#L185-_scores_from_recent_window: demo tiles that bypass the score tables0012_movement_met.py#L1- migration header: D-43 (#1370), D-44 (#1371), #1362 provenanceengine/shapes.py#L18-BASELINE_SHAPES: the five-element shape tuple the scorer loopsscripts/seed_registries.py#L69-upsert_met_activity: registry seed,ON CONFLICT (id) DO UPDATE
Live-schema facts (row counts, baseline_kind / calibrating distributions, the worked profile's movement_score + components + shape_score + baseline + rollup + episode rows, the 08-18 deleted-duplicate walking episodes, window_days census, Debezium dbz_balance publication) come from PGPASSWORD=… psql -h 10.0.1.2 -U olly -d balance · \d streams.movement_score, \d streams.shape_score, \d streams.shape_contribution, \d streams.profile, 2026-08-21.
