Read integrity, as-of context, and the shadow matchup resolve (A1-A7)

Seven orders of measurement-first repair. The served grade does not move.

A0/A1 — the unordered page walk returned the right COUNT and the wrong ROWS:
410-617 of 2,490 duplicated with an equal number never returned, while
rows.length matched the server exactly. safePaginate orders on a real unique
key, verifies the tuple at runtime, and THROWS on a query error instead of
treating it as end-of-data. Both hits PROVES are withdrawn: they were drawn
through that reader, and defense_by_direction's distinct-n was likely below
the gate floor all along.

A2/A2b — rolled across every reader: 11 FAIL -> 0. Composite keys pulled from
pg_index (the context tables are dated-composite and had no single unique
column). The unordered helper is deleted, not parked.

A3 — ledgerService and retentionService defaulted the SAME env var to
DIFFERENT versions, so no ledger row ever carried the marker eligibility
requires. One source now. model_snapshots settlement moved onto the cron:
15,484 -> 28,894 settled, repaired-champion 0 -> 7,556.

A4 — hitsFactorContext takes an as-of cutoff. Refusal over reconstruction: no
row at-or-before the date means the factor does not apply, never the nearest
row. Live path unchanged, proven 400/400 on real rows.

A5 — factor_inputs freezes what the factor READ, never the multiplier, so an
audit can recompute and check. It also recorded the finding: the three hits
factors have NEVER fired. prop.opponent and prop.opposing_pitcher are read by
the resolver and written by nothing.

A6/A7 — matchupKeys resolves those keys from the posted lineup plus the
schedule's probable pitchers, and fires the factors into a SHADOW freeze:
248 fires on 308 props, 245 of which would move the grade. The served
forecast is untouched. specs/a8-shadow-factor-gate.md pre-registers the test
that decides whether they ever go live.

Nothing is turned on. CALIBRATION_DEPLOYED stays []. Both verdicts stay
withdrawn. 4,772 tests / 371 suites green, web build exit 0, read-integrity
harness 34/34.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-08-11 22:49:56 -04:00
parent 387ae4d54e
commit f61ec6b391
49 changed files with 4874 additions and 308 deletions
+43 -13
View File
@@ -11,6 +11,8 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const { paginate } = require('../src/utils/safePaginate');
const { uniqueKeyFor } = require('../src/utils/tableKeys');
const { createClient } = require('@supabase/supabase-js');
const hf = require('../src/services/model/hitsFactors');
const lp = require('../src/services/model/lowParamCalibrator');
@@ -22,15 +24,25 @@ const BOX = path.join(process.cwd(), '.seq-cache', 'batting-lines.json');
const SEQ = path.join(process.cwd(), '.seq-cache', 'sequences.json');
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
async function page(sb, t, s, f, orderBy = 'id') {
const o = [];
for (let i = 0; ; i += 1000) {
const { data, error } = await f(sb.from(t).select(s)).order(orderBy, { ascending: true }).range(i, i + 999);
if (error) throw new Error(`${t}: ${error.message}`);
if (!data || !data.length) break; o.push(...data); if (data.length < 1000) break;
}
return o;
// FIX A4 — the context walks here ordered by a NON-UNIQUE prefix
// ('player_key' / 'team'), which leaves the trailing key columns tied and lets
// pages overlap. They now order by the table's real unique tuple.
async function page(sb, t, s, f, key) {
return paginate(() => f(sb.from(t).select(s)),
{ key: key || uniqueKeyFor(t), pageSize: 1000, label: `factor-wiring-audit:${t}` });
}
/**
* AS-OF. `FWA_AS_OF=YYYY-MM-DD` bounds every dated context table to rows at or
* before that date, so an audit of a past slate cannot join a profile built from
* games played after it. Unset = today = the live read, unchanged.
*
* REFUSAL OVER RECONSTRUCTION: an entity with no row at or before the cutoff is
* simply absent from the index, so its factor does not apply. Never the nearest
* row — a substituted row is a plausible wrong value wearing a date.
*/
const AS_OF = process.env.FWA_AS_OF || null;
const dated = (q) => (AS_OF ? q.lte('as_of_date', AS_OF) : q);
const isPreGame = (c, g) => {
const et = new Date(new Date(c).getTime() - 4 * 3600 * 1000);
const d = et.toISOString().slice(0, 10);
@@ -59,15 +71,33 @@ function decompose(rows, bins = 10) {
// Factor inputs.
const [spray, defense, platoon, statcast] = await Promise.all([
page(sb, 'batter_spray', '*', (q) => q.eq('sport', 'mlb'), 'player_key'),
page(sb, 'team_defense', '*', (q) => q.eq('sport', 'mlb'), 'team'),
page(sb, 'platoon_splits', '*', (q) => q.eq('sport', 'mlb'), 'player_key'),
page(sb, 'statcast_aggregates', 'player_key, role, bats, throws, hard_hit_pct', (q) => q.eq('sport', 'mlb'), 'player_key'),
page(sb, 'batter_spray', '*', (q) => dated(q.eq('sport', 'mlb'))),
page(sb, 'team_defense', '*', (q) => dated(q.eq('sport', 'mlb'))),
page(sb, 'platoon_splits', '*', (q) => dated(q.eq('sport', 'mlb'))),
// `statcast_aggregates` keeps ONE as-of date, so it cannot answer an as-of
// question; the dated path reads the retained history instead.
AS_OF
? page(sb, 'statcast_history', 'player_key, source_id, role, bats, throws, hard_hit_pct, as_of_date',
(q) => q.eq('sport', 'mlb').lte('as_of_date', AS_OF))
: page(sb, 'statcast_aggregates', 'player_key, source_id, role, bats, throws, hard_hit_pct',
(q) => q.eq('sport', 'mlb')),
]);
const latest = (rows, k) => { const m = new Map(); for (const r of rows) { const key = r[k]; if (!key) continue; const p = m.get(key); if (!p || String(r.as_of_date) > String(p.as_of_date)) m.set(key, r); } return m; };
const sprayBy = latest(spray, 'player_key'); const defBy = latest(defense, 'team'); const platBy = latest(platoon, 'player_key');
const batBy = new Map(); const pitBy = new Map();
for (const r of statcast) { if (!r.player_key) continue; (r.role === 'pitcher' ? pitBy : batBy).set(r.player_key, r); }
// On the dated path several as-of rows per player are in scope; collapse to
// the latest WITHIN the bound, keyed on (source_id, role) so a two-way player
// keeps both profiles. No-op on the live path (one row per player).
const statcastLatest = AS_OF ? (() => {
const m = new Map();
for (const r of statcast) {
const k = `${r.source_id}|${r.role}`;
const prev = m.get(k);
if (!prev || String(r.as_of_date) > String(prev.as_of_date)) m.set(k, r);
}
return [...m.values()];
})() : statcast;
for (const r of statcastLatest) { if (!r.player_key) continue; (r.role === 'pitcher' ? pitBy : batBy).set(r.player_key, r); }
const frac = (v) => { const n = knownNumber(v); return n === null ? null : (n > 1 ? n / 100 : n); };
// Opponent + starter per (player,date) from the sequence cache.