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>
145 lines
6.4 KiB
JavaScript
145 lines
6.4 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* calibrationService.loadSettledRows — the Fix A1 regression.
|
|
*
|
|
* This reader measured 24.8% corrupt on production (617 of 2,490 rows returned
|
|
* twice, an equal number never returned) via two independent defects: an
|
|
* unordered page walk, and a query error swallowed as end-of-data. Both are
|
|
* locked here against a fake `sb`, so the regression cannot return silently.
|
|
*/
|
|
|
|
const calSvc = require('../../src/services/model/calibrationService');
|
|
|
|
/**
|
|
* A fake PostgREST client for `ledger_entries`. Every filter in the reader's
|
|
* chain is accepted and RECORDED, so a dropped filter fails the test rather than
|
|
* quietly changing the row set.
|
|
*/
|
|
function fakeSb(rows, opts = {}) {
|
|
const state = { rows, filters: [], selected: null, rangeCalls: 0, ordered: [] };
|
|
const q = {
|
|
select(cols) { state.selected = cols; return q; },
|
|
eq(c, v) { state.filters.push(['eq', c, v]); return q; },
|
|
is(c, v) { state.filters.push(['is', c, v]); return q; },
|
|
in(c, v) { state.filters.push(['in', c, v]); return q; },
|
|
not(c, o, v) { state.filters.push(['not', c, o, v]); return q; },
|
|
lt(c, v) { state.filters.push(['lt', c, v]); return q; },
|
|
order(c, o) { state.ordered.push([c, o && o.ascending !== false]); return q; },
|
|
async range(from, to) {
|
|
state.rangeCalls += 1;
|
|
if (opts.errorOnRange && opts.errorOnRange(from)) {
|
|
return { data: null, error: { message: 'connection reset by peer' } };
|
|
}
|
|
if (opts.beforeRange) opts.beforeRange(from, state);
|
|
const key = state.ordered.length ? state.ordered[state.ordered.length - 1][0] : null;
|
|
const out = key
|
|
? [...state.rows].sort((a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0))
|
|
: [...state.rows];
|
|
return { data: out.slice(from, to + 1), error: null };
|
|
},
|
|
};
|
|
return { state, from() { return q; } };
|
|
}
|
|
|
|
const led = (id, p, won, date) => ({
|
|
id, p_win: p, outcome: won ? 'hit' : 'miss', game_date: date, quarantine_reason: null,
|
|
});
|
|
const many = (n) => Array.from({ length: n }, (_, i) => led(i + 1, 0.5 + (i % 40) / 100, i % 2 === 0, '2026-08-01'));
|
|
|
|
describe('loadSettledRows — orders on a UNIQUE key, every page', () => {
|
|
it('returns every row exactly once across a multi-page walk', async () => {
|
|
const sb = fakeSb(many(2490));
|
|
const rows = await calSvc.loadSettledRows(sb, { sport: 'mlb', stat: 'hits', before: '2026-08-09' });
|
|
expect(rows).toHaveLength(2490);
|
|
expect(new Set(rows.map((r) => r.id)).size).toBe(2490);
|
|
expect(sb.state.rangeCalls).toBe(3);
|
|
});
|
|
|
|
it('orders by id on EVERY page, not just the first', async () => {
|
|
const sb = fakeSb(many(2490));
|
|
await calSvc.loadSettledRows(sb, { before: '2026-08-09' });
|
|
expect(sb.state.ordered).toHaveLength(sb.state.rangeCalls);
|
|
for (const [col, asc] of sb.state.ordered) {
|
|
expect(col).toBe('id'); // a UNIQUE key — an order on a non-unique
|
|
expect(asc).toBe(true); // column still lets ties scramble
|
|
}
|
|
});
|
|
|
|
it('selects id — without it the ordering key is not in the payload', async () => {
|
|
const sb = fakeSb(many(10));
|
|
await calSvc.loadSettledRows(sb, { before: '2026-08-09' });
|
|
expect(sb.state.selected).toMatch(/\bid\b/);
|
|
});
|
|
|
|
it('keeps the point-in-time cut and every identity filter', async () => {
|
|
const sb = fakeSb(many(10));
|
|
await calSvc.loadSettledRows(sb, { sport: 'mlb', stat: 'hits', before: '2026-08-09' });
|
|
expect(sb.state.filters).toEqual(expect.arrayContaining([
|
|
['eq', 'sport', 'mlb'],
|
|
['is', 'user_id', null],
|
|
['eq', 'stat', 'hits'],
|
|
['in', 'outcome', ['hit', 'miss']],
|
|
['not', 'p_win', 'is', null],
|
|
['lt', 'game_date', '2026-08-09'], // STRICTLY before — never the answer
|
|
]));
|
|
});
|
|
});
|
|
|
|
describe('loadSettledRows — a failed read THROWS, never a short fit', () => {
|
|
it('throws when a later page errors instead of returning the earlier pages', async () => {
|
|
// The :117 defect returned 1,000 rows here and `build()` fitted a map on
|
|
// them, indistinguishable from a complete fit.
|
|
const sb = fakeSb(many(2490), { errorOnRange: (from) => from === 1000 });
|
|
await expect(calSvc.loadSettledRows(sb, { before: '2026-08-09' }))
|
|
.rejects.toThrow(/read failed at range 1000-1999/);
|
|
});
|
|
|
|
it('propagates through fromLedger — a read failure is not "thin history"', async () => {
|
|
// null means "not enough settled rows to fit"; a throw means "the read
|
|
// broke". Collapsing them is what let a fragment look like a fit.
|
|
const sb = fakeSb(many(2490), { errorOnRange: (from) => from === 1000 });
|
|
await expect(calSvc.fromLedger(sb, { sport: 'mlb', stat: 'hits', before: '2026-08-09' }))
|
|
.rejects.toThrow(/read failed/);
|
|
});
|
|
|
|
it('still returns null (not a throw) when there is genuinely no client', async () => {
|
|
await expect(calSvc.fromLedger(null, {})).resolves.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('loadSettledRows — concurrent append (the production condition)', () => {
|
|
it('returns every pre-existing row exactly once while the cron appends mid-walk', async () => {
|
|
// ledger_entries is written at 14/19/22/1/3 UTC. With a stable ascending
|
|
// unique key, an appended row lands after the cursor: nothing already
|
|
// returned can repeat, nothing pending can be skipped.
|
|
const original = many(2500);
|
|
let nextId = 90000;
|
|
const sb = fakeSb([...original], {
|
|
beforeRange: (from, state) => {
|
|
if (from > 0) state.rows.push(led(nextId++, 0.6, true, '2026-08-08'));
|
|
},
|
|
});
|
|
const rows = await calSvc.loadSettledRows(sb, { before: '2026-08-09' });
|
|
const seen = rows.map((r) => r.id);
|
|
|
|
expect(new Set(seen).size).toBe(seen.length); // zero duplicates
|
|
for (const o of original) expect(seen).toContain(o.id); // zero dropped
|
|
});
|
|
|
|
it('THROWS rather than returning a corrupted set if the walk ever repeats a row', async () => {
|
|
// Belt and braces: if the ordering guarantee were ever lost, the reader
|
|
// refuses instead of handing a duplicated sample to the fit.
|
|
const sb = fakeSb([led(1, 0.5, true, '2026-08-01'), led(1, 0.5, true, '2026-08-01')]);
|
|
await expect(calSvc.loadSettledRows(sb, { before: '2026-08-09' }))
|
|
.rejects.toThrow(/duplicate key \(id\)=1/);
|
|
});
|
|
});
|
|
|
|
describe('Fix A1 does not change what is SERVED', () => {
|
|
it('the deploy set is still empty — no stat serves a calibrated number', () => {
|
|
const snapshotService = require('../../src/services/snapshotService');
|
|
expect(snapshotService.CALIBRATION_DEPLOYED).toEqual([]);
|
|
});
|
|
});
|