Skip to content
Updated Aug 22, 2026

Wearable streams: movement & shape scores

Schema deep-dive · living document · #24 in the reading sequence

Tablesstreams.movement_score[1], streams.shape_score[2], streams.shape_contribution[3]
Owner servicebalance (sole writer; Python/FastAPI/asyncpg, no ORM)
Locatornatural key - (profile_id, as_of) / (profile_id, shape, window_days); shape_contribution keys on a serial id and is reseeded wholesale (§4)
Last updated2026-08-28
Companionprevious: 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 inJoin
the raw sample or the episodeevent_raw / episode - the compaction inputs (#21, #23)profile_id; the score reads rollups derived from them
the daily aggregatestreams.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 againststreams.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 activitystreams.met_activity / activity_alias (#22) - the Compendium-2011 registry resolve_met walks[12]episode_type / raw text
the narrative about the scorefinding / insight (#25) - the detectors read the same rollups; scores and findings do not read each otherprofile_id, shape
the memberstreams.profile - party_locator is the only cross-service key; both score tables FK it ON DELETE CASCADEprofile_idprofile.idparty_locator
a contribution used by the scoreshape_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

FieldTypeReqNotes
profile_iduuidPK part; FK profile(id) ON DELETE CASCADE
as_ofdatePK part; the day the score is for (not computed_at)
scorenumericclamp(ratio, 0, 1); NULL when there is no baseline yet
rationumericweekly_met_minutes / baseline_weekly_target, unclamped (can exceed 1)
weekly_met_minutesnumericthe recency-weighted 7-day weekly-equivalent; NOT NULL, 0.0 when no data (§9)
baseline_weekly_targetnumericthe member's own target; NULL when baseline_kind='none'
baseline_kindtextnone / measured / provisional - no DB CHECK (§4)
calibratingbooleantrue unless baseline_kind='measured'
componentsjsonbDEFAULT '{}'; the full derivation blob (§6) - daily series, weights, baseline, spec version
computed_attimestamptzDEFAULT now(); when the row was last written

shape_score

FieldTypeReqNotes
iduuidPK, gen_random_uuid()
profile_iduuidFK profile(id) ON DELETE CASCADE
shapetextone of movement / sleep / nutrition / mind / social
window_daysintegerthe drill window; caller-supplied, so odd values (32, 91, 130) exist (§9)
absolutedouble precisionfolded value / baseline mean, clamped 0..1; NULL while calibrating
relativedouble precisionabsolute / mean(absolute of non-calibrating shapes); NULL when it stands alone
calibratingbooleanDEFAULT true; the honesty flag - stays true until the shape has floor data
computed_attimestamptzDEFAULT now()
UNIQUE (profile_id, shape, window_days)

shape_contribution

FieldTypeReqNotes
idintegerPK, serial (shape_contribution_id_seq) - synthetic, not content-derived
source_episode_typetextthe episode type that projects (nullable when the source is metric-based)
source_metrictextthe metric that projects (nullable when episode-type-based)
predicatejsonboptional qualifier predicate (all 7 live rows are NULL)
contributes_totexttarget shape; indexed
contribution_typetextmagnitude / count / quality
contribution_metrictextwhich metric's value flows
weightdouble precisionDEFAULT 1.0 (one live row is 0.5: mindfulness_practice)
dedup_keytextprojection-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

InvariantEnforced 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 profileDB FK to streams.profile ON DELETE CASCADE (delete the member, the scores vanish)
score / absolute in 0..1Application - 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 movementApplication[23]; for other shapes, calibrating until the shape has anomaly-floor data[24][25]
baseline_kind is one of none/measured/provisionalConvention - the code only ever writes those three strings; nothing in the DB forbids a fourth
weekly_met_minutes is never NULLDB NOT NULL, but satisfied by 0.0 when the member has no MET data (§9)
A shape never scores against a calibrating peerApplication - 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 CDCDebezium 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, default 0 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 at computed_at ~04:00 UTC.
  • On ingest. After a wearable sync or a chat-logged episode, schedule_on_ingest submits a debounced compact+recompute (default 15s after the last push, coalesced so a chunked sync fires once)[29]. The pillar chat /turn route calls it after storing an episode[30].
  • On delete. The incognito-delete route drops the affected days' rollups and calls recompute_profile to 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:

json
{
  "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]:

DayLive walking episodes (active_minutes)MET x minutesrollup met_minutes
2026-08-1513 + 18 = 31 min3.5 x 31108.5
2026-08-1628 min3.5 x 2898.0
2026-08-1830 min (see below)3.5 x 30105.0

The arithmetic, by hand

StepRead byWhat actually happens
weight the 7 dayscompute_movement_score[13]105x0.75 + 98x0.6 + 108.5x0.4 = 180.95 (the three zero days drop out)
weekly-equivalentsame180.95 / 5.0 (weight sum) x 7 = 253.33 = weekly_met_minutes
divide by targetmovement_baseline measured[11]253.33 / 1963.06 = 0.12905 = ratio
clampscore 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=false

absolute 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

WhereColumn / mechanismMeaning 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 / CDCDebezium dbz_balance streams all three tablesdownstream BI; no in-DB consumer
shape_contributioncontributions_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

  1. 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].
  2. 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].
  3. MET pinned to Compendium 2011 with provenance - the registry carries compendium_code + compendium_version, and everyday_movement is barred from earning MET. D-43 (#1370), D-44 (#1371)[38].
  4. calibrating is a first-class output, not a UI afterthought - stored on both tables, defaulting true, cleared only by measured history / floor data. The product refuses to present a confident score it cannot back[6].
  5. The movement shape defers to the movement blob - rather than run the v0 folded-active-minutes formula, the movement cell copies movement_score.score, so grid and blob can never diverge[17].
  6. Scoring is batch, not real-time - schedule_on_ingest debounces and defers, the nightly pass does the bulk; build-spec D2's explicit call[29].
  7. The scoring formula is a seam, and the successor is already modelled - shapes.py and config.py both label the v0 measure "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 addWhere
a sixth shape or pillar scored (care, finance, ...)one V0_MEASURES entry + its DEFAULT_MEASURE_ID, and the shape name in the BASELINE_SHAPES tupleengine/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 cellengine/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 formulaa new ShapeMeasure (metric + fold + contribution type), or a GrowthBook balance.shapes.<shape>.measure override to switch between them per environmentengine/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 METa met_activity row (Compendium code, MET band, aliases) in the registry seedscripts/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 deploya GrowthBook flag valuerecency 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 gridnothing - the window is part of the keyUNIQUE (profile_id, shape, window_days)[2] mints a row family per window the client asks for

Facts a reader should carry:

  • weekly_met_minutes NOT NULL is satisfied by 0.0. 252 of 471 rows (baseline_kind='none') carry weekly_met_minutes = 0.0 and NULL score - a populated column that means "no data yet", not "zero activity".
  • window_days is 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_score is a fully-recorded, re-derivable blob; shape_score.absolute for the other four shapes is the v0 measure 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 show calibrating=false; mind, nutrition and social rows are calibrating with NULL absolute, because the v0 measure needs 10 days of anomaly-floor data those shapes do not yet have. The calibrating flag exists to say exactly this, and the cells fill as the data arrives (determination 4).
  • shape_contribution holds 7 global rules, reseeded wholesale. Populated by seed_registries.py (DELETE + INSERT in one tx)[26], read by contribution.py and its tests[9]. Do not read the 7 rows as the breakdown of a live shape_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_rollup upserts positive met_minutes rows and never deletes one whose episodes were later soft-deleted, so a movement_score can count activity that has no live episode behind it - the 2026-08-18 = 105 walked in §6[10]. Fix: make compute_met_rollup write the day's computed total unconditionally, including a 0 (or delete the row) when no live episode carries MET for that day, in engine/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.

  1. balance/migrations/versions/0012_movement_met.py#L41 - movement_score CREATE TABLE, PK (profile_id, as_of)
  2. 0007_derived.py#L79 - shape_score CREATE TABLE, UNIQUE (profile_id, shape, window_days)
  3. 0003_registries.py#L46 - shape_contribution CREATE TABLE + indexes
  4. engine/movement.py#L120 - compute_movement_score: the whole blob
  5. engine/shapes.py#L44 - compute_shape_scores: per-shape absolute + relative + persist
  6. engine/shapes.py#L1 - module docstring: "v0 placeholder formula (product-owned)"
  7. data/registries/shape_contribution.json#L1 - the 7 seed rows (the entire live population)
  8. engine/contribution.py#L34 - contributions_for_episode: reads shape_contribution, dedups per shape
  9. tests/test_contribution.py#L8 - the only live importer of contributions_for_episode / fold
  10. engine/movement.py#L29 - compute_met_rollup: MET x active_minutes, upsert-only (no retract)
  11. engine/movement.py#L85 - movement_baseline: measured (stored, 56-day recal) vs provisional (WHO-clamped)
  12. engine/met.py#L39 - resolve_met: registry -> alias -> fuzzy -> episode_type -> category -> default
  13. engine/movement.py#L124 - the recency-weighted weekly-equivalent
  14. engine/met.py#L10 - NO_MET_TYPES = {"everyday_movement"}, the steps guard
  15. engine/movement.py#L67 - _meets_measured_minimums: >=14 wearable days OR >=5 self-report sessions
  16. engine/config.py#L21 - V0_MEASURES + shape_measure; care returns None
  17. engine/shapes.py#L50 - movement branch: absolute = movement_score.score
  18. engine/shapes.py#L78 - relative normalisation over non-calibrating shapes + upsert
  19. engine/contribution.py#L73 - fold: magnitude/count/quality reduction
  20. engine/movement.py#L144 - movement_score upsert ON CONFLICT (profile_id, as_of)
  21. engine/movement.py#L133 - score = clamp(ratio, 0, 1), NULL when ratio is NULL
  22. engine/shapes.py#L69 - shape absolute clamp to 0..1
  23. engine/movement.py#L130 - calibrating = kind != "measured"
  24. engine/shapes.py#L59 - non-movement calibrating gate via has_floor_data
  25. engine/floors.py#L24 - (shape, detector) floors, GrowthBook-overridable
  26. scripts/seed_registries.py#L93 - reseed_shape_contributions: DELETE + INSERT (no natural key)
  27. engine/recompute.py#L20 - recompute_profile: compact -> rollup -> met rollup -> shape scores
  28. engine/scheduler.py#L25 - _daily_pass: nightly recompute of active profiles
  29. engine/scheduler.py#L126 - schedule_on_ingest: debounced, coalesced compact+recompute
  30. surfaces/pillar_routes.py#L143 - pillar chat /turn triggers rollup + schedule_on_ingest
  31. surfaces/incognito_routes.py#L52 - incognito delete drops rollups then recomputes
  32. engine/shapes.py#L113 - fetch_grid folds the latest movement_score into the movement cell
  33. surfaces/visualise_routes.py#L40 - GET /shapes/{shape}: reads shape_score + baseline + findings
  34. surfaces/visualise_routes.py#L26 - GET /shapes: the Balance Grid
  35. surfaces/movement_routes.py#L75 - GET /movement/history (reads rollup, not the score)
  36. read/movement_history.py#L40 - the movement chart builder over streams.rollup
  37. surfaces/wearable_routes.py#L185 - _scores_from_recent_window: demo tiles that bypass the score tables
  38. 0012_movement_met.py#L1 - migration header: D-43 (#1370), D-44 (#1371), #1362 provenance
  39. engine/shapes.py#L18 - BASELINE_SHAPES: the five-element shape tuple the scorer loops
  40. scripts/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.

Olly Health Insurance Platform