I've got the full picture now. Here's the detailed review — worth noting first that "the LPI tables" are actually three different things in lumos-backend, and the new cognitive_percentile_tables borrows from the DB-based one, not from LPI proper.
What the existing "LPI tables" actually are
1. LPI percentiles — not database tables at all. lpi-percentiles/index.ts:106 is ~100 static TypeScript files checked into game-score-data, one per game+platform (e.g. color-match-web.ts), each holding Record<ScoreVersion, PercentileTable> where PercentileTable = { scores: number[], percentiles: number[] }. Lookup is getPercentileTable({platform, gameSlug, variant, scoreVersion}) — a nested object access, no SQL. Updating them means a PR; versioning is ScoreVersion ('0.0.0'/'1.0.0'/'2.0.0') plus git history.
2. Age-cohort / fit-test JSON — also static files (age-cohort-percentiles/), rows shaped {output_value, input_value, age_cohort: '0-20', min_age, max_age} with coarse fixed cohorts.
3. The real DB precedents — assessment-api's test_percentile_tables and assessment_percentile_tables. This is what the new table is modeled on (the doc says so in §2/§7).
Column-by-column comparison
| Column | cognitive_percentile_tables (new) |
test_percentile_tables (DB precedent) |
LPI static files |
|---|---|---|---|
id |
UUID, gen_random_uuid() |
UUID, same | — (no DB) |
| entity key | metric_slug + game_slug |
test_slug |
file name = game+platform |
platform |
VARCHAR(16) |
text ✓ |
in file name |
| version | ✗ none | score_version in the lookup key |
ScoreVersion is the key |
| age | age_bucket varchar '25-30' (12 buckets) |
target_age nullable single integer |
none (coarse cohorts live in separate JSON) |
| play count | play_bucket ('p1'…'p500+') — entirely new dimension, no analog anywhere |
✗ | ✗ |
| variant | ✗ | ✗ | GameVariant (Regular/Fittest) — cognitive doesn't need it |
batch_month |
✓ VARCHAR(7) '2026-07' — new, no precedent |
✗ (only created_at latest-wins) |
✗ (git) |
data |
JSONB {scores[], percentiles[]} |
JSONB ✓ same idea | same shape, as TS literals |
metadata |
JSONB with a documented shape: {cohort_size, age_range_widened_to, source_row_count, pipeline_run_id} |
JSONB nullable ✓ exists, shape unspecified | ✗ |
created_at |
✓, latest-wins reader | ✓ same convention | — |
updated_at |
✗ (append-only, consistent) | ✗ | — |
So to answer your direct questions: yes, metadata has precedent — both assessment DB tables already carry a nullable metadata JSONB; the new table just commits to a specific auditing shape (needed because the cohort-size-≥100 floor and age-widening rules are enforced by the pipeline, and you want that decision recorded on the row). batch_month has no precedent — the assessment tables identify a batch only implicitly by created_at. It's added because (a) cognitive_metric_results.table_month and cognitive_profiles.table_month reference which batch scored them, so batch identity must be a stable value, not a timestamp; and (b) retention is DELETE WHERE batch_month < ? (§8.5) — you can't prune safely on created_at alone when a batch's inserts span time.
The substantive differences (beyond columns)
- Static config vs. pipeline-produced data. LPI norms change ~never and ship as code per the house "config in code" rule. Cognitive norms are recomputed monthly by data-eng at ~140k rows per batch — that volume and cadence is exactly why this one is a DB table and doesn't violate the config-in-code rule.
- Key dimensionality: 3 → 5. LPI: game × platform × score-version. Cognitive: metric × game × platform × age_bucket × play_bucket. The two new axes are the science of the feature: age-relative norms and practice-adjusted norms (your 50th play is compared against other people's ~50th play).
- Same consumption path, deliberately.
datareuses the exactPercentileTableparallel-array shape so the existingcurve()interpolation in interpolate.ts can be reused unchanged.
One gap I'd flag before the migration PR
cognitive_percentile_tables has no version column, while its own stated precedent test_percentile_tables puts score_version in the lookup key, and LPI keys everything by ScoreVersion. The doc hand-waves this in §8.4 ("bump algo_version and the pipeline can partition by it") — but there's nothing to partition on: if the metric formula changes, a new batch built from new-algo raws and the old batch are indistinguishable except by batch_month, and during the transition month the post-play lookup can't ask for "the newest table for my algo_version". Adding algo_version VARCHAR(16) to the table (and to the lookup index) would cost nothing now and match both precedents; retrofitting it after batches exist is messier. Also note the doc's own open flag §8.1: the spec sizes for 3 platforms but the backend only models web | mobile — that decision changes platform's cardinality and belongs before the migration ships.