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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user