Intraday is a market overlay: it may not restate belief
`grade` is a band label over `p_win` and nothing else. `servedGrade.gradeFor`
reads exactly { p_win, refused, refusal_reason, insufficient_data,
factor_adjustment } -- no line, no odds, no edge, no side -- and
`gradeFreeze.TOLERANCES.MAX_LETTER_MISMATCH` is 0, so the served letter MUST
equal the band of the served probability.
The intraday refresh re-graded an adversely-moved prop AT THE CURRENT LINE and
then copied only `res.grade` onto the row for the LOCKED line, discarding the
p_win that produced it. Two defects in one write: the letter stopped matching
the probability beside it, and the letter answered a different proposition than
the claim it was stamped on.
MEASURED on ledger_entries in the current model era
(engine1@2026-08-07-fullwindow): 30,047 unrevised rows carry ZERO letter/p_win
mismatches; all 9 intraday-revised rows are mismatched. The revision was the
sole producer of incoherent authoritative rows. The pre-cutover era is
uninterpretable here (its `grade` was the engine index, not a p_win band, and
mismatches 49.4% of the time WITHOUT any revision) -- which is exactly why the
control was run before quoting a rate.
Simulated over the 57 historical revisions: in the current era, keeping the
published grade restores coherence on 9 of 9.
So the adverse branch now records the move and stops there. No grader is
invoked, no ledger revision is applied, and the grade-rank comparator is
deleted rather than parked -- a dead comparator beside the code is how the
mutation gets re-wired. A production coherence gate compares every output row
to its input on every BELIEF field and, on any divergence, publishes the
untouched slate instead.
History is untouched: the 57 existing `revised_from_grade` rows keep their
values, and the UI that renders them is unchanged. Forward-only.
Lineage is not touched and stays OFF.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
@@ -9,12 +9,30 @@
|
|||||||
*
|
*
|
||||||
* moved WITH the grade (market chasing our number) → STEAM ▲ +N badge.
|
* moved WITH the grade (market chasing our number) → STEAM ▲ +N badge.
|
||||||
* Good for the record; entry edge compressed. NO re-grade.
|
* Good for the record; entry edge compressed. NO re-grade.
|
||||||
* moved AGAINST ≥ 1.0 → re-grade THAT
|
* moved AGAINST → AGAINST, recorded
|
||||||
* PROP ONLY at the current line:
|
* with the signed delta and the real current line. NO RE-GRADE.
|
||||||
* grade holds → VALUE ▲ (better number, same read)
|
*
|
||||||
* grade drops → PUBLIC revision: grade updates with
|
* -- WHY THE RE-GRADE IS GONE (INTRADAY BELIEF INTEGRITY) ----------------
|
||||||
* revised_from_grade set (original preserved, struck through in
|
* `grade` is not a market field. `servedGrade.gradeFor` reads exactly
|
||||||
* the UI + ledger). Never a silent regrade — Ledger ethos.
|
* { p_win, refused, refusal_reason, insufficient_data, factor_adjustment } —
|
||||||
|
* no line, no odds, no edge, no side — so the letter is a BAND LABEL OVER
|
||||||
|
* p_win and nothing else. `gradeFreeze.TOLERANCES.MAX_LETTER_MISMATCH` is 0:
|
||||||
|
* the served letter MUST equal the band of the served probability.
|
||||||
|
*
|
||||||
|
* The old branch re-graded at the CURRENT line and then wrote only
|
||||||
|
* `res.grade` onto the row for the LOCKED line, discarding the p_win that
|
||||||
|
* produced it. That is two defects at once: the letter no longer matched the
|
||||||
|
* probability beside it, and the letter answered a DIFFERENT proposition
|
||||||
|
* (line L2) than the claim it was stamped on (line L1).
|
||||||
|
*
|
||||||
|
* MEASURED on `ledger_entries` in the current model era
|
||||||
|
* (engine1@2026-08-07-fullwindow): 30,047 unrevised rows had ZERO
|
||||||
|
* letter/p_win mismatches; all 9 intraday-revised rows were mismatched.
|
||||||
|
* The revision was the sole producer of incoherent authoritative rows.
|
||||||
|
*
|
||||||
|
* So intraday is MARKET-OVERLAY ONLY. It may record what the market did; it
|
||||||
|
* may not restate what the model believes. A new belief needs a new published
|
||||||
|
* claim, which is a snapshot's job, not a refresh's.
|
||||||
*
|
*
|
||||||
* Every displayed line stays a REAL book value from this refresh — the
|
* Every displayed line stays a REAL book value from this refresh — the
|
||||||
* refresh CAPTURES market numbers, never computes them. Each run also
|
* refresh CAPTURES market numbers, never computes them. Each run also
|
||||||
@@ -24,8 +42,7 @@
|
|||||||
* QUOTA MATH (zero out-of-pocket): one getOdds call per sport per run.
|
* QUOTA MATH (zero out-of-pocket): one getOdds call per sport per run.
|
||||||
* 20-min cadence × 12 slate hours = 36 runs/day/sport × 4 sports =
|
* 20-min cadence × 12 slate hours = 36 runs/day/sport × 4 sports =
|
||||||
* ≤144 PropLine requests/day — against 9,000/day free capacity (3 keys ×
|
* ≤144 PropLine requests/day — against 9,000/day free capacity (3 keys ×
|
||||||
* 3,000). Re-grades are internal feature computation (free) and bounded
|
* 3,000). No grading is invoked at all — the refresh reads the market.
|
||||||
* to props that moved against ≥ 1.0.
|
|
||||||
*
|
*
|
||||||
* Everything injectable → unit-tested with zero network.
|
* Everything injectable → unit-tested with zero network.
|
||||||
*/
|
*/
|
||||||
@@ -40,8 +57,50 @@ const { isModelBook } = require('../config/bookRoles');
|
|||||||
|
|
||||||
const HISTORY_CAP = 24; // {t, line} points per grade (S6 sparklines)
|
const HISTORY_CAP = 24; // {t, line} points per grade (S6 sparklines)
|
||||||
|
|
||||||
const GRADE_RANK = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10 };
|
/**
|
||||||
const rank = (g) => (g && GRADE_RANK[g] !== undefined ? GRADE_RANK[g] : 99);
|
* FIELDS THIS REFRESH MAY NEVER WRITE.
|
||||||
|
*
|
||||||
|
* Kept as a LOCAL constant rather than imported from the lineage taxonomy so
|
||||||
|
* the market path carries no dependency on the shadow recorder — but a test
|
||||||
|
* cross-checks it against `readLineage.CLAIM_FIELD_CLASSES` BELIEF, so drift
|
||||||
|
* between the two is a suite failure rather than a silent divergence.
|
||||||
|
*
|
||||||
|
* `line` and `direction` are included because they identify WHICH proposition
|
||||||
|
* the belief is about: changing either would move the claim to a different
|
||||||
|
* question rather than answer the same one differently. The real current line
|
||||||
|
* is reported as `movement.currentLine`, never by overwriting the locked one.
|
||||||
|
*/
|
||||||
|
const BELIEF_FIELDS = Object.freeze([
|
||||||
|
'grade', 'engine_grade', 'served_grade', 'revised_from_grade',
|
||||||
|
'p_win', 'projection', 'confidence', 'confidence_basis',
|
||||||
|
'fair_prob', 'fair_odds', 'model_odds', 'kelly',
|
||||||
|
'refused', 'insufficient_data', 'refusal_reason',
|
||||||
|
'line', 'direction',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count fields the overlay changed that it had no right to change.
|
||||||
|
* Returns the violations rather than a bare boolean — a guard that cannot name
|
||||||
|
* what it caught is hard to act on. Used by the refresh itself (reported as
|
||||||
|
* `belief_mutations`) and asserted at 0 by the coherence suite.
|
||||||
|
*/
|
||||||
|
function beliefMutations(before, after) {
|
||||||
|
const out = [];
|
||||||
|
const b = before || {};
|
||||||
|
const a = after || {};
|
||||||
|
for (const f of BELIEF_FIELDS) {
|
||||||
|
const x = b[f] === undefined ? null : b[f];
|
||||||
|
const y = a[f] === undefined ? null : a[f];
|
||||||
|
if (JSON.stringify(x) !== JSON.stringify(y)) out.push({ field: f, before: x, after: y });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: the grade-rank comparison table that used to live here is GONE, not
|
||||||
|
// parked. Under the market-overlay contract nothing in this file may compare or
|
||||||
|
// choose a letter, and a dead comparator sitting beside the code is how the
|
||||||
|
// mutation gets re-wired. The guard against its return is the coherence suite,
|
||||||
|
// not a helper kept warm.
|
||||||
|
|
||||||
const sideOver = (dir) => String(dir || 'over').toLowerCase() !== 'under';
|
const sideOver = (dir) => String(dir || 'over').toLowerCase() !== 'under';
|
||||||
|
|
||||||
@@ -104,13 +163,14 @@ function signedDelta(side, lockedLine, currentLine) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Run one intraday refresh for a sport. Returns
|
* Run one intraday refresh for a sport. Returns
|
||||||
* { sport, status, checked, steam, value, revised } — never throws.
|
* { sport, status, checked, steam, against, belief_mutations } — never throws.
|
||||||
|
* `status: 'blocked'` means the coherence gate refused to publish an overlay
|
||||||
|
* that would have mutated belief; the previous slate is left standing.
|
||||||
*/
|
*/
|
||||||
async function runIntradayRefresh(sport, opts = {}) {
|
async function runIntradayRefresh(sport, opts = {}) {
|
||||||
const sp = String(sport || '').toLowerCase();
|
const sp = String(sport || '').toLowerCase();
|
||||||
const deps = {
|
const deps = {
|
||||||
getOdds: opts.getOdds || require('./oddsService').getOdds,
|
getOdds: opts.getOdds || require('./oddsService').getOdds,
|
||||||
analyze: opts.analyze || require('./intelligence/analyzeViaEngine1').analyzeViaEngine1,
|
|
||||||
cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
|
cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
|
||||||
cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
|
cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
|
||||||
ledger: opts.ledger || require('./ledgerService'),
|
ledger: opts.ledger || require('./ledgerService'),
|
||||||
@@ -121,22 +181,22 @@ async function runIntradayRefresh(sport, opts = {}) {
|
|||||||
|
|
||||||
const snap = await deps.cacheGet(`snapshot:${sp}:latest`);
|
const snap = await deps.cacheGet(`snapshot:${sp}:latest`);
|
||||||
if (!snap || !Array.isArray(snap.grades) || snap.grades.length === 0) {
|
if (!snap || !Array.isArray(snap.grades) || snap.grades.length === 0) {
|
||||||
return { sport: sp, status: 'skipped', reason: 'no snapshot', checked: 0, steam: 0, value: 0, revised: 0 };
|
return { sport: sp, status: 'skipped', reason: 'no snapshot', checked: 0, steam: 0, against: 0, belief_mutations: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
let odds;
|
let odds;
|
||||||
try {
|
try {
|
||||||
odds = await deps.getOdds(sp);
|
odds = await deps.getOdds(sp);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { sport: sp, status: 'error', reason: e.message, checked: 0, steam: 0, value: 0, revised: 0 };
|
return { sport: sp, status: 'error', reason: e.message, checked: 0, steam: 0, against: 0, belief_mutations: 0 };
|
||||||
}
|
}
|
||||||
const props = odds && Array.isArray(odds.props) ? odds.props : [];
|
const props = odds && Array.isArray(odds.props) ? odds.props : [];
|
||||||
if (props.length === 0) {
|
if (props.length === 0) {
|
||||||
return { sport: sp, status: 'skipped', reason: 'no odds', checked: 0, steam: 0, value: 0, revised: 0 };
|
return { sport: sp, status: 'skipped', reason: 'no odds', checked: 0, steam: 0, against: 0, belief_mutations: 0 };
|
||||||
}
|
}
|
||||||
const byKey = indexOddsProps(props);
|
const byKey = indexOddsProps(props);
|
||||||
|
|
||||||
let steam = 0; let value = 0; let revised = 0; let checked = 0;
|
let steam = 0; let against = 0; let checked = 0;
|
||||||
const moveEvents = [];
|
const moveEvents = [];
|
||||||
const grades = [];
|
const grades = [];
|
||||||
|
|
||||||
@@ -171,47 +231,46 @@ async function runIntradayRefresh(sport, opts = {}) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moved AGAINST the grade.
|
// Moved AGAINST the grade — recorded, never re-graded.
|
||||||
if (Math.abs(delta) < REGRADE_TRIGGER) {
|
//
|
||||||
|
// A market move is a fact about the market. It does not tell us the model
|
||||||
|
// now believes something different; it tells us the price of the same
|
||||||
|
// belief changed. The magnitude rides on `delta` and the real book number
|
||||||
|
// on `currentLine`, so nothing is lost by declining to relabel the letter.
|
||||||
|
against += 1;
|
||||||
grades.push(withHist({ ...g, movement: { kind: 'against', delta, currentLine: current, at: ts } }));
|
grades.push(withHist({ ...g, movement: { kind: 'against', delta, currentLine: current, at: ts } }));
|
||||||
continue;
|
// A big move is still newsworthy: the ticker event fires at the same
|
||||||
}
|
// threshold it always did.
|
||||||
|
if (Math.abs(delta) >= REGRADE_TRIGGER) {
|
||||||
// ≥ 1.0 against → re-grade THIS PROP ONLY at the current (real) line.
|
|
||||||
let res = null;
|
|
||||||
try {
|
|
||||||
res = await deps.analyze({
|
|
||||||
player, stat_type: stat, line: current,
|
|
||||||
direction: g.direction || 'over', sport: sp, book: prop.book,
|
|
||||||
});
|
|
||||||
} catch { /* re-grade unavailable → treated as hold below */ }
|
|
||||||
moveEvents.push(moveEvent(sp, g, locked, current, ts));
|
moveEvents.push(moveEvent(sp, g, locked, current, ts));
|
||||||
|
}
|
||||||
if (!res || !res.grade || res.insufficient_data || rank(res.grade) <= rank(g.grade)) {
|
|
||||||
// Grade holds (or the model refuses to re-read) → better entry, same read.
|
|
||||||
value += 1;
|
|
||||||
grades.push(withHist({ ...g, movement: { kind: 'value', delta, currentLine: current, at: ts } }));
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grade DROPS → public revision. Original grade preserved once, forever.
|
// COHERENCE GATE. Every output row is compared to the row it came from on
|
||||||
revised += 1;
|
// every belief field. This is not a test double's assertion — it runs in
|
||||||
const fromGrade = g.revised_from_grade || g.grade;
|
// production and reports itself, because the failure it guards against
|
||||||
grades.push(withHist({
|
// (a new letter beside an old probability) is invisible in any single row.
|
||||||
...g,
|
const beliefIndex = new Map();
|
||||||
grade: res.grade,
|
for (const g of snap.grades) {
|
||||||
revised_from_grade: fromGrade,
|
const k = `${nameKey(g.player || g.player_name)}|${String(g.stat_type || g.stat || '').toLowerCase()}|${g.line}|${g.direction}`;
|
||||||
movement: { kind: 'revised', delta, currentLine: current, at: ts },
|
if (!beliefIndex.has(k)) beliefIndex.set(k, g);
|
||||||
}));
|
|
||||||
try {
|
|
||||||
await deps.ledger.applyRevision(sp, {
|
|
||||||
playerKey: nameKey(player), stat, line: Number(locked),
|
|
||||||
side: sideOver(g.direction) ? 'over' : 'under',
|
|
||||||
newGrade: res.grade, fromGrade,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[intraday] ledger revision failed for ${player}:`, e.message);
|
|
||||||
}
|
}
|
||||||
|
const violations = [];
|
||||||
|
for (const g of grades) {
|
||||||
|
const k = `${nameKey(g.player || g.player_name)}|${String(g.stat_type || g.stat || '').toLowerCase()}|${g.line}|${g.direction}`;
|
||||||
|
const before = beliefIndex.get(k);
|
||||||
|
if (!before) continue;
|
||||||
|
for (const v of beliefMutations(before, g)) violations.push({ key: k, ...v });
|
||||||
|
}
|
||||||
|
if (violations.length > 0) {
|
||||||
|
// Fail CLOSED: publish the untouched slate rather than an incoherent one.
|
||||||
|
// The overlay is a convenience; a torn authoritative Read is not.
|
||||||
|
console.warn(`[intraday] BELIEF MUTATION BLOCKED for ${sp}: ${violations.length} field(s)`,
|
||||||
|
JSON.stringify(violations.slice(0, 5)));
|
||||||
|
return {
|
||||||
|
sport: sp, status: 'blocked', reason: 'belief_mutation',
|
||||||
|
checked, steam, against, belief_mutations: violations.length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write back: refreshed movement state + the higher-fidelity close.
|
// Write back: refreshed movement state + the higher-fidelity close.
|
||||||
@@ -249,7 +308,7 @@ async function runIntradayRefresh(sport, opts = {}) {
|
|||||||
await deps.pushTickerItems(moveEvents.slice(0, TICKER_MOVE_CAP), deps);
|
await deps.pushTickerItems(moveEvents.slice(0, TICKER_MOVE_CAP), deps);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sport: sp, status: 'ok', checked, steam, value, revised };
|
return { sport: sp, status: 'ok', checked, steam, against, belief_mutations: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveEvent(sport, g, locked, current, ts) {
|
function moveEvent(sport, g, locked, current, ts) {
|
||||||
@@ -283,5 +342,7 @@ module.exports = {
|
|||||||
runIntradayRefresh,
|
runIntradayRefresh,
|
||||||
runAllIntradayRefreshes,
|
runAllIntradayRefreshes,
|
||||||
inSlateHours,
|
inSlateHours,
|
||||||
__internals: { signedDelta, indexOddsProps, moveEvent, rank, trackHistory, STEAM_NOISE, REGRADE_TRIGGER, HISTORY_CAP },
|
BELIEF_FIELDS,
|
||||||
|
beliefMutations,
|
||||||
|
__internals: { signedDelta, indexOddsProps, moveEvent, trackHistory, beliefMutations, BELIEF_FIELDS, STEAM_NOISE, REGRADE_TRIGGER, HISTORY_CAP },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -383,8 +383,13 @@ function startSnapshotScheduler(opts = {}) {
|
|||||||
// Soccer is excluded: a 20-min odds-api refresh would drain the 500/mo key.
|
// Soccer is excluded: a 20-min odds-api refresh would drain the 500/mo key.
|
||||||
const results = await refreshAll({ sports: cadence.intradaySports() });
|
const results = await refreshAll({ sports: cadence.intradaySports() });
|
||||||
const touched = results.filter((r) => r.status === 'ok');
|
const touched = results.filter((r) => r.status === 'ok');
|
||||||
const revised = results.reduce((n, r) => n + (r.revised || 0), 0);
|
// Intraday is a MARKET OVERLAY: it reports what the market did, never a
|
||||||
if (touched.length > 0) console.log(`[intraday] refresh — ${touched.length} sports, ${revised} public revisions`);
|
// revised belief. `blocked` is the coherence gate refusing to publish an
|
||||||
|
// overlay that would have mutated a published claim — it must be visible.
|
||||||
|
const against = results.reduce((n2, r) => n2 + (r.against || 0), 0);
|
||||||
|
const blocked = results.filter((r) => r.status === 'blocked').length;
|
||||||
|
if (touched.length > 0) console.log(`[intraday] refresh — ${touched.length} sports, ${against} adverse moves recorded`);
|
||||||
|
if (blocked > 0) console.warn(`[intraday] ${blocked} sport(s) BLOCKED by the belief-coherence gate`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[intraday] refresh failed:', e.message);
|
console.warn('[intraday] refresh failed:', e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
// INTRADAY BELIEF INTEGRITY — the coherence contract.
|
||||||
|
//
|
||||||
|
// `grade` is a BAND LABEL OVER p_win: `servedGrade.gradeFor` reads only
|
||||||
|
// { p_win, refused, refusal_reason, insufficient_data, factor_adjustment }.
|
||||||
|
// `gradeFreeze.TOLERANCES.MAX_LETTER_MISMATCH` is 0, so a served letter that
|
||||||
|
// disagrees with the served probability is already a frozen violation.
|
||||||
|
//
|
||||||
|
// The intraday refresh is therefore a MARKET OVERLAY: it may record what the
|
||||||
|
// market did and may not restate what the model believes. These tests assert
|
||||||
|
// that no authoritative Read can leave a refresh with a new letter beside an
|
||||||
|
// old probability.
|
||||||
|
|
||||||
|
const intraday = require('../../src/services/intradayRefreshService');
|
||||||
|
const { runIntradayRefresh, BELIEF_FIELDS, beliefMutations } = intraday;
|
||||||
|
const servedGrade = require('../../src/services/model/servedGrade');
|
||||||
|
const gradeFreeze = require('../../src/services/evaluator/gradeFreeze');
|
||||||
|
const readLineage = require('../../src/services/read/readLineage');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const NOW = '2026-08-28T21:00:00.000Z';
|
||||||
|
const SRC = path.join(__dirname, '../../src/services/intradayRefreshService.js');
|
||||||
|
|
||||||
|
function harness({ grades, oddsProps, analyzeResult, ledgerThrows }) {
|
||||||
|
const store = new Map([['snapshot:mlb:latest', { sport: 'mlb', updated_at: NOW, grades, deltas: [] }]]);
|
||||||
|
const calls = { analyze: [], revisions: [], closing: 0, ticker: [] };
|
||||||
|
const deps = {
|
||||||
|
cacheGet: async (k) => store.get(k) ?? null,
|
||||||
|
cacheSet: async (k, v) => { store.set(k, v); },
|
||||||
|
getOdds: async () => ({ props: oddsProps }),
|
||||||
|
// Injected and expected to stay untouched. A dep that is present and never
|
||||||
|
// called is stronger evidence than a dep that was deleted.
|
||||||
|
analyze: async (p) => { calls.analyze.push(p); return analyzeResult; },
|
||||||
|
ledger: {
|
||||||
|
applyRevision: async (sport, rev) => {
|
||||||
|
calls.revisions.push(rev);
|
||||||
|
if (ledgerThrows) throw new Error('ledger down');
|
||||||
|
return { revised: true };
|
||||||
|
},
|
||||||
|
captureClosing: async () => { calls.closing += 1; return { updated: 0 }; },
|
||||||
|
},
|
||||||
|
pushTickerItems: async (ev) => { calls.ticker.push(...ev); },
|
||||||
|
now: () => NOW,
|
||||||
|
};
|
||||||
|
return { deps, store, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real published claim: p_win 0.900 -> the band table says B+.
|
||||||
|
const CLAIM = {
|
||||||
|
player: 'Aaron Judge', stat_type: 'home_runs', line: 1.5, direction: 'over',
|
||||||
|
p_win: 0.9, grade: 'B+', confidence: 90, confidence_basis: 'p_win',
|
||||||
|
projection: 1.9, fair_prob: 0.55, fair_odds: -122,
|
||||||
|
gradedAt: { line: 1.5, odds: -115, timestamp: NOW },
|
||||||
|
};
|
||||||
|
const prop = (line) => ({ player: 'Aaron Judge', stat_type: 'home_runs', line, book: 'draftkings' });
|
||||||
|
|
||||||
|
describe('the grade contract: a letter is a band over p_win', () => {
|
||||||
|
test('gradeFor reads no market input — only belief', () => {
|
||||||
|
const base = { p_win: 0.9 };
|
||||||
|
const letter = servedGrade.gradeFor(base).letter;
|
||||||
|
// Every market term the refresh could move, added and varied. None may
|
||||||
|
// change the letter, because none is an input.
|
||||||
|
for (const extra of [{ line: 0.5 }, { line: 99 }, { odds: -500 }, { book: 'fanduel' },
|
||||||
|
{ edge_pct: 40 }, { side: 'under' }, { currentLine: 12 }]) {
|
||||||
|
expect(servedGrade.gradeFor({ ...base, ...extra }).letter).toBe(letter);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the served letter is exactly the frozen band of the served p_win', () => {
|
||||||
|
for (let p = 0; p <= 1000; p += 1) {
|
||||||
|
const pw = p / 1000;
|
||||||
|
expect(servedGrade.gradeFor({ p_win: pw }).letter).toBe(gradeFreeze.expectedLetter(pw));
|
||||||
|
}
|
||||||
|
expect(gradeFreeze.TOLERANCES.MAX_LETTER_MISMATCH).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('BELIEF_FIELDS does not drift from the repository claim taxonomy', () => {
|
||||||
|
const lineageBelief = Object.entries(readLineage.CLAIM_FIELD_CLASSES)
|
||||||
|
.filter(([, c]) => c === readLineage.FIELD_CLASS.BELIEF)
|
||||||
|
.map(([f]) => f);
|
||||||
|
// Every field lineage calls BELIEF must be protected here. This module may
|
||||||
|
// protect MORE (line/direction identify which proposition the belief is
|
||||||
|
// about), but it may never protect fewer.
|
||||||
|
for (const f of lineageBelief) expect(BELIEF_FIELDS).toContain(f);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('intraday is a market overlay and may not mutate belief', () => {
|
||||||
|
test('sub-threshold move: no movement chip, belief untouched', async () => {
|
||||||
|
const { deps, store, calls } = harness({ grades: [CLAIM], oddsProps: [prop(1.8)], analyzeResult: { grade: 'F' } });
|
||||||
|
const res = await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(res).toMatchObject({ status: 'ok', steam: 0, against: 0, belief_mutations: 0 });
|
||||||
|
expect(store.get('snapshot:mlb:latest').grades[0].movement).toBeNull();
|
||||||
|
expect(calls.analyze).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('large ADVERSE move: recorded, belief frozen, grader never called', async () => {
|
||||||
|
// OVER 1.5 -> the book drops the line to 0.5: the market is moving AGAINST
|
||||||
|
// the graded side (our locked number is now the worse one).
|
||||||
|
const { deps, store, calls } = harness({ grades: [CLAIM], oddsProps: [prop(0.5)], analyzeResult: { grade: 'F', p_win: 0.1 } });
|
||||||
|
const res = await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(res).toMatchObject({ against: 1, belief_mutations: 0 });
|
||||||
|
const g = store.get('snapshot:mlb:latest').grades[0];
|
||||||
|
expect(g.movement).toMatchObject({ kind: 'against', currentLine: 0.5 });
|
||||||
|
expect(beliefMutations(CLAIM, g)).toEqual([]);
|
||||||
|
expect(calls.analyze).toHaveLength(0);
|
||||||
|
expect(calls.revisions).toHaveLength(0);
|
||||||
|
expect(calls.ticker.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('large FAVOURABLE move: STEAM, belief frozen', async () => {
|
||||||
|
const { deps, store } = harness({ grades: [CLAIM], oddsProps: [prop(3.5)], analyzeResult: { grade: 'A' } });
|
||||||
|
const res = await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(res).toMatchObject({ steam: 1, belief_mutations: 0 });
|
||||||
|
const g = store.get('snapshot:mlb:latest').grades[0];
|
||||||
|
expect(g.movement.kind).toBe('steam');
|
||||||
|
expect(beliefMutations(CLAIM, g)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a grader that would IMPROVE the grade changes nothing', async () => {
|
||||||
|
const { deps, store } = harness({
|
||||||
|
grades: [{ ...CLAIM, p_win: 0.5, grade: 'C', confidence: 50 }],
|
||||||
|
oddsProps: [prop(0.5)], analyzeResult: { grade: 'B+', p_win: 0.82 },
|
||||||
|
});
|
||||||
|
await runIntradayRefresh('mlb', deps);
|
||||||
|
const g = store.get('snapshot:mlb:latest').grades[0];
|
||||||
|
expect(g.grade).toBe('C');
|
||||||
|
expect(g.p_win).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a grader that returns grade AND p_win together still changes nothing', async () => {
|
||||||
|
const { deps, store } = harness({
|
||||||
|
grades: [CLAIM], oddsProps: [prop(0.5)],
|
||||||
|
analyzeResult: { grade: 'D', p_win: 0.36, projection: 0.4, confidence: 36 },
|
||||||
|
});
|
||||||
|
await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(beliefMutations(CLAIM, store.get('snapshot:mlb:latest').grades[0])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refusing grader changes nothing and never blanks the letter', async () => {
|
||||||
|
const { deps, store } = harness({
|
||||||
|
grades: [CLAIM], oddsProps: [prop(0.5)],
|
||||||
|
analyzeResult: { grade: null, insufficient_data: true },
|
||||||
|
});
|
||||||
|
await runIntradayRefresh('mlb', deps);
|
||||||
|
const g = store.get('snapshot:mlb:latest').grades[0];
|
||||||
|
expect(g.grade).toBe('B+');
|
||||||
|
expect(g.refused).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing market data: prop frozen exactly as published', async () => {
|
||||||
|
const { deps, store } = harness({ grades: [CLAIM], oddsProps: [], analyzeResult: null });
|
||||||
|
const res = await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(res.status).toBe('skipped');
|
||||||
|
expect(store.get('snapshot:mlb:latest').grades[0]).toEqual(CLAIM);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an already-revised historical row keeps its history and gains no new one', async () => {
|
||||||
|
const hist = { ...CLAIM, grade: 'C', revised_from_grade: 'B+' };
|
||||||
|
const { deps, store } = harness({ grades: [hist], oddsProps: [prop(0.5)], analyzeResult: { grade: 'F' } });
|
||||||
|
await runIntradayRefresh('mlb', deps);
|
||||||
|
const g = store.get('snapshot:mlb:latest').grades[0];
|
||||||
|
expect(g.revised_from_grade).toBe('B+');
|
||||||
|
expect(g.grade).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeated refreshes never accumulate belief drift', async () => {
|
||||||
|
let grades = [CLAIM];
|
||||||
|
for (const line of [3.5, 4.5, 0.5, 2.5]) {
|
||||||
|
const { deps, store } = harness({ grades, oddsProps: [prop(line)], analyzeResult: { grade: 'F', p_win: 0.05 } });
|
||||||
|
await runIntradayRefresh('mlb', deps);
|
||||||
|
grades = store.get('snapshot:mlb:latest').grades;
|
||||||
|
expect(beliefMutations(CLAIM, grades[0])).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publication then refresh: the published claim survives the overlay', async () => {
|
||||||
|
const published = { ...CLAIM };
|
||||||
|
const { deps, store } = harness({ grades: [published], oddsProps: [prop(0.5)], analyzeResult: { grade: 'F' } });
|
||||||
|
await runIntradayRefresh('mlb', deps);
|
||||||
|
const g = store.get('snapshot:mlb:latest').grades[0];
|
||||||
|
for (const f of BELIEF_FIELDS) expect(g[f]).toEqual(published[f]);
|
||||||
|
expect(g.movement).toBeTruthy(); // market state DID advance
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the letter and the probability stay mutually derivable after a refresh', async () => {
|
||||||
|
const { deps, store } = harness({ grades: [CLAIM], oddsProps: [prop(0.5)], analyzeResult: { grade: 'F' } });
|
||||||
|
await runIntradayRefresh('mlb', deps);
|
||||||
|
const g = store.get('snapshot:mlb:latest').grades[0];
|
||||||
|
expect(g.grade).toBe(gradeFreeze.expectedLetter(g.p_win));
|
||||||
|
expect(g.confidence).toBe(Math.round(g.p_win * 100));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ranking is unaffected because belief is unaffected', () => {
|
||||||
|
// `rankByForecast` sorts on p_win FIRST and uses the grade rank only as a
|
||||||
|
// TIEBREAK. Because the letter is a function of p_win, that tiebreak is
|
||||||
|
// normally redundant with the primary key -- an intraday grade mutation was
|
||||||
|
// the ONLY thing that could make the two disagree.
|
||||||
|
const { rankByForecast } = require('../../src/utils/gradeRanking');
|
||||||
|
const mk = (name, pWin) => ({
|
||||||
|
player: name, stat_type: 'home_runs', line: 1.5, direction: 'over',
|
||||||
|
p_win: pWin, grade: gradeFreeze.expectedLetter(pWin),
|
||||||
|
confidence: Math.round(pWin * 100), gradedAt: { line: 1.5, odds: -110, timestamp: NOW },
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slate order is byte-identical before and after a refresh', async () => {
|
||||||
|
const slate = [mk('A Hitter', 0.82), mk('B Hitter', 0.71), mk('C Hitter', 0.55), mk('D Hitter', 0.33)];
|
||||||
|
const before = rankByForecast(slate).map((g) => g.player);
|
||||||
|
const oddsProps = slate.map((g) => ({ player: g.player, stat_type: 'home_runs', line: 0.5, book: 'draftkings' }));
|
||||||
|
const { deps, store } = harness({ grades: slate, oddsProps, analyzeResult: { grade: 'F', p_win: 0.02 } });
|
||||||
|
const res = await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(res.against).toBe(4);
|
||||||
|
const after = rankByForecast(store.get('snapshot:mlb:latest').grades).map((g) => g.player);
|
||||||
|
expect(after).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every ranked row still has a letter derivable from its own p_win', async () => {
|
||||||
|
const slate = [mk('A Hitter', 0.82), mk('B Hitter', 0.71)];
|
||||||
|
const oddsProps = slate.map((g) => ({ player: g.player, stat_type: 'home_runs', line: 0.5, book: 'draftkings' }));
|
||||||
|
const { deps, store } = harness({ grades: slate, oddsProps, analyzeResult: { grade: 'F', p_win: 0.02 } });
|
||||||
|
await runIntradayRefresh('mlb', deps);
|
||||||
|
for (const g of rankByForecast(store.get('snapshot:mlb:latest').grades)) {
|
||||||
|
expect(g.grade).toBe(gradeFreeze.expectedLetter(g.p_win));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('structural guarantees', () => {
|
||||||
|
test('the module invokes no grading logic and writes no ledger revision', () => {
|
||||||
|
const src = fs.readFileSync(SRC, 'utf8')
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||||
|
expect(src).not.toMatch(/deps\.analyze/);
|
||||||
|
expect(src).not.toMatch(/applyRevision/);
|
||||||
|
expect(src).not.toMatch(/analyzeViaEngine1/);
|
||||||
|
// No letter comparison may exist here at all.
|
||||||
|
expect(src).not.toMatch(/GRADE_RANK/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the coherence gate fails CLOSED and reports the offending field', () => {
|
||||||
|
const before = { grade: 'B+', p_win: 0.9, line: 1.5, direction: 'over' };
|
||||||
|
const after = { ...before, grade: 'C' };
|
||||||
|
const v = beliefMutations(before, after);
|
||||||
|
expect(v).toHaveLength(1);
|
||||||
|
expect(v[0]).toMatchObject({ field: 'grade', before: 'B+', after: 'C' });
|
||||||
|
// A market-only change is not a violation.
|
||||||
|
expect(beliefMutations(before, { ...before, movement: { kind: 'against' } })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the gate ACTIVELY BLOCKS and does not merely sit there', async () => {
|
||||||
|
// A gate that never fires is indistinguishable from a gate that is gone:
|
||||||
|
// deleting it changed nothing, which is a coverage hole rather than a safe
|
||||||
|
// defect. This drives a REACHABLE divergence — a slate carrying two rows
|
||||||
|
// for one (player, stat, line, side) whose belief disagrees — so the gate
|
||||||
|
// has something to catch and its fail-closed behaviour is observable.
|
||||||
|
//
|
||||||
|
// It proves the MECHANISM blocks and refuses to publish. It does not, by
|
||||||
|
// itself, prove an intraday mutation would be caught; that is covered by
|
||||||
|
// the overlay tests above, which assert no mutation is produced at all.
|
||||||
|
const a = { ...CLAIM };
|
||||||
|
const b = { ...CLAIM, grade: 'F', p_win: 0.1 };
|
||||||
|
const { deps, store } = harness({ grades: [a, b], oddsProps: [prop(0.5)], analyzeResult: null });
|
||||||
|
const before = JSON.stringify(store.get('snapshot:mlb:latest'));
|
||||||
|
const res = await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(res.status).toBe('blocked');
|
||||||
|
expect(res.reason).toBe('belief_mutation');
|
||||||
|
expect(res.belief_mutations).toBeGreaterThan(0);
|
||||||
|
// FAIL CLOSED: the previous slate is left standing, untouched.
|
||||||
|
expect(JSON.stringify(store.get('snapshot:mlb:latest'))).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('this tranche touches no lineage code or configuration', () => {
|
||||||
|
const lineage = fs.readFileSync(path.join(__dirname, '../../src/services/read/readLineage.js'), 'utf8');
|
||||||
|
expect(lineage).toContain("const LINEAGE_VERSION = 'lin@1'");
|
||||||
|
expect(lineage).toContain("const CLAIM_SCHEMA_VERSION = 'claim@1'");
|
||||||
|
const cfg = fs.readFileSync(path.join(__dirname, '../../src/services/lineageCanaryConfig.js'), 'utf8');
|
||||||
|
expect(cfg).toContain('process.env.LINEAGE_CANARY_SPORTS');
|
||||||
|
// The refresh must never reach the lineage layer. Comments are STRIPPED
|
||||||
|
// first: this module's own docstring names `readLineage` when explaining
|
||||||
|
// why BELIEF_FIELDS is a local constant, and a scanner that trips on prose
|
||||||
|
// about a rule instead of a violation of it is the scar this repo keeps
|
||||||
|
// re-learning.
|
||||||
|
const src = fs.readFileSync(SRC, 'utf8')
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||||
|
expect(src).not.toMatch(/readLineage|commitPublication|attachLineage/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the frozen game-date and canonical-participant repairs are intact', () => {
|
||||||
|
const binder = fs.readFileSync(path.join(__dirname, '../../src/services/gameBinder.js'), 'utf8');
|
||||||
|
expect(binder).toContain('if (!p.game_date) p.game_date = etFast;');
|
||||||
|
const evid = fs.readFileSync(path.join(__dirname, '../../src/services/event/eventIdentity.js'), 'utf8');
|
||||||
|
expect(evid).toContain('function resolveParticipant');
|
||||||
|
expect(evid).toMatch(/ids\.size !== 1/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// Session 60 (night2/D) — Phase 2.5 intraday refresh. Direction is the
|
// Session 60 (night2/D) — Phase 2.5 intraday refresh, as amended by the
|
||||||
// signal: WITH the grade = STEAM (no re-grade); AGAINST ≥1.0 = re-grade
|
// INTRADAY BELIEF INTEGRITY repair. Direction is still the signal: WITH the
|
||||||
// that prop only (holds → VALUE, drops → PUBLIC revision). All lines are
|
// grade = STEAM, AGAINST = recorded with its signed delta. The re-grade branch
|
||||||
// real feed values; the refresh recaptures the close.
|
// is GONE — `grade` is a band label over p_win, so a market move may not
|
||||||
|
// restate it. All lines are real feed values; the refresh recaptures the close.
|
||||||
|
|
||||||
const { runIntradayRefresh, inSlateHours, __internals } = require('../../src/services/intradayRefreshService');
|
const { runIntradayRefresh, inSlateHours, __internals } = require('../../src/services/intradayRefreshService');
|
||||||
const { signedDelta, trackHistory, HISTORY_CAP } = __internals;
|
const { signedDelta, trackHistory, HISTORY_CAP } = __internals;
|
||||||
@@ -50,7 +51,7 @@ describe('runIntradayRefresh — directional handling', () => {
|
|||||||
test('moved WITH the grade → STEAM, NO re-grade', async () => {
|
test('moved WITH the grade → STEAM, NO re-grade', async () => {
|
||||||
const { deps, store, calls } = harness({ grades: [GRADE], oddsProps: [prop(1.5)], analyzeResult: null });
|
const { deps, store, calls } = harness({ grades: [GRADE], oddsProps: [prop(1.5)], analyzeResult: null });
|
||||||
const res = await runIntradayRefresh('mlb', deps);
|
const res = await runIntradayRefresh('mlb', deps);
|
||||||
expect(res).toMatchObject({ status: 'ok', steam: 1, value: 0, revised: 0 });
|
expect(res).toMatchObject({ status: 'ok', steam: 1, against: 0, belief_mutations: 0 });
|
||||||
expect(calls.analyze).toHaveLength(0); // steam never re-grades
|
expect(calls.analyze).toHaveLength(0); // steam never re-grades
|
||||||
const snap = store.get('snapshot:mlb:latest');
|
const snap = store.get('snapshot:mlb:latest');
|
||||||
expect(snap.grades[0].movement).toMatchObject({ kind: 'steam', delta: 1, currentLine: 1.5 });
|
expect(snap.grades[0].movement).toMatchObject({ kind: 'steam', delta: 1, currentLine: 1.5 });
|
||||||
@@ -58,50 +59,43 @@ describe('runIntradayRefresh — directional handling', () => {
|
|||||||
expect(calls.ticker[0].text).toContain('o0.5 → o1.5');
|
expect(calls.ticker[0].text).toContain('o0.5 → o1.5');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('moved AGAINST ≥1.0, re-grade HOLDS → VALUE (better entry)', async () => {
|
test('moved AGAINST >= 1.0 records the move and NEVER re-grades', async () => {
|
||||||
const { deps, store, calls } = harness({
|
const { deps, store, calls } = harness({
|
||||||
grades: [{ ...GRADE, gradedAt: { line: 2.5, odds: -110, timestamp: NOW }, line: 2.5 }],
|
grades: [{ ...GRADE, gradedAt: { line: 2.5, odds: -110, timestamp: NOW }, line: 2.5 }],
|
||||||
oddsProps: [prop(1.5)],
|
oddsProps: [prop(1.5)],
|
||||||
analyzeResult: { grade: 'A', confidence: 70 }, // same grade at the new line
|
analyzeResult: { grade: 'C', confidence: 40, p_win: 0.31 },
|
||||||
});
|
});
|
||||||
const res = await runIntradayRefresh('mlb', deps);
|
const res = await runIntradayRefresh('mlb', deps);
|
||||||
expect(res).toMatchObject({ value: 1, revised: 0 });
|
expect(res).toMatchObject({ against: 1, belief_mutations: 0 });
|
||||||
expect(calls.analyze).toHaveLength(1);
|
// The grader is never consulted. Injecting one and finding it untouched is
|
||||||
expect(calls.analyze[0].line).toBe(1.5); // re-graded at the REAL current line
|
// the point: the torn state is unreachable, not merely unwritten.
|
||||||
expect(store.get('snapshot:mlb:latest').grades[0].movement.kind).toBe('value');
|
expect(calls.analyze).toHaveLength(0);
|
||||||
expect(calls.revisions).toHaveLength(0);
|
expect(calls.revisions).toHaveLength(0);
|
||||||
});
|
|
||||||
|
|
||||||
test('moved AGAINST ≥1.0, grade DROPS → PUBLIC revision (original preserved)', async () => {
|
|
||||||
const { deps, store, calls } = harness({
|
|
||||||
grades: [{ ...GRADE, gradedAt: { line: 2.5, odds: -110, timestamp: NOW }, line: 2.5 }],
|
|
||||||
oddsProps: [prop(1.5)],
|
|
||||||
analyzeResult: { grade: 'C', confidence: 40 },
|
|
||||||
});
|
|
||||||
const res = await runIntradayRefresh('mlb', deps);
|
|
||||||
expect(res).toMatchObject({ revised: 1 });
|
|
||||||
const g = store.get('snapshot:mlb:latest').grades[0];
|
const g = store.get('snapshot:mlb:latest').grades[0];
|
||||||
expect(g.grade).toBe('C');
|
expect(g.movement).toMatchObject({ kind: 'against', delta: -1, currentLine: 1.5 });
|
||||||
expect(g.revised_from_grade).toBe('A'); // the original, forever
|
expect(g.grade).toBe('A');
|
||||||
expect(calls.revisions[0]).toMatchObject({ newGrade: 'C', fromGrade: 'A', line: 2.5, side: 'over' });
|
expect(g.revised_from_grade).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a second drop keeps the ORIGINAL revised_from_grade', async () => {
|
test('a favourable grade the old branch would have adopted is still refused', async () => {
|
||||||
|
const { deps, store, calls } = harness({
|
||||||
|
grades: [{ ...GRADE, grade: 'C', gradedAt: { line: 2.5, odds: -110, timestamp: NOW }, line: 2.5 }],
|
||||||
|
oddsProps: [prop(1.5)],
|
||||||
|
analyzeResult: { grade: 'B+', confidence: 82, p_win: 0.81 },
|
||||||
|
});
|
||||||
|
await runIntradayRefresh('mlb', deps);
|
||||||
|
expect(calls.analyze).toHaveLength(0);
|
||||||
|
expect(store.get('snapshot:mlb:latest').grades[0].grade).toBe('C');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a row already carrying a historical revised_from_grade is left alone', async () => {
|
||||||
const already = { ...GRADE, grade: 'B', revised_from_grade: 'A', gradedAt: { line: 2.5, odds: null, timestamp: NOW }, line: 2.5 };
|
const already = { ...GRADE, grade: 'B', revised_from_grade: 'A', gradedAt: { line: 2.5, odds: null, timestamp: NOW }, line: 2.5 };
|
||||||
const { deps, store } = harness({ grades: [already], oddsProps: [prop(1.5)], analyzeResult: { grade: 'C' } });
|
const { deps, store } = harness({ grades: [already], oddsProps: [prop(1.5)], analyzeResult: { grade: 'C' } });
|
||||||
await runIntradayRefresh('mlb', deps);
|
await runIntradayRefresh('mlb', deps);
|
||||||
expect(store.get('snapshot:mlb:latest').grades[0].revised_from_grade).toBe('A');
|
const g = store.get('snapshot:mlb:latest').grades[0];
|
||||||
});
|
// History is preserved verbatim — the repair is forward-only.
|
||||||
|
expect(g.revised_from_grade).toBe('A');
|
||||||
test('an insufficient re-read HOLDS the original grade (never blanks it)', async () => {
|
expect(g.grade).toBe('B');
|
||||||
const { deps, store } = harness({
|
|
||||||
grades: [{ ...GRADE, gradedAt: { line: 2.5, odds: null, timestamp: NOW }, line: 2.5 }],
|
|
||||||
oddsProps: [prop(1.5)],
|
|
||||||
analyzeResult: { grade: null, insufficient_data: true },
|
|
||||||
});
|
|
||||||
const res = await runIntradayRefresh('mlb', deps);
|
|
||||||
expect(res.value).toBe(1);
|
|
||||||
expect(store.get('snapshot:mlb:latest').grades[0].grade).toBe('A');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prop gone from the feed → frozen untouched; close recaptured every run', async () => {
|
test('prop gone from the feed → frozen untouched; close recaptured every run', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user