MD Reader Open in MD Reader

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)

  1. 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.
  2. 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).
  3. Same consumption path, deliberately. data reuses the exact PercentileTable parallel-array shape so the existing curve() 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.