f61ec6b391
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>
124 lines
5.2 KiB
JavaScript
124 lines
5.2 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* lowParamService — the production side of the two-parameter correction.
|
|
*
|
|
* Mirrors calibrationService's interface so the serving path swaps cleanly, but
|
|
* fits a Platt curve instead of an isotonic map. The reason for the swap is
|
|
* capacity, not score: on 19 dates we cannot certify the stability of a map with
|
|
* one free parameter per prediction level, and LODO turned out to have 1.4-9.3%
|
|
* power to tell us otherwise. Two parameters cannot encode "this Tuesday was
|
|
* odd", which is exactly the failure we cannot rule out for isotonic.
|
|
*
|
|
* Stated plainly because it is a judgement rather than a measurement: on the
|
|
* held-out window isotonic scored BETTER than this on hits (+0.0028) and rbi
|
|
* (+0.0042) and tied on total_bases. That window spans 2-4 date blocks, so it is
|
|
* weak evidence either way, and it is consistent with a flexible map having
|
|
* captured structure shared by fit and evaluation periods.
|
|
*
|
|
* Same point-in-time cut as before: fitted ONLY on games that are already over.
|
|
*/
|
|
|
|
const lp = require('./lowParamCalibrator');
|
|
const cal = require('./calibration');
|
|
const { paginate } = require('../../utils/safePaginate');
|
|
const { knownNumber } = require('../../utils/known');
|
|
|
|
const MIN_FIT = 200;
|
|
const HOLDOUT_FRACTION = 0.35;
|
|
|
|
/** Build from settled rows: fit on the older part, certify bands on the newer. */
|
|
function build(rows, opts = {}) {
|
|
const clean = (rows || [])
|
|
.map((r) => ({ p: knownNumber(r.p), won: knownNumber(r.won), date: String(r.date || '') }))
|
|
.filter((r) => r.p !== null && (r.won === 0 || r.won === 1))
|
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
if (clean.length < (opts.minFit ?? MIN_FIT)) return null;
|
|
|
|
const cut = Math.floor(clean.length * (1 - (opts.holdout ?? HOLDOUT_FRACTION)));
|
|
const fitRows = clean.slice(0, cut);
|
|
const certRows = clean.slice(cut);
|
|
if (fitRows.length < (opts.minFit ?? MIN_FIT) || certRows.length < 50) return null;
|
|
|
|
const model = lp.fitPlatt(fitRows, opts);
|
|
// A refused fit (inverting or collapsed slope) yields no calibrator at all.
|
|
if (!model || model.refused) return null;
|
|
|
|
const corrected = certRows
|
|
.map((r) => ({ ...r, p: lp.applyPlatt(model, r.p) }))
|
|
.filter((r) => knownNumber(r.p) !== null);
|
|
const bands = cal.certifyBands(corrected, {
|
|
tolerance: opts.tolerance ?? 0.05,
|
|
minBin: opts.minBin ?? 40,
|
|
});
|
|
|
|
return {
|
|
model,
|
|
bands,
|
|
fit_n: fitRows.length,
|
|
certify_n: certRows.length,
|
|
fitted_through: fitRows[fitRows.length - 1].date,
|
|
shrinkage: model.shrinkage,
|
|
calibrate(p) {
|
|
const raw = knownNumber(p);
|
|
if (raw === null) return { p_raw: null, p_calibrated: null, calibrated: false, reason: 'absent' };
|
|
const c = lp.applyPlatt(model, raw);
|
|
if (c === null) return { p_raw: raw, p_calibrated: null, calibrated: false, reason: 'no_model_value' };
|
|
const inBand = cal.inCertifiedBand(bands, c);
|
|
return {
|
|
p_raw: raw,
|
|
p_calibrated: Math.round(c * 1000) / 1000,
|
|
calibrated: inBand,
|
|
reason: inBand ? null : 'outside_certified_band',
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
function todayEt() {
|
|
return new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
}).format(new Date());
|
|
}
|
|
|
|
/**
|
|
* The ROW LOAD — exported so the read-integrity harness measures THE REAL
|
|
* FUNCTION rather than a restatement of its query.
|
|
*
|
|
* ── FIX A2 (2026-08-09) — THIS READ WAS 24.8% CORRUPT ────────────────────
|
|
* This is the PRIMARY calibrator (calibrationService is only its shadow), and it
|
|
* carried the byte-identical defect A1 fixed there: an unordered `.range()` walk,
|
|
* plus `if (error || !data) break` swallowing a failed read as end-of-data.
|
|
* Measured on production: 617 of 2,490 rows returned twice, an equal number never
|
|
* returned, with `rows.length` matching the server count exactly.
|
|
*
|
|
* Both are now `safePaginate` on the unique `id`. A throw means the read failed;
|
|
* `null` from `fromLedger` still means "not enough settled history to fit". Those
|
|
* are different states and collapsing them is what hid the defect.
|
|
*/
|
|
async function loadSettledRows(sb, { sport = 'mlb', stat = 'hits', before = null } = {}) {
|
|
const cutoff = before || todayEt();
|
|
return paginate(
|
|
() => sb.from('ledger_entries')
|
|
.select('id, p_win, outcome, game_date, quarantine_reason')
|
|
.eq('sport', sport).is('user_id', null).eq('stat', stat)
|
|
.in('outcome', ['hit', 'miss']).not('p_win', 'is', null)
|
|
.lt('game_date', cutoff),
|
|
{ key: 'id', pageSize: 1000, label: `lowParamService.fromLedger(${sport}/${stat})` },
|
|
);
|
|
}
|
|
|
|
/** Load settled history and build, POINT-IN-TIME (strictly before today). */
|
|
async function fromLedger(sb, { sport = 'mlb', stat = 'hits', before = null, ...opts } = {}) {
|
|
if (!sb) return null;
|
|
const cutoff = before || todayEt();
|
|
const rows = await loadSettledRows(sb, { sport, stat, before: cutoff });
|
|
const clean = rows
|
|
.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'))
|
|
.map((r) => ({ p: Number(r.p_win), won: r.outcome === 'hit' ? 1 : 0, date: String(r.game_date) }));
|
|
const built = build(clean, opts);
|
|
return built ? { ...built, cutoff } : null;
|
|
}
|
|
|
|
module.exports = { build, fromLedger, loadSettledRows, MIN_FIT, HOLDOUT_FRACTION };
|