713f90183f
Serving/gating change only. src/services/ untouched: no grade, model or
settlement-logic change. Pricing = Build 2, migration = Build 3.
WHY THE PRIOR GATE WAS WRONG: freeing grades at resolution made the free tier a
ONE-DAY-DELAYED FEED OF THE WHOLE PRODUCT — settlement is nightly, so a bettor
watching one cycle behind got the entire method free. There is now NO
per-grade resolution flip: an itemized grade, tonight's or last week's, is
Analyst+.
FREE now gets, none of it itemizing the nightly slate:
1. the full data aggregator (unchanged — schedule, per-book lines, stats,
streaks, hubs)
2. the AGGREGATE track record, which ALREADY EXISTS and is public:
/api/accuracy (sample 937, byGrade tiers, per-sport mlb+wnba, min_sample 20)
and /api/ledger/accuracy (per-grade buckets). The honest-record laws are
already honored there — A/D/F return pct:null under the n>=20 threshold
rather than a fake percentage.
3. a CAPPED, day-rotated sample of resolved calls for texture: cap 3, stable
within a day, rotates across days, and only RESOLVED rows are eligible so a
live read can never be sampled. The cap is what kills the exploit — three
rotating past calls cannot reconstruct a nightly slate, whereas the full
settled list is the feed one cycle late.
4. the locked shell of tonight's reads: they exist, and their shape.
EVERY itemized grade for an unentitled tier now loses grade, confidence,
confidence_basis, reasoning, kill_conditions_triggered, projection, edge_pct,
matchup_grade, form, alt_lines and kelly, and is stamped locked. Free-side DATA
survives so the board still reads as real: player, market, line, book_odds,
fair_odds (the de-vigged fair number is the free hook and is never the paywall),
season/last10 stats, archetype — and `outcome`, because a RESULT is a fact
rather than a judgment.
The tease stays aggregate-only (live_locked {count, tiers}) computed from the
ungated rows and never joined back to one, and no gated row carries a grade, so
nobody can work out which prop is the A.
Floor: 319 suites / 3970 tests green, web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
88 lines
4.0 KiB
JavaScript
88 lines
4.0 KiB
JavaScript
/**
|
|
* BUILD 1 CORRECTED — itemized grades are PAID (live AND settled).
|
|
* The property that matters: the one-day-behind exploit must be DEAD.
|
|
*/
|
|
const g = require('../../src/utils/snapshotGating');
|
|
|
|
const settled = { player: 'Settled Sam', stat_type: 'points', line: 12.5, book_odds: -110, fair_odds: -104,
|
|
season_avg: 14, grade: 'A', confidence: 75, reasoning: { summary: 'real why' },
|
|
kill_conditions_triggered: [{ code: 'X', reason: 'r' }], projection: 15, edge_pct: 20,
|
|
outcome: { result: 'hit', actual: 14 } };
|
|
const live = { player: 'Live Larry', stat_type: 'assists', line: 5.5, book_odds: -120, fair_odds: -112,
|
|
season_avg: 6, archetype: { primary: { name: 'CONDUCTOR' } }, grade: 'A', confidence: 75,
|
|
confidence_basis: 'grade_band', reasoning: { summary: 'secret why' },
|
|
kill_conditions_triggered: [{ code: 'Y', reason: 'secret' }], projection: 7.1, edge_pct: 29,
|
|
matchup_grade: 'B', form: 'hot', alt_lines: [{}], kelly: { units: 1 } };
|
|
const J = ['grade', 'confidence', 'confidence_basis', 'reasoning', 'kill_conditions_triggered',
|
|
'projection', 'edge_pct', 'matchup_grade', 'form', 'alt_lines', 'kelly'];
|
|
|
|
describe('THE EXPLOIT IS DEAD — settled itemized grades are PAID too', () => {
|
|
const out = g.gateItemizedGrades([settled, live], 'free');
|
|
|
|
test('a SETTLED grade is locked for free — no one-day-delayed feed', () => {
|
|
for (const f of J) expect(out[0][f]).toBeUndefined();
|
|
expect(out[0].locked).toBe(true);
|
|
});
|
|
|
|
test('a LIVE grade is locked for free', () => {
|
|
for (const f of J) expect(out[1][f]).toBeUndefined();
|
|
expect(out[1].locked).toBe(true);
|
|
});
|
|
|
|
test('NEITHER serialized row carries a trace of judgment', () => {
|
|
const json = JSON.stringify(out);
|
|
expect(json).not.toMatch(/real why|secret why|grade_band|hot/);
|
|
});
|
|
|
|
test('free-side DATA survives on both, so the board still reads as real', () => {
|
|
expect(out[0].player).toBe('Settled Sam');
|
|
expect(out[1].fair_odds).toBe(-112); // the fair leg is never the paywall
|
|
expect(out[1].season_avg).toBe(6);
|
|
expect(out[1].archetype).toBeTruthy();
|
|
expect(out[0].outcome).toEqual({ result: 'hit', actual: 14 }); // the RESULT is a fact, not judgment
|
|
});
|
|
});
|
|
|
|
describe('the free SAMPLE is a taste, not the archive', () => {
|
|
const pool = Array.from({ length: 40 }, (_, i) => ({ player: `P${i}`, grade: 'B', outcome: { result: 'hit' } }));
|
|
|
|
test('capped at 3 no matter how large the settled pool', () => {
|
|
expect(g.FREE_SAMPLE_CAP).toBe(3);
|
|
expect(g.freeSample(pool, '2026-07-31')).toHaveLength(3);
|
|
expect(g.freeSample(pool, '2026-07-31').length).toBeLessThan(pool.length);
|
|
});
|
|
|
|
test('rotates by day but is STABLE within a day', () => {
|
|
const a = g.freeSample(pool, '2026-07-30').map((x) => x.player);
|
|
const b = g.freeSample(pool, '2026-07-31').map((x) => x.player);
|
|
expect(a).toEqual(g.freeSample(pool, '2026-07-30').map((x) => x.player)); // stable
|
|
expect(a).not.toEqual(b); // rotates
|
|
});
|
|
|
|
test('only RESOLVED calls can be sampled — never a live read', () => {
|
|
expect(g.freeSample([live, settled], 'd')).toEqual([settled]);
|
|
expect(g.freeSample([live], 'd')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('entitled tiers get everything, untouched', () => {
|
|
test('analyst and desk get the array by reference — live AND archive', () => {
|
|
const rows = [settled, live];
|
|
expect(g.gateItemizedGrades(rows, 'analyst')).toBe(rows);
|
|
expect(g.gateItemizedGrades(rows, 'desk')).toBe(rows);
|
|
expect(g.entitledToItemizedGrades('free')).toBe(false);
|
|
expect(g.entitledToItemizedGrades(undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('the tease shows shape, never which prop', () => {
|
|
test('aggregate count + tiers, and no gated row carries a grade', () => {
|
|
const s = g.liveLockedSummary([settled, live, { grade: 'B' }]);
|
|
expect(s.count).toBe(2); // settled excluded from LIVE count
|
|
expect(s.tiers).toEqual({ A: 1, B: 1 });
|
|
for (const row of g.gateItemizedGrades([live, { grade: 'B' }], 'free')) {
|
|
expect(row.grade).toBeUndefined();
|
|
}
|
|
});
|
|
});
|