S11 (a1): live tracking — the read locked, the game watched

MLB statsapi + WNBA ESPN live boxscores -> per-player current values
(live:{sport}:{date} TTL 90s, /api/live/:sport + Next proxy). Pure
propState math (HIT / ON PACE / NEEDS N / HOLDS / LINE PASSED — never
red in-progress), attachLiveProgress strip join on nameKey+statType,
proximity-to-hit slate float, StatStrip LiveTracker in the ROW-GRAMMAR
outcome slot (spec amended + lock test updated). Grades never change
in-game — tracking, labeled as such. 2698 -> 2757 tests, web build 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-12 00:45:29 -04:00
parent 14dc9cf270
commit b5d3fd14bb
18 changed files with 1666 additions and 18 deletions
+200
View File
@@ -0,0 +1,200 @@
/* ============================================================
VYNDR — live progress engine (A1 board, Session 11).
LIVE TRACKING math + the strip join. The read is locked pre-game;
these marks are PROTO-OUTCOMES rendered in the ROW-GRAMMAR outcome
slot (specs/LIVE-TRACKING.md, specs/ROW-GRAMMAR.md §2 slot 6).
GRADES NEVER CHANGE IN-GAME.
Plain CommonJS so the .tsx components import it (allowJs) AND the
plain-JS Jest suite exercises every branch directly.
COLOR LAW (one meaning per color): green = on-pace / already-cleared /
holding; amber = needs-more / line-passed caution. Red is RESERVED for
settled-negative truth — an in-progress prop is NEVER red.
DATA SEMANTICS: a player not in the box has no live entry → no mark,
never a fabricated 0. All numeric paths are strict-null.
============================================================ */
const { nameKey } = require('./playerName');
/** Strict numeric read — null when absent/unparseable, never 0-by-default. */
function numOrNull(v) {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/**
* Pure prop-state math. { side, line, current, progress } → state or null.
*
* over, current > line → 'hit' (✓ the over has already cleared
* — a counting stat cannot un-clear; still
* TRACKING until the settle pass owns it)
* over, projected to clear → 'on_pace' (green)
* over, otherwise → 'needs' (amber, NEEDS N)
* under, current < line → 'holding' (green HOLDS — an under is
* never 'hit' until final)
* under, current ≥ line → 'past' (amber LINE PASSED — not red;
* nothing settles until the game is final)
* current/line missing → null (absent beats wrong)
*
* NEEDS N beats a push on integer lines: N = floor(line) + 1 current,
* ceil'd for fractional stats (IP thirds) — over-strict amber beats an
* over-claimed green. `progress` is the game fraction (innings/9, quarters/4);
* on-pace = current / progress ≥ floor(line) + 1. No progress → no pace
* judgement (stays NEEDS N — honest, not optimistic).
*/
function propState({ side, line, current, progress } = {}) {
const ln = numOrNull(line);
const cur = numOrNull(current);
if (ln == null || cur == null) return null;
const under = /^u/i.test(String(side || 'O'));
if (!under) {
if (cur > ln) return { state: 'hit', label: 'HIT ✓', needs: 0 };
const clearAt = Math.floor(ln) + 1;
const needs = Math.max(1, Math.ceil(clearAt - cur - 1e-9));
const p = numOrNull(progress);
const onPace = p != null && p > 0 && cur / p >= clearAt;
return onPace
? { state: 'on_pace', label: 'ON PACE', needs }
: { state: 'needs', label: `NEEDS ${needs}`, needs };
}
if (cur < ln) return { state: 'holding', label: 'HOLDS' };
return { state: 'past', label: 'LINE PASSED' };
}
/**
* Flatten /api/live/:sport response(s) → one join index:
* { hasLive, count, players: { [nameKey]: { name, team, values, progress, gameId } } }.
* Accepts a single response or an array (the Slate merges sports).
*/
function buildLiveIndex(responses) {
const list = Array.isArray(responses) ? responses : [responses];
const players = {};
let hasLive = false;
let count = 0;
for (const resp of list) {
if (!resp) continue;
if (resp.hasLive) hasLive = true;
for (const g of resp.games || []) {
for (const [key, rec] of Object.entries(g.players || {})) {
if (!rec) continue;
players[key] = {
name: rec.name,
team: rec.team || null,
values: rec.values || {},
progress: g.progress || null,
gameId: g.id,
};
count += 1;
}
}
}
return { hasLive, count, players };
}
/**
* Join built player strips ↔ the live index (PURE). Only GRADED, unsettled,
* non-dead props get `prop.live` — settled outcomes, dead reads and awaiting
* rows are untouched, and a player absent from the box gets NO mark. The
* state is computed against the LOCKED line (gradedAt.line when present) —
* the read never re-grades.
*/
function attachLiveProgress(strips, liveIndex) {
const idx = liveIndex && liveIndex.players ? liveIndex.players : null;
if (!Array.isArray(strips) || !idx || Object.keys(idx).length === 0) return strips || [];
return strips.map((strip) => {
const entry = idx[nameKey(strip.player)];
if (!entry) return strip;
const fraction = entry.progress ? numOrNull(entry.progress.fraction) : null;
let touched = false;
const props = (strip.props || []).map((p) => {
if (!p || !p.grade || p.outcome || p.dead || p.awaiting) return p;
const st = String(p.statType || '').toLowerCase();
if (!st) return p;
const current = entry.values ? numOrNull(entry.values[st]) : null;
if (current == null) return p; // not in the box for this stat — absent
const line = p.gradedAt && numOrNull(p.gradedAt.line) != null ? p.gradedAt.line : p.line;
const state = propState({ side: p.side, line, current, progress: fraction });
if (!state) return p;
touched = true;
return {
...p,
live: {
current,
line: numOrNull(line),
...state,
progressLabel: entry.progress ? entry.progress.label || null : null,
progressFraction: fraction,
},
};
});
return touched ? { ...strip, props } : strip;
});
}
/**
* Proximity-to-hit for one game's raw odds props (the Slate's sort key).
* A prop is TRACKED when it's snapshot-graded AND its player has a live box
* value for the stat. Proximity (overs only, per spec): current / needed-total
* = current / (floor(line)+1), capped at 1; already-cleared overs count 1.
* Unders count as tracked but contribute no over-proximity.
* Returns { tracked, proximity }.
*/
function gameLiveProximity(rawProps, gradeIndex, liveIndex) {
const idx = liveIndex && liveIndex.players ? liveIndex.players : null;
if (!Array.isArray(rawProps) || !idx || !gradeIndex) return { tracked: false, proximity: 0 };
let tracked = false;
let proximity = 0;
for (const p of rawProps) {
if (!p || !p.player) continue;
const stat = String(p.stat_type || p.stat || '').toLowerCase();
const key = nameKey(p.player);
const rec = gradeIndex[`${key}|${stat}`];
if (!rec || !rec.grade) continue;
const entry = idx[key];
if (!entry) continue;
const current = entry.values ? numOrNull(entry.values[stat]) : null;
if (current == null) continue;
tracked = true;
const under = /^u/i.test(String(rec.direction || 'over'));
if (under) continue;
const line = rec.gradedAt && numOrNull(rec.gradedAt.line) != null ? rec.gradedAt.line : rec.line;
const ln = numOrNull(line);
if (ln == null) continue;
const clearAt = Math.floor(ln) + 1;
const frac = clearAt > 0 ? Math.min(1, current / clearAt) : 0;
if (frac > proximity) proximity = frac;
}
return { tracked, proximity };
}
/**
* Stable partition sort: items whose scorer says tracked float to the top,
* ordered by proximity desc; everything else keeps its original order.
*/
function sortLiveFirst(items, scorer) {
if (!Array.isArray(items) || typeof scorer !== 'function') return items || [];
const scored = items.map((it, i) => {
const s = scorer(it) || { tracked: false, proximity: 0 };
return { it, i, tracked: !!s.tracked, proximity: numOrNull(s.proximity) || 0 };
});
return scored
.sort((a, b) => {
if (a.tracked !== b.tracked) return a.tracked ? -1 : 1;
if (a.tracked && b.tracked && b.proximity !== a.proximity) return b.proximity - a.proximity;
return a.i - b.i;
})
.map((x) => x.it);
}
module.exports = {
propState,
buildLiveIndex,
attachLiveProgress,
gameLiveProximity,
sortLiveFirst,
numOrNull,
};
+6 -1
View File
@@ -365,6 +365,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
const delta = deltaIndex[`${gradeKey(p.player, p.stat_type || p.stat)}|${side}`];
byPlayer[pk].props.push({
stat: statShort(rec.stat_type || rec.stat),
// A1 S11 — the CANONICAL stat key (live-tracking join; `stat` above is
// the shortened display label and can't be joined on).
statType: String(rec.stat_type || rec.stat || '').toLowerCase(),
line: rec.line,
side,
grade: rec.grade,
@@ -391,7 +394,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
});
} else {
byPlayer[pk].props.push({
stat: statShort(p.stat_type || p.stat), line: p.line, side: '', grade: null, awaiting: true,
stat: statShort(p.stat_type || p.stat),
statType: String(p.stat_type || p.stat || '').toLowerCase(),
line: p.line, side: '', grade: null, awaiting: true,
book: p.book || null,
bestBook: detectBestBook(p.books, p.direction || 'over', p.line),
});