71d3b7b78692c31ba0596ec88874986ff1b58116
202 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
71d3b7b786 |
E10 Report issue template + E12 /report archive, to spec
PHASE 0 — the spec, read not recalled. E10: "Hybrid: dark billboard header that survives every client, light paper body Gmail can't wreck. 600px, stacked, no webfont dependence." Content law: "One email per slate day. Top read, what changed, the record. Nothing else." E12: "EVERY ISSUE SHOWS ITS OWN DAY RECORD -- THE ARCHIVE IS A LEDGER TOO." COMPOSED, NOT FORKED. The audit had E10 as PARTIAL, not absent: newsletterService already builds the daily report's CONTENT and lints its voice. What was missing is the designed hybrid SHELL, so reportTemplate.js is a template over that builder rather than a second report -- the same call made for the movement strip, and for the same reason. PHASE 1 — the hybrid shell is an ENGINEERING constraint, not a look, and the tests say so: Gmail strips style blocks, Outlook ignores flexbox, and a dark body renders as a black rectangle in several clients. Hence tables, inline styles, 600px fixed, system fonts, no image required to read, and the green SHIFTS from #00D4A0 to #00A57D on paper because the dark-mode green is unreadable there. FACT-CONTRACTED: a section whose data is absent is OMITTED and NAMED in `omitted`, never filled. There is no code path producing a placeholder figure. The honesty block carries the real numbers -- graded count, cleared-ceiling count, the realized rate against baseline, and that we do not issue A grades. E1'S LAW TRAVELS EVEN THOUGH ITS RENDERING CANNOT. An SVG strip is not reliable in email, so movementText carries the RULE: green only when the move favours the read, and a flat market says FLAT · [N]D rather than showing nothing. NO DESIGNER SAMPLE DATA. Nabers 1,120.5, No 128, DAY RECORD 9-4 are a spec for what a live issue renders; pasting them in would be fabrication carrying a designer's authority and would look entirely correct. Tested. PHASE 2 — /report is now the real archive, REPLACING the S41 redirect to /blog. That redirect existed because the surface did not; E12 built it, so the placeholder is correctly gone and the S41 test is updated rather than worked around. Every row carries its own day record, and an unknown record says UNSETTLED -- never a dash that reads as zero. Empty archive is an honest state. Backend: public read-only /api/report over Redis issues, plus the Next proxy. Both surfaces registered under the reachability guard. A test bug I made twice now: my check for forbidden sample values matched the template's own doc block, which NAMES those values as things never to paste. Documentation worth keeping, so both suites strip comments before matching -- a guard that reads its own warning is not reading the code. WAVE-2 STATUS: E1, F9-F11, E10, E12 done. Still gated -- F5 article media and E16/F8 on the card-system reconciliation; the in-season hub IA on the social chat's formula; E9/E15 on model; E2/E6 on licensing. Read-only throughout; serving fingerprint unchanged including newsletterService; accrual clock unchanged at 0 eligible dates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 |
||
|
|
49e76068da |
Doctrine + E1 movement strip + F9-F11 offseason hub shell, to spec
PHASE 0 — specs/ARCHETYPE-TAXONOMY-DOCTRINE.md records the ruling as
shared law: 83 designed glyphs are the full four-sport taxonomy; a glyph
renders ONLY where its archetype is modeled and proven. 39 of 83 map to a
real archetype and are wired; the 44 unmapped are DORMANT SLOTS for
WNBA/NBA/Soccer, not a wiring gap. Wiring them would mean inventing 44
archetypes to consume artwork -- decoration presented as classification,
which is forbidden. DUAL THREAT and PAINT BOSS are modeled archetypes with
no mark: the mirror gap, flagged to the design side. When a sport's
archetypes ship, activation is a MANIFEST lookup, not new art.
PHASE 1 — E1 movement strip. The spec's own line is "the movement strip is
defined once here and reused everywhere a line has a past", so it is a
primitive, not a fourth chart.
RECONCILED RATHER THAN FORKED: lib/gradeShift.js ALREADY implements E1's
colour law -- toward/against/flat, including the direction flip that makes
an UNDER's favourable move the opposite sign of an OVER's. MovementStrip
CONSUMES buildGradeTimeline instead of reimplementing it, and a test
asserts it never redefines isUnder. GradeShift stays the grade-history
view; this is the reusable strip. That is the card-fork lesson applied
before it could happen again.
Spec laws honoured: STEPS NOT CURVES (H then V, no smoothing -- a curve
invents prices that never traded, and a test rejects any C/S/Q/T command);
green only when the move FAVOURS the read; FLAT renders as a hairline plus
FLAT · [N]D because a flat market is a finding; and too little history
says NO MOVEMENT HISTORY rather than rendering blank.
PHASE 2 — F9-F11 offseason hub shell, built from Vyndr Offseason.dc.html.
The spec's load-bearing words are used verbatim: "OUTLOOKS REPRICE ON NEWS
· NOT GAME ODDS" (an offseason number is not a game line), the QUIET WIRE
empty state ("No outlook-moving news since X. We don't manufacture
movement."), WHAT CHANGED TODAY as the hero with the countdown ambient and
top-right, the tag-colour-is-meaning row anatomy, the open -> NOW -> FAIR
triplet with the movement strip embedded, and the OUTLOOK ONLY block where
every row carries NOT GRADED.
THE DESIGN FILE'S SAMPLE DATA IS NOT IN THE COMPONENT. Wembanyama +420 ->
+330, Nabers cleared 11:42 AM, the Summer League names -- all of it is a
SPEC for what a live feed renders, and copying it in would be fabrication
carrying a designer's authority. A test asserts none of those strings
appear.
The IN-SEASON information architecture is NOT invented here. The spec
covers an offseason hub; nothing specifies how content, articles, wire and
the live slate share year-round navigation. That remains the open design
gap, and the route notes it.
Two test bugs caught and fixed: my first assertions matched my own doc
comments -- the ordering check found "WHAT CHANGED TODAY" in the header
block and the no-curves check caught the word "curve" in the sentence
explaining why curves are wrong. A guard that reads its own explanation is
not reading the render; both now strip comments first.
PHASE 3 — both surfaces registered under the reachability guard. Read-only
throughout, serving fingerprint unchanged, accrual clock unchanged at 0
eligible dates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
|
||
|
|
60469422af |
Wave D1 primitives — and the audit says E1/E9 were not Wave 1
PHASE 0 — the order proposed E1 + E9 as Wave-1 and told me to follow the
audit if it disagreed. It disagrees: E9 calibration curve is WAVE D3,
gated on MODEL work ("resolve n>=20 vs N30, accrue buckets"), and E1
movement strip is WAVE D6, a large surface build. E9's gate is live right
now -- calibration is WITHDRAWN at 0 eligible dates, so the curve could
only render its empty state today. Building it would ship a component
whose entire purpose is unavailable.
PHASE 1 — three of the five real Wave D1 items were ALREADY DONE, and the
2026-07-31 audit has aged:
D1 glyph library audit: 38/83 wired (46%)
now: COMPLETE for everything wireable -- 39 of 83
designed glyphs map to a real archetype, all 39
are wired, colours match the registry exactly
(0 disagreements).
A1 card token audit: BUILT-BUT-DRIFTED, "in only 1 file"
now: BUILT-TO-SPEC -- it IS the --bg-1 token,
consumed by 32 files. The audit counted literal
hex, which is what a correctly tokenised value
looks like.
B1 boundary blue audit: PARTIAL, hex in 2 files
now: BUILT -- --priced-out/#8fb2de is a token with a
documented colour law, 4 consumers.
The 44 unwired glyphs are NOT a wiring gap: they have no backend
archetype, so wiring them means inventing 44 archetypes to consume
artwork -- the fabrication this programme refuses. That is the 41-vs-74
scope question and it is Kev's call. Separately, 2 registry archetypes
have NO designed glyph (DUAL THREAT, PAINT BOSS) -- a design gap.
PHASE 2 — what was genuinely absent is now built. web/src/lib/motion.js:
nudge() capped at 180ms so it reads as acknowledgement rather than
latency; bootStagger capped at 240ms because uncapped, row 40 waits 1.1s
and the stagger BECOMES the latency it exists to disguise; rowHover
returns handlers not CSS so touch cannot stick a hover state; and
revealOnIntersect returns an unobserve in every path and reveals
IMMEDIATELY when there is no IntersectionObserver or motion is reduced --
content is never hidden behind a capability check.
Reduced motion is honoured, not softened. The sharpest of the 10 tests:
bootStagger under reduced motion returns opacity 1, not merely delay 0 --
if the CSS animation supplies the opacity, skipping it leaves the row
invisible forever.
PHASE 3 — the reachability guard gains a PRIMITIVES section: a module
built to be embedded must declare its exports AND name its intended
consumers, because a primitive imported by nothing is the same
built-but-unread class as an unmounted component.
WAVE-2 UNGATED: F9-F11 offseason hub, F5 article media, E10/E12 Report,
E1 movement strip. GATED: E9 + E15 on model, E16/F8 on the resolution tail
and the card-system reconciliation, E2/E6 on licensing, E13 on another
order.
Read-only throughout; serving fingerprint unchanged; accrual clock
unchanged at 0 eligible dates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
|
||
|
|
c575a708c7 |
Content studio API + preview page; widen the reachability guard; correct
two inventory errors INVENTORY CORRECTION, and it was mine. Phase 2's two "orphans" are NOT orphans -- my board grepped only web/src/app and missed component-level mounting. The transitive check says both are already mounted: BookComparisonPanel -> GradeResultCard -> app/scan/page.tsx NewsWire -> ExploreHub -> app/explore/page.tsx So book comparison is DONE (wired to /api/books, rendering on the grade card) and THE WIRE is DONE-BY-DESIGN, mounted in ExploreHub. Its header names an "Offseason Hub" as its home, and that hub genuinely does not exist -- but that is board item #8, not a mounting bug, and inventing a surface to satisfy a comment would be the wrong fix. The lesson is the same one this session keeps teaching: I checked one directory and reported a conclusion the check could not support. ALSO CAUGHT: I overwrote src/routes/content.js, which was the Session-29 content-templates route, by picking a filename without looking. Restored from git with no work lost; the new surface lives at /api/content-studio and both now coexist. PHASE 0/1 — /api/content-studio serves finished posts (copy, branded card, card_svg, the fact_contract each was REQUIRED to have, and the facts that actually backed it) plus a POST for editorial status in Redis. Private via internal key; the Next proxy holds the key server-side so the browser never does. /studio renders it as a thin client -- copy and card side by side with the fact contract visible, because reviewing copy by reading it is exactly how a wrong number ships. Never-blank: a night with nothing generated says so. API-FIRST is the point: the endpoint an autonomous poster will call is the one the page already renders, so the agent handoff is a pointer change, not a rebuild. Contract documented at docs/CONTENT-STUDIO-API.md. EXPRESS 5 BROKE 23 SUITES at first: `router.get('/:date?')` throws at mount time in Express 5, taking down everything that imports app.js. Two explicit routes instead. PHASE 3 — the reachability guard is widened from grade-fields-only to a general built-but-unread check. Book comparison, THE WIRE and the content studio are now registered surfaces; a page counts as its own entry point (Next mounts it by convention) while everything else must trace to one. 22 checks green; a registered-but-unimported surface still goes red. FULLY ISOLATED: read-only on model/slate/ledger, serving fingerprint verified unchanged, accrual clock unchanged at 0 eligible dates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 |
||
|
|
09186ea609 |
Wire the composed surface: the honest fields now reach a user
PHASE 0 re-check caught the same failure a THIRD time, mine again. Last
turn I created GradeScaleLegend.tsx and never mounted it, and I reported
that the separation flag "renders per band" -- it did not. grep: zero
frontend references to separates_from_base_rate, served_grade or
factor_adjustment. The honest grade was reaching the API payload and dying
at the adapter boundary.
That is three occurrences in three orders of the same shape: built,
correct, unread. gradeBands, then served_grade, then the legend.
PHASE 2 — the composed surface is wired end to end:
analyzeViaEngine1 -> served_grade + factor_adjustment on the payload
scan/page.tsx -> ScanResponse types them and forwards them
gradeAdapter -> gradeMeaning, separatesFromBaseRate,
bandRealizedRate, factorsApplied, refusalReason
GradeResultCard -> renders "WHAT THIS GRADE MEANS"
What a user now sees that they could not before: what the band has
actually realized, an amber note when the read CANNOT be separated from
the baseline, and -- only where a factor actually fired, with its proven
sign -- what moved the read, in plain language rather than feature names
("where he hits it vs who is standing there").
No narrative on props where nothing fired: factorsApplied is empty and the
block self-hides. Only the three PROVEN hits factors have labels, so an
unproven factor cannot acquire prose by being added to the map.
PHASE 1 — GradeScaleLegend is now MOUNTED on the grade card (compact). The
ceiling is a stated position where the grade is, not a page a user would
have to find.
PHASE 3 hand-verified through the real adapter across twelve states: B+
with 3/0 factors, B with 1, C+/C/C- all flagged not-separable, D, F, a
switch-hitter case where 2 of 3 factors fire, two refusals and a
no-forecast. never-blank PASS, no-manufactured-A PASS, no-narrative-when-
nothing-fired PASS, separation-flag-reaches-card PASS.
No A-threshold loosening. No calibrated number leaks. p_win never mutated.
engine_grade still read by zero serving code. No Bonferroni slot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
|
||
|
|
3591c7626e |
Total grade cutover + the ceiling stated as a position
PHASE 0 caught my own repeat of the failure I diagnosed one order ago.
|
||
|
|
6ae11f1193 |
LODO-gated provisional calibration: total_bases deploys, hits withdrawn
PHASE 0 — I applied factorGate's >=40 date-cluster floor to a calibration layer without challenging the binding. That floor is a cluster-robust interval bar for a CAUSAL claim. Calibration makes no causal claim, has a bounded failure mode (it can only over- or under-shrink) and consumes no Bonferroni slot. Its real risk is that the correction is DATE-DRIVEN, and leave-one-date-out tests that directly -- a STRICTER bar, since a cluster count cannot detect a single day carrying the effect. The >=40 floor is retained, correctly scoped as the PROMOTION bar. PHASE 1 — both guards codified, 11 tests, green before Phase 2. Demonstrated on live data: raw population violated=true, mean_p 0.4962, both_sides_share 0.9763; after dedup violated=false, mean_p 0.6694. The null guard's test demonstrates the trap explicitly, since (null-1)**2 is 1 and (null-0)**2 is 0 so a Brier over nulls equals the win rate. PHASE 2 — LODO: hits n=1140 dates=17 2 reversals (07-22 n=20, 07-26 n=25) FAIL total_bases n=1050 dates=7 0 reversals, 0 sign flips PASS rbi n= 630 dates=5 1 reversal (08-01 n=99) FAIL runs n= 597 dates=5 2 reversals (08-01 n=86, 08-05 n=244) FAIL Threshold sensitivity reported because the verdict moves: total_bases passes at every held-size threshold, runs fails at every one, and hits fails ONLY when 20/25-row dates are admitted. I fixed MIN_HELD_ROWS=20 before seeing which stats passed and did not move it afterwards to preserve a deploy. Honest caveat: a per-date Brier delta on 20 rows has a standard error several times the effect, so the instrument is underpowered per-drop -- an argument for pre-registering a higher threshold, which is a Roundtable call, not one to make while holding the results. PHASE 3 — total_bases DEPLOY-PROVISIONAL, band [0.6-0.8]. hits, rbi and runs REFUSE. HITS WAS BEING SERVED CALIBRATED AND IS NOT ANY MORE. snapshotService hardcoded it since S91; it fails LODO, so it is out. A stat that cannot survive dropping one day was never calibrated, it was fitted to that day. The consequence is real -- hits props become unstackable for chain.chainAcross -- and it errs toward withdrawing a claim rather than preserving one on a fragile verdict. Deployment is now driven by a frozen, tested CALIBRATION_DEPLOYED set, not a hardcoded stat name. PHASE 4 — calibrationRegistry, 14 tests. Deploy needs BOTH gates, neither waivable. reverify auto-demotes on the first breach (CI stops excluding zero, or the favourite bias flips sign) and logs the breaking date. Promotion needs the original >=40 bar. A provisional deploy that cannot be taken away is just a deploy. PHASE 5 — TB bands rebuilt on calibrated values, 625 eval rows. The two-bar rule still bites: calibrated YES, proven NO, so they stay a base-rate read, now honestly numbered. Every archetype still collapses to one band -- calibrated p_win separates within archetype no better than raw. PHASE 6 logged only: the dead gradient is buried (hits~TB > runs > RBI, and RBI has the SMALLEST bias, so the skill-driven-gradient mechanism did not survive); the refused set is a map of missing inputs; a low-parameter calibrator is queued unbuilt. p_win never mutated; calibration rides as p_win_calibrated with calibration_status provisional. No Bonferroni slot consumed. Counter and frozen clusters byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 |
||
|
|
f976df47b8 |
Settle model_snapshots + four-stat calibration: works, deploys nowhere
Settlement done (15,484 written). Calibration improves held-out Brier on
all three stats it can be fitted for, beating every factor ever tested.
No stat deploys: the date-cluster ceiling is 17, not 90.
PHASE 0 CORRECTIONS: 71,192 snapshots unsettled, not 22,032. Span is
07-19 -> 08-06 = 19 dates, not 05-01 -> 08-04. Nothing has ever been
rescaled on any stat -- all four are base-rate bands today -- and the TB
"inversion confirmed" was the units-bug artifact, UNPROVEN.
PHASE 1, two integrity findings both caught by the gate:
1. The dupe check hard-failed on snapshot id 33875. model_snapshots is
written by the cron at 14/19/22/1/3 UTC and an unordered .range() walk
over a live table returns overlapping pages. Fixed with .order('id').
2. 12,894 rows were logged AFTER first pitch -- cycles at ET 21/22/23 on
the game date (10,738) plus 664 the next morning. A 01:00-UTC cycle is
21:00 the previous evening Eastern, same game date, two hours into the
slate. Tested for contamination: bias +0.0058 in-game vs +0.0008
pre-game, so NOT sharper, just late. Excluded for provenance.
THE ENABLING MOVE DID NOT ENABLE. 71,192 rows collapse to 4,799 distinct
pre-game props (2.5x cycle fan-out, then 97.6% both-sides duplication,
then the pre-game filter). Hits ends at 1,140 rows against the ledger's
existing 1,312. Date-clusters: hits 17, TB 7, rbi 5, runs 5.
THE MEASUREMENT THAT NEARLY WENT THE OTHER WAY: 97.6% of props carry both
sides, whose p_wins sum to ~1 and whose outcomes are complementary, so
the raw population is pinned to 0.5 by construction. Measured that way
the counter reads +0.0002 on hits -- "perfectly calibrated" -- and would
have overturned three sessions. Deduped to the model-picked side it is
+0.0868. The tell was mean p_win sitting at 0.4998 on every stat.
PHASE 2/3, isotonic point-in-time, split by cumulative rows (a
60%-of-dates cut left 143 fit rows under the fitter's 200 minimum; still
strictly temporal):
hits n=1140 bias +0.0868 brier 0.2626 -> 0.2511 d -0.0115 CI [-0.0139,-0.0097]
TB n=1050 bias +0.0834 brier 0.2490 -> 0.2438 d -0.0052 CI [-0.0061,-0.0045]
rbi n= 630 bias +0.0164 brier 0.2011 -> 0.1965 d -0.0046 CI [-0.0092,-0.0010]
runs n= 597 bias +0.0410 no map fittable (173 fit rows < 200)
ALL FOUR REFUSE: 2-4 eval date-clusters against a floor of 40. The floor
is the order's own and was not relaxed to force a pass.
A NULL THAT SCORED ITSELF: the first run reported hits at Brier 0.5567,
worse than predicting 0.5 for everything. fitIsotonic returns null below
its minimum, applyIsotonic then returns null per row, and (null-1)**2 is
1 while (null-0)**2 is 0 -- so the "Brier" was silently just the win rate
(0.5684). This project's signature Number(null)===0 breach, in my own
measurement code. Now a hard refuse.
PHASE 4: the bias is NOT a uniform shift. Identical favourite-longshot
shape on all four stats -- near zero or negative at 0.5-0.6, rising to
+0.21 to +0.28 above 0.9. The counter is over-confident specifically
about its favourites, which is the population a user acts on. Gradient is
hits ~ TB > runs > rbi, not the TB > RBI > runs anticipated.
PHASE 5/6 NOT RUN -- both gated on a Phase 3 deploy that did not open.
PHASE 7, refusal accuracy, first real measurement: refused props are
FURTHER from a coin flip than graded ones (TB refusals went over 21.6% of
the time). The obvious explanation, that refusals concentrate on players
who barely played, was tested and does not hold -- refused mean 3.20 AB
vs graded 3.39, 6.6% vs 6.2% with <=1 AB. So we pass on what we have no
INPUT for, not on what we cannot call. Refusing to invent a number
without a reference stays correct; the pass is not landing on the
genuinely uncertain props.
p_win never mutated, no p_win_calibrated written since nothing deployed,
no Bonferroni slot consumed. Counter and frozen clusters byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
|
||
|
|
23d1b13176 |
runs + RBI: mostly-base-rate confirmed, and one level deeper than expected
Nothing proved. For RBI even the ARCHETYPE split is theatre, so the honest grade is the POOLED base rate. PREMISE NOTE: the order's closing line says the batter board is per-archetype-graded after this. Nothing has been rescaled for hits or total_bases either -- no archetype slot has ever reached sample and gradeBands remains built, gated and unwired. This is the fourth stat measured, not the completion of three. AUDIT: RBI 935 clean / 43 games; RUNS 617 clean / 33 games. Zero quarantined. No archetype slot reaches 500 -- and the signature archetypes the order names are the two SMALLEST slots on the board, RBI->DRIVER at n=24 and runs->CATALYST at n=9. RUNS is refused structurally before any factor is tested: 33 game clusters against a 40 floor. INPUTS RECONSTRUCTED rather than declared missing. lineup_context only covers 08-04 onward while settled rows start 07-31, so 187/617 runs rows joined. But the play-by-play cache runs from 05-01 and the batting order IS the order batters first appear -- slot, power-behind and reach-base all rebuilt point-in-time, coverage 187 -> 574. RBI, all THEATER: risp_opportunity +0.0047, extra_base_skill +0.0010, risp x extra_base +0.0056. RUNS, all refused on clusters and all pointing the wrong way: +0.0043 / +0.0008 / +0.0054. THE COMPOUND IS THE WORST VERSION IN BOTH STATS. The causally-correct compound was the most promising factor on the sheet and is the most harmful in each. Two multipliers that individually carry nothing do not cancel -- they compound each other's noise. Distinct from the collapsed-sequence lesson: there the product of two REAL effects was too small to use; here the product of two NULL effects is worse than either. THE ARCHETYPE DOES NOT RESCUE IT, and this is where the session nearly went wrong. The base rates look strongly differentiated (RBI DRIVER 0.609 vs BOMBER 0.413; runs GHOST 0.716 vs BOMBER 0.460). Gated directly against the pooled base rate: RBI +0.0010 CI [-0.0034,+0.0050] THEATER; runs -0.0028 CI [-0.0147,+0.0108] candidate at k=33. DRIVER's 0.609 is n=23 -- small-slot noise wearing a decimal point. Read off the table instead of gated, this would have shipped as "archetype differentiation is real and large". It is not. THE CROSS-STAT PATTERN THAT IS REAL -- the counter over-predicts every batter counting stat measured: total_bases p_win 0.5698 vs actual 0.5074 bias +0.0624 rbi p_win 0.4860 vs actual 0.4313 bias +0.0547 runs p_win 0.5949 vs actual 0.5749 bias +0.0200 Across four stats and three sessions, calibration is the systematic defect and factor scarcity is not. TB's held-out isotonic fix (-0.0039) still outperforms every factor tried on any stat, all null or theatre. NO RESCALE. Nothing proved, nothing certified calibrated, no slot at sample, and for RBI the archetype split is itself theatre -- so the honest band is the pooled base rate, which gradeBands returns by construction. Counter and frozen clusters byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 |
||
|
|
6a327d9114 |
total_bases: every power factor is THEATER, and a units bug nearly hid it
PREMISE CORRECTION: the per-archetype rescale is not "proven and live on hits". gradeBands was built, gated and explicitly NOT wired two orders ago -- no hits archetype slot reached sample, every band came back base-rate, and only defense_by_direction proved pooled. This applies an unvalidated-at-archetype-level method to a second stat. FULL-HISTORY AUDIT: 988 clean settled TB rows (101 quarantined, 948 with p_win), 341 players, and only 9 DISTINCT GAME DATES. No archetype slot reaches 500 -- BOMBER 340, GHOST 147, BRUSH 55. Confirmed short on full history, not a windowed artifact. The 9-date figure matters more than the row count: ~49 games means any game- or venue-borne factor has almost no replication here. THE BASELINE HAD TO CHANGE, to a harder null. TB lines vary (1.5 on 559 rows, 0.5 on 345), so a per-line personal base rate would rest on ~2 rows per player-line and would have to be invented. The null is the counter's own p_win, which already prices the line -- beating the champion, not beating "he's due". THE UNITS BUG, caught, and it had produced the best result in the programme. The first run reported barrel_rate at Brier -0.0095, the largest improvement ever measured here. fromStatcastRow returns barrel_pct as a FRACTION (0.06) while the raw table stores 0-100, so (0.06 - 7.8) * 0.018 clamped EVERY row to the maximum negative shift. That uniform downward push "improved" Brier purely by leaning on the counter's over-prediction and contained no barrel information at all. Same family as the S80 trap, inverted. exit_velo was a second bug -- the column is avg_exit_velo, so it read null on every row and reported n=0. A zero is a wiring bug until proven an honest absence. GATE with units fixed, 138 cumulative tests: barrel_rate n=707 shift 0.0364 brier +0.0036 THEATER exit_velo n=707 shift 0.0229 brier +0.0022 THEATER hard_contact_allowed n=707 shift 0.0260 brier +0.0033 THEATER park_weather_hit_type n=651 36 entities PENDING (k<40) platoon_severity n=481 PENDING (n<500) THE PREDICTED INVERSION WENT THE OTHER WAY. BOMBER x barrel_rate is +0.0114, the single most harmful cell in the table, exactly where the strongest proof was predicted. GHOST +0.0012. All sample-blocked so not a verdict, but recorded so it is not claimed later. AND IT IS NOT DOUBLE-COUNTING -- tested and refuted: corr(barrel, p_win) = -0.061, the counter is not pricing barrel at all. The duller answer is corr(barrel, counter RESIDUAL) = -0.012. Barrel is a real skill that carries no information about what the counter gets wrong at this line. That also closes the S81 lead: hard_hit r=0.153 at n=295 drifted to 0.135 at n=383 and is THEATER at n=707. THE REAL FINDING: TB is miscalibrated, not under-factored. mean p_win 0.5698 vs actual 0.5074, bias +0.0624. Held out on a strict time split (fit < 2026-08-02, eval 651 unseen rows): raw 0.25007, constant de-bias 0.24740 (-0.00267), isotonic 0.24621 (-0.00386). Worth more than any factor tested and the only intervention pointing the right way -- and still refused at the corrected bar on 32 clusters. A CANDIDATE, not a result. It also explains the units bug's fake success exactly: a blanket downward shift is a crude de-bias. NO RESCALE. Nothing proved, nothing certified calibrated, no slot at sample -- every band would be the honest base-rate band gradeBands already returns by construction. Counter and frozen clusters byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 |
||
|
|
8ab6557faa |
Collapsed sequence edge: two proven links whose product is too small to use
Nothing in this failed, which is what makes it the most instructive
negative so far. Link 1 proved (MAE 3.22 -> 2.80 batters faced). Link 2's
quality grain proved (2.70pp of realized separation). Both point-in-time,
both past cumulative correction. Their product is 0.37pp and detecting it
would take 52 seasons.
FRAMING CORRECTION: the order says Link 2 proved you can't predict the
reliever. Half true -- the INDIVIDUAL grain failed at 17.2%, but the
QUALITY grain PROVED. Pen-season-quality is a measured predictor here, not
a fallback after a failure.
TWO OF THREE SPECIFIED INPUTS COULD NOT BE USED HONESTLY. Pen archetype
did not prove (0.5669 vs a 0.5309 modal baseline, interval spanning zero)
so building it in would chain on an unproven link. And hitter
approach-identity -- "fastball-hunter", "finesse-vulnerable" -- does not
exist in this registry; MLB batter archetypes are BOMBER/GHOST/TORCH/
BRUSH/DRIVER/FLEX/ALPHA/HYBRID/CATALYST. Inventing one to condition on is
the fabrication the gate exists to catch. A power/contact split derived
from the sequence data was tested as a SEPARATE gated addition instead;
neither half proved.
GATE on the concentrated subset, 114 cumulative tests:
early-exit x WEAK pen n=1931 brier -0.0001 CI [-0.0014,+0.0010] NOT_PROVEN
early-exit x STRONG pen n=2574 brier 0.0000 CI [-0.0011,+0.0010] THEATER
all early-exit later ABs n=6869 brier -0.0001 CI [-0.0007,+0.0005] NOT_PROVEN
pooled all later ABs n=17891 brier 0.0000 CI [-0.0004,+0.0003] THEATER
Not pooled-diluted -- the concentrated subset was gated alone and is no
better.
THE CEILING, which explains it. The descriptive pass found the predicted
direction (+0.74pp weak pen, -0.79pp strong pen). The magnitude is the
problem and it is structural:
P(faces pen | early-exit flagged) 0.8075
P(faces pen | starter goes deep) 0.7149
exposure the flag actually buys 0.0925
hit-rate swing across pen quality 0.0394
MAX JUSTIFIABLE ADJUSTMENT 0.00365
actually applied 0.01930 -> 5.3x over-movement
A hitter's 3rd/4th plate appearance is ALREADY against the bullpen 71% of
the time when the starter is projected to go deep. Link 1 lifts it to 81%
-- nine points of extra exposure, not a change of opponent. The 5.3x
over-movement is precisely why the mirror subset reads THEATER rather than
as a small true effect.
A correctly-scaled version is not detectable either: 0.37pp is 0.37 SE at
n=1,931; the corrected bar needs n=168,488, an 87x shortfall, ~52 seasons.
STRUCTURALLY CLOSED, not sample-blocked. Waiting does not fix it.
NOT WIRED, and the self-check deliberately not wired either -- flagging
line-divergence on an adjustment measured as absent would advertise an
edge we just showed does not exist, which is fabricated reasoning one
layer up.
THE LESSON: link-by-link validation guarantees each link is real. It does
not guarantee the chain transmits anything. Size the multiplicative
structure BEFORE building -- one exposure term of 0.09 reduces a genuine
3.94pp signal to noise and no downstream care recovers it.
Link 3 confirmed skipped. Parallel track logged unchanged: TB n=948
pooled, BOMBER x TB 340, short by 160.
Counter and frozen clusters byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
|
||
|
|
b2e4c6c4fb |
Link 2 at the coarse grain: pen QUALITY proves, archetype does not
The refinement was right. Naming the individual reliever failed; the same
question at the grain the chain needs passes, and it transmits more than
anything else measured in this chain.
WHY IT WAS WORTH RE-ASKING: last session's null (the pen is on average no
softer, +0.0010 on 35,760 PAs) does NOT rule this out, and treating it as
though it did would have been the error. An average washing out is fully
consistent with quality VARIATION mattering. It does -- actual arm quality
moves the hit rate monotonically across quartiles, 0.2244 / 0.2293 /
0.2410 / 0.2501, a 2.57pp spread, larger than the whole times-through-
the-order effect.
CLUSTER UNIT CORRECTED, THEN CHECKED RATHER THAN ARGUED. Last session
refused Link 2 partly as team-borne (30 bullpens, the park ceiling). My
first re-check was that 76% of pen-quality variance is within-team -- but
that is a statement about TREATMENT variance, not about where errors
correlate, and stopping there would have been picking the convenient
answer. Measured the actual thing: ICC of prediction error by team =
0.0261, design effect 1.41, SEs inflated ~19%. So the verdict was run
three ways:
unclustered CI [-0.0067,-0.0010] excludes zero
team-clustered (30) CI [-0.0086,-0.0003] excludes zero (below the
40-cluster floor -- indicative, not a pass)
design-effect adjusted CI [-0.0072,-0.0005] excludes zero
QUALITY GRAIN PROVES on the concentrated elevated-early-exit subset:
n=501 team-games, 426 clusters, MAE 0.0294 -> 0.0260, delta -0.0034, CI
[-0.0063,-0.0005] at 110 cumulative tests. Pooled also proves, so it is
not a subset artefact.
ARCHETYPE GRAIN DOES NOT: 0.5669 vs a 0.5309 modal-guess baseline,
corrected interval [-0.1073,+0.0268] spans zero. Two grains tested, one
earned a place -- penQuality.js exposes no archetype and a test asserts
it.
WHAT LINK 3 RECEIVES, which is the number that actually matters -- not
the MAE gain but realized outcome separation, prediction strictly
point-in-time:
predicted BEST pen 167 games 2,044 PAs hit rate 0.2231 +/-0.0180
predicted WORST pen 167 games 1,799 PAs hit rate 0.2501 +/-0.0200
2.70pp separated, intervals non-overlapping, capturing nearly all the
2.57pp available at the quartile grain. Caveat stated not buried: the
tercile cut is chosen in-sample; the prediction driving it is not.
BUILT: penQuality.js + 9 tests. Abstains below 5 prior club games and 40
arm appearances -- a league-average stand-in would assert "this is an
ordinary bullpen", which is a claim, and usually the wrong one for exactly
the clubs whose pens just turned over.
Link 3 is unblocked on a proven Link 2 at the quality grain only. Not run
here; this order scopes to building and gating Link 2.
Parallel track logged unchanged: TB n=948 pooled, BOMBER x TB 340, short
by 160.
Counter and frozen clusters byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
|
||
|
|
e4dae0e6b0 |
Reliever chain: Link 1 proves, Link 2 does not, and the premise inverts
The causal insight is right -- the game is a sequence and the matchup does shift mid-game. The direction is backwards, measured on 93,663 plate appearances from 1,238 games pulled free from statsapi. LINK 1 PROVES. Starter batters-faced, point-in-time from his own prior starts only, clustered on the pitcher: MAE 3.2226 -> 2.7990, delta -0.4236, CI [-0.6006,-0.2731] at 0.9995 corrected for 107 tests, 1,706 starts across 204 pitchers. It finds the tail the chain needed -- early exits are a 23.2% base rate, model-flagged starts are 34.0% early, lift +10.8pp. Scope correction inside Link 1: the order specifies fatigue x GAME SCRIPT, but game script is not available at grade time -- whether he gets hit tonight is the thing being projected, not an input to it. Only the workload half is measured; the in-game half is recorded as a live feature, out of scope, rather than quietly folded in. LINK 2 DOES NOT PROVE, twice over. Model accuracy 17.2% vs an 8.6% baseline -- doubling it sounds good and is not, since naming a specific arm is wrong five times in six. And structurally the entity is the BULLPEN: 39,629 post-starter plate appearances across 30 clubs is 30 readings, below the 40-cluster floor, the same permanent ceiling as park geometry and team defence. LINK 3 NOT RUN, per the order's own rule. THE PREMISE IS REFUTED, and this chains on nothing so it was safe to measure: vs STARTER n=48,492 hit rate 0.2444 +/-0.0038 vs BULLPEN n=35,760 hit rate 0.2373 +/-0.0044 The pen is 0.7pp HARDER. The specific effect the chain exists to exploit -- early exit making later at-bats softer -- is +0.0010 on 35,760 PAs. A well-powered null, not a sample problem. What IS real is times through the order: TTO1 0.2351 -> TTO2 0.2515 -> TTO3 0.2518. A starter does decay as the lineup sees him again, but that advantage is SURRENDERED when he leaves, not extended -- the pen is harder than his second and third time through. A modern bullpen is a queue of fresh specialists throwing one inning each; there is no tiring arm to punish. So the insight survives inverted, and Link 1 stays valuable for the opposite reason it was built: a likely early hook predicts the hitter LOSES his third-time-through look (0.2518 -> 0.2373 on that PA). The mispricing is on hitters who get an EXTRA look at a starter going deep. BUILT: predictionGate.js + tests -- the two-part gate for a continuous prediction. factorGate binarises outcomes for Brier, which would destroy a target like batters faced. Same discipline, same THEATER verdict, real scale. PRE-REGISTERED NOT RUN: Link 2' using a PA-weighted bullpen AGGREGATE rather than a named arm. Recorded rather than substituted in -- running Link 3 on a swapped-in Link 2 is the assumed-link failure the order forbids. Given the premise result its expected value is now low. PARALLEL TRACK logged: total_bases n=948 pooled, BOMBER x TB 340, short by 160. Sample-readiness only, not a verdict. Counter and frozen clusters byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 |
||
|
|
3081c92e00 |
Per-archetype grade bands: built, gated, and the rescale blocked twice
The premise does not hold. proven-status.js run fresh: PROVEN_SET is EMPTY, no archetype x stat reaches the gate. pitcher_contact_profile has a CI upper bound of exactly 0.0000 and platoon_severity is held on 4.5%-contaminated splits, so the proven set is one factor, pooled, not three archetype-conditioned ones. The specific pattern the order names -- defense strong for GHOST/BRUSH, null for BOMBER -- is the one I measured running the OTHER WAY yesterday, both noise-dominated. But the second blocker is new and matters more, because it would stop the rescale even if the factors had proved: the grade does not separate within any archetype. Every archetype collapses to ONE band at the corrected bar, because bands merge when their intervals overlap and publishing two letters we cannot tell apart is a distinction we have not measured. Uncorrected, so the ranking is visible rather than hidden by the bar, this INVERTS the order's design. The order gives contact types the factor-rich treatment and power types honest base-rate, reasoning that single-game hits are variance for a power profile. Measured: BOMBER n=466 corr(p_win,outcome) +0.207 quintiles 0.75 0.62 0.60 0.48 0.48 GHOST n=192 corr(p_win,outcome) -0.007 quintiles 0.47 0.63 0.74 0.58 0.45 BOMBER is the one archetype the model ranks, and it splits into a real A 0.660 / B 0.481 at 95%. GHOST is flat, and non-monotone -- its most confident reads hit 47% while its middle reads hit 74%. Shipping as specified would have given the factor-rich treatment to the archetype the model reads worst and left base-rate on the one it reads best. That is mechanically sensible in hindsight: a power hitter's hit tracks whether he can damage the arm, a contact hitter's depends on balls finding holes. BOMBER's split does not survive the cumulative correction at 106 tests. Exposing it by loosening the correction is the curve-to-make-A's the order forbids, so it stays one band. BUILT: gradeBands.js -- lift against the archetype's OWN base rate (the same 62% is lift for a 45% profile and a deficit for a 68% one), indistinguishable neighbours merged, thin bands PROVISIONAL not dropped, Wilson intervals widened by the cumulative correction. The two-bar rule is structural: proven-alone, calibrated-alone and neither all return base_rate with the reason stated, so with nothing proven no factor-informed band can be produced at all. reasoning() is built and tested but NOT wired to the card -- there is no per-archetype band being served, so attaching the copy now would ship product language for a rescale that does not exist. NOT BUILT: the specified power-type reason "the matchup edge is in total_bases". total_bases is recorded INCONCLUSIVE (+0.0038, CI [-0.068,+0.075]). Wiring it would assert an edge measured as indistinguishable from zero -- the exact fabricated-reason failure this module exists to prevent. BOMBER x hits is 29 rows short of the gate and is the archetype the model actually reads. That is the first slot to test, not GHOST. Counter and frozen clusters byte-identical. No letter was moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 |
||
|
|
6b17f79367 |
Per-archetype re-audit: no slot reaches 500, and the replication unit
decided everything The premise does not hold. prove-hit-factors.js has no date filter anywhere in it and pages the full table -- there was never a window to widen. Full clean history is 1,266 rows, not 2,715. platoon was not "proved" last session, it was explicitly held on 4.5%-median-contaminated season-to-date splits, and pitcher_contact_profile was demoted. The proven set going in was one factor, not three. STEP 1: no archetype slot reaches n>=500 on full history. Best is BOMBER at 408, and BOMBER is the most common archetype on the board. GHOST 173, BRUSH 64, DRIVER 43, CATALYST 16. These are confirmed genuinely short, not artifacts. STEP 2 is where the real finding is. park_hits initially PROVED at 619 rows across 45 games -- but those games only ever visited 14 distinct park values. A park effect is replicated across parks, and unmodelled park heterogeneity is confounded with the thing being estimated. Each factor is now clustered on the coarser of the game and the entity its treatment rides on. That flipped two verdicts and confirms Kev's causal-correctness thesis from a new direction: defense_by_direction has 442 hitter-team units of replication where crude team defense has 26. The correct atom is not just more accurate, it is the only one measurable at all. park_hits (14) and defense (26) can never be validated however long the ledger runs -- the same ceiling as park dimensions, reached independently. Also fixed a bar I got wrong last session: I transplanted the 500-row floor onto clusters, which refused a factor with 1,059 rows over 85 games while answering neither question. Two floors now -- rows>=500 for a stable estimate, clusters>=40 for a trustworthy interval. Not a lowered bar: park_hits and defense are still refused. PROVEN: defense_by_direction only, pooled, [-0.0054,-0.0012] at 99 tests. It stays POOLED-ONLY -- no per-archetype reasoning wired, nothing grandfathered. The card must not say "GHOST: defence matchup strong" because we have not earned that sentence. The predicted fingerprint did not appear either: BOMBER -0.0036 vs GHOST -0.0024, the opposite direction, both noise-dominated. Recorded so it is not claimed later. RESCALE: NOT READY. One proven factor worth -0.0031 Brier. Rescaling on that is relabelling. Counter and frozen clusters byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 |
||
|
|
7b85934dc3 |
Under-querying vs out of data: the answer depends on the unit
The platoon test's n=452 described how much of the JOIN survived, not how much data exists. There are 1,266 clean settled hits rows and zero quarantined ones. platoon_splits had been ingested from tonight's lineups only (315 players), so any hitter who settled a prop without appearing in an ingest-day lineup was silently absent from every test. Backfilled all 380 hitters (81 fetched, 0 unresolved). Re-ran on 1,059 rows, up from 452. THE DEMOTION IS THE HEADLINE. pitcher_contact_profile, the strongest proven factor in the programme (-0.0064, CI [-0.0113,-0.0014]), roughly halved to -0.0034 on more than double the sample and its corrected interval now spans zero. The Bonferroni denominator also rose to 55, which widens every interval -- but a denominator cannot move a point estimate, and that halved on its own. platoon and platoon_severity now clear the bar and are NOT promoted. Upper bound -0.0001, on season-to-date splits that contain the games they predict: measured contamination is 4.5% median, 12.4% at p90, 137% worst. I had assumed ~1%. They stay CANDIDATE pending point-in-time splits. GAME-LEVEL IS A DIFFERENT PROBLEM. game_context held zero weather rows ever -- not because the fetcher was wrong (it correctly targets Open-Meteo's archive) but because ledger_entries keys a game as mlb:2026-08-03:Away@Home and game_context keys it as mlb:823437. Every lookup missed and NULL columns read as honest absence. Third occurrence of that class. Fixed the join: 96/101 settled games now carry actual archived weather, park dimensions backfilled 15 -> 30 venues. But 928 total_bases rows sit on 47 games at 17.6 rows per game. Park and weather assign one value per game, so resampling rows would have manufactured a pass. factorGate now resamples clusters when rows carry one and judges sample against effective_n; unclustered rows keep the original path byte-for-byte. Verdict: 47 clusters < 500, and the point estimate is +0.0011 -- worse, not merely unproven. Weather needs ~57 more days. Park dimensions need never: there are 30 ballparks in MLB, so a venue-constant factor can never reach 500 independent units. That bar was built for player-level factors and does not transfer. Wind is refused. We have speed and bearing for all 96 games; we lack park orientation, and 220 degrees is blowing out at one park and in at another. Using speed alone would assert an effect while discarding the sign that decides what it is. Counter and frozen clusters untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 |
||
|
|
6c97f59546 |
WNBA truth correction + THE p_win FLIP (live, rollback armed)
PART A -- WNBA TRUTH CORRECTION (no behaviour change).
WNBA does not "abstain" and is not "anti-predictive". The -0.12 that
produced those words was NBA-template machinery run on WNBA data -- WNBA
has never had its own archetypes, variables, conditions or calibration,
which is precisely the "sport stubbed in on another sport's template"
CLAUDE.md forbids. That is an UNBUILT MODEL'S EXPECTED FAILURE, not a
verdict on the sport; reading it as a verdict would quietly retire a sport
we never actually attempted. Its own build is QUEUED, after MLB.
The guard CODE is unchanged -- FORECAST_RANKED_SPORTS = {'mlb'} and the
inheritance test are correct live safety either way. Only the meaning is
corrected, and generalised into the doctrine-as-a-gate: a sport ranks on
p_win ONLY once its OWN model is built and shown to predict (calibration
AND resolution on its own holdout). Others are held out as NOT-BUILT,
never as failed. Re-labelled across gradeRanking, snapshot route, tests,
MASTER-PLAN and the challenger report.
PART B -- THE FLIP, gated on a full-slate re-run.
The re-run found something better than a bigger sample. An induced
snapshot graded 7 props: gradeAndCacheSlate runs with DEFAULT_LIMIT = 25
and ~72% of those refuse for insufficient_data, while 546 props are
gradeable. So 8 props IS the board, structurally -- not a small sample of
it. Logged as its own finding; the cap is a separate order.
For a statistically meaningful delta I used 11 real historical boards
(n=328, board sizes 14-57): 79.9% of rows move, mean 5.16 places per
board, TOP READ CHANGES ON 9 OF 11 BOARDS. The re-ordering holds at real
board size. Query committed.
FLIPPED:
- rankGrades drops its edge key (safe for every sport: removes a
non-predictive tiebreak without putting p_win in front).
- selectTopGrades leads on forecast_rank, edge key removed.
- flattenToEdgeBoard sorts on forecastRank, not edge -- this board had
edge as its PRIMARY key, so the whole mobile board was ordered by a
quantity measured not to predict.
- forecast_rank threaded onto strip props.
Sports whose model is not built supply no forecast_rank, so their boards
fall through to the unchanged grade chain -- the fallback is the guard.
ROLLBACK ARMED: boards sort by forecast_rank WHEN PRESENT, so
FORECAST_RANK=0 reverts every surface on the next response -- no deploy,
no client release.
Edge is still computed, stored, carried and displayed as a labelled
diagnostic. Retired from ranking, not deleted.
Eight superseded tests updated to strictly stronger INVERSE properties --
they now fail if edge is ever re-introduced as a ranking key, which the
originals could not detect.
Gates: 4,045 tests / 323 suites green; next build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
ef4ac60b81 |
Per-sport rank guard + edge diagnostic-only display + delta report
DELTA MEASURED on live prod grades (live ordering unchanged): MLB 7/8 props move (87.5%), mean 2.5 places, TOP READ CHANGES (corey seager hits 1.5 under -> jake burger hits 0.5 over). WNBA 25/25 move, mean 4.1, max 12. This is a large re-ordering, not a tweak. Caveat recorded rather than buried: MLB had only 8 graded props at measurement time. The percentages are real; the sample is one small slate. Re-run before the flip -- it is one call. PER-SPORT DOCTRINE ENFORCED IN CODE. WNBA moves the most and must NOT adopt this: its p_win is anti-predictive, so ranking that board by p_win would sort it by a signal measured to point the WRONG WAY -- worse than the incumbent, not better. A comment would not have stopped a future flip from going global, so FORECAST_RANKED_SPORTS = Set(['mlb']) gates the forecast_rank stamp, with tests asserting no sport inherits MLB's result. A sport joins only by passing its own holdout. EDGE IS NOW DIAGNOSTIC-ONLY IN DISPLAY. MobileEdgeBoard.EdgeCell rendered green (--g-a) for positive edge and red (--miss) for negative. Two things were wrong: green/red IS a quality claim on a quantity that does not predict, and ROW-GRAMMAR reserves red for settled-negative ONLY -- a negative diagnostic is not a settled loss. Now neutral mono with a diagnostic tooltip; header reads "MKT GAP · DIAGNOSTIC". The number is still shown -- no display went blank. DeskShowcase neutralised likewise. PINNACLE LOGGED, NOT ENSHRINED. Per the order, "market-not-sharp" is PENDING-RECOVERY rather than a confirmed permanent limitation. The single question for PropLine is in BLOCKERS.md with its evidence, and MASTER-PLAN now carries the pending status instead of the permanent claim. Live sorts remain byte-identical: selectTopGrades, flattenToEdgeBoard and topGradedService all still call the incumbent. Gates: 4,041 tests / 323 suites green; next build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
bedbb8c008 |
Build 2 Phase B: checkout claims atomically, webhook finalizes, bypass retired
Stripe wired to the Phase-A mechanism. Live prices verified READ-ONLY; no Stripe object was created and no payment was run. B1 PRICE KEY -> ID + BOOT ASSERTION (src/config/stripePrices.js). claim_founder_slot returns a price KEY; this module is the only place a key becomes a Stripe id, and it reads env (legacy STRIPE_PRICE_ANALYST/DESK accepted as fallbacks so an existing deploy keeps working). assertPricesConfigured() is wired into server.js and FAILS BOOT when any of the four is unset — verified by deleting one: it throws "BOOT FAILED - unset Stripe price env for: desk_founder". A blank price can no longer sell at the wrong rate or 503 a customer at checkout. B2 CHECKOUT CLAIMS BEFORE CREATING THE SESSION. resolveCheckoutPrice previously called founderSeatsAvailable() — a COUNT read, which WAS the race (two checkouts at seat 99 both read 99, both got founder). It now calls claim_founder_slot and uses the returned key. The promo-code bypass is retired: founderCode no longer influences price or metadata, and getPriceId THROWS if handed a code rather than silently granting a founder rate. metadata.is_founder is renamed is_founder_audit and the webhook no longer reads it — caller-supplied metadata must never decide who pays the lifetime founder price. TRANSIENT-FAILURE POLICY (a real design call, not a default): if the claim RPC errors we now fail RETRYABLY (503 claim_failed) instead of silently selling at standing. Both silent options are irreversible — standing permanently overcharges someone who was entitled to founder, and granting founder without a slot pushes past the 100 cap at permanent prices. A full cap is NOT an error and still returns standing normally, per "never error to the customer": a full cap is a real answer, a DB blip is not. B3 WEBHOOK FINALIZES THROUGH THE SINGLE WRITER. checkout.session.completed calls finalize_founder_slot, which flips user_profiles.founder_pricing (canonical) and mirrors users.founder_status in the SAME txn, so they cannot drift again (they already had, 1 vs 0). Verify-after-write re-reads the profile and logs the end state. If finalize errors, the tier is still set so a PAID customer is never left unentitled, but no founder flag is guessed. B4 SIGNATURE VERIFICATION was already present (constructEvent with STRIPE_WEBHOOK_SECRET + express.raw). The live endpoint exists and is enabled: https://api.vyndr.app/api/stripe/webhook subscribing checkout.session.completed, customer.subscription.created/updated/deleted, invoice.payment_succeeded/failed. VERIFICATION: V1 boot assertion proven by simulation. V2 all four prices retrieved live and confirmed active with correct amounts and monthly recurrence (14.99 / 24.99 / 44.99 / 59.99) — read-only, nothing created. V3 no code path grants founder except the claim (greps clean; the legacy helper now throws). V4 the handler reads customer/subscription/metadata.user_id and calls finalize with signature verification in place. V5 reset to a pristine 100 free / 0 claimed baseline with both founder flags at 0. Secrets live only in .env (0600, gitignored, untracked). A pre-commit scan confirmed NO tracked file contains the key material. Floor: 320 suites / 3984 passed, 3 skipped (superseded founder-code tests), web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
5930f18d81 |
Free proof surface: /record — tier-record-forward, honest CLV building panel
Presentation over existing endpoints. src/ untouched (git diff empty): no grade, model or ledger change. Pricing/migration are Builds 2/3. TIER-RECORD-FORWARD. /record reads the canonical public aggregates (/api/accuracy + /api/ledger/model) and prints them as-is. B 60% n512 and C 57% n413 ship with C honestly BELOW B; A (n2), D (n5) and F (n5) render HOLLOW with their real sample instead of a rate. Sport slicing (all/mlb/wnba) is client-side because the endpoints ignore ?sport= — mlb 526 @62%, wnba 411 @54% come from the sports map. THE LOAD-BEARING RULE, enforced in lib/proofRecord.js and locked by tests: where the source withholds a percentage it stays null. A is 1/2 and therefore 50% is derivable — a test asserts we do NOT derive it, because the API withheld it on purpose (n < 20). CLV IS AN HONEST ABSENCE, NOT A NUMBER. beat_close_pct is null because clvCaptureReliable() has not passed. The panel says "NOT PUBLISHED YET" and explains that any percentage printed today would be measuring our collection gaps as much as our edge; it surfaces the accruing sample (937) but no rate. Tests assert the panel never falls back to clv_beat/clv_sample (34/937 = 3.6%) and that the serialized panel contains no "3.6" — that number is computable and would be wrong, which is the exact fabrication this surface exists to refuse. The panel is built to receive a real number later without a redesign. HELD, and named on the page rather than faked: calibration and accuracy-over-time are absent because there is no honest source (no claimed-vs-actual endpoint; window_days fixed at 30 with no series). The page says so, and says it is not because they are unflattering. A page-level test asserts no hard-coded percentage exists in the markup, so no figure can drift from the aggregate, and that the page never touches /api/snapshot or itemized rows — the Build-1 gate holds and the exploit stays dead. Floor: 320 suites / 3986 tests green (16 new), web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
713f90183f |
Build 1 CORRECTED: itemized grades are PAID (live AND settled) — exploit killed
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
|
||
|
|
6d36e05bfe |
Build 1: the settled/live gate — unresolved is paid, resolved is free
Serving/gating change only. src/services/ untouched (git diff empty): no grade,
model or settlement-logic change. Pricing and migration are Builds 2 and 3.
Push scoring untouched.
THE RULE: a grade is PAID while its outcome is unknown and becomes FREE the
moment it resolves.
Resolution is read ONLY from a written outcome — never from time, game status or
gradedAt. A game can be final long before the settle pass runs, so treating
"probably over" as settled is exactly how a live edge would leak; a test asserts
an hours-old gradedAt with no outcome is still LIVE. void and unrecoverable ARE
resolutions (terminal results, no live edge left). isResolved FAILS CLOSED:
null outcome, {} with no result, and empty-string result all read as LIVE, so a
settlement failure withholds content rather than exposing it — the same
direction resolveTierFromRequest fails.
FREE/ANON: settled grades pass through IN FULL, reasoning and kill conditions
included — settled reads are the proof product and cost nothing once the outcome
is known. That also converts the previously-unenforced board reasoning leak into
a deliberate rule rather than an oversight.
LIVE grades for unentitled tiers are reduced to a shell: every piece of model
JUDGMENT is dropped (grade, confidence, confidence_basis, reasoning,
kill_conditions_triggered, projection, edge_pct, matchup_grade, form, alt_lines,
kelly) and `locked: true` is stamped so the card renders the unlock prompt. The
free-side DATA stays so the tease is real rather than empty: player, market,
line, book_odds, fair_odds, season/last10 stats, archetype, gradedAt, history.
fair_odds deliberately survives — the de-vigged fair number is the free hook and
is never the paywall. A test asserts the serialized free row carries no trace of
the withheld judgment.
THE TEASE IS 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
a free viewer learns that N reads exist and their tier shape without being able
to work out WHICH prop is the A.
Gate order in the route: stripModelPrice (S67) first, then gateLiveGrades.
Entitled tiers get the array back by reference — zero cost, zero change.
Floor: 319 suites / 3971 tests green (10 new), web build exit 0.
One test note: the route-level supertest case was removed deliberately — it
needs a live Redis and hangs on ioredis' reconnect timer in a single-suite local
run (known behaviour, CLAUDE.md). The gate contract is fully covered by pure
tests; the wire is verified against prod anonymously in the fingerprint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
085e8a3a63 |
D1 finish: row-hover rationale, IntersectionObserver reveal, team-gradient chips
Additive frontend. Backend untouched (git diff src/ = empty): no grade, model,
classifier or ledger change. Scope held to the row anatomy these three items
need — no System-artboard-wide rebuild. Push scoring untouched.
REVIEW ZERO — the two checks that decided whether these could be honest:
0.2 RATIONALE SOURCE — VERIFIED REAL. Live snapshot grades carry `reasoning`
and `kill_conditions_triggered`. The summary is built by analyzeViaEngine1
from the actual feature vector (l5/l20 averages, gap to the line, home/away,
opponent defensive rank, rest days) and kills carry real codes + reasons.
So the hover shows genuine grade truth, not a placeholder.
0.3 TEAM COLOURS — PARTIAL, and deliberately left partial. The System artboard
defines a colour pair for only 10 teams (BOS CHC CHI DEN LAD MIL MIN NYY PIT
SD), lifted verbatim; lib/teams.js holds ~80. The other ~70 are NOT invented
— a wrong team colour is a recognition error the user reads as fact. Unknown
teams get the honest-neutral chip (muted border, no colour claim), never a
guess and never a blank gap. Coverage is reported by coverage(), not hidden.
SHIPPED:
- web/src/lib/rowRationale.js — rationaleFor() returns real summary + kills, or
NULL. No generic fallback: an empty hover is honest, a manufactured "why" is a
fabricated model explanation. A locked/tier-gated reasoning is treated as
ABSENT rather than paraphrased or leaked, and a kill condition with no reason
explains nothing so it is dropped.
- web/src/lib/reveal.js — IntersectionObserver reveal that fires ONCE then
unobserves ("react to truth, then rest"), reuses D1-A's bootDelayMs for the
60ms stagger so there is ONE source of truth for the timing, and reveals
IMMEDIATELY when IntersectionObserver is absent (SSR/test) so a missing API can
never hide real content. Reduced motion is handled by the existing CSS, so the
row is visible either way.
- web/src/lib/teamChips.js — Rev-3 geometry (10px, 135deg, before the abbr,
inside the row) plus the ranked opacity ramp 1/.86/.64/.48 so chips dim with
their row. Swap-ready for licensed logos at the same size.
Floor: 318 suites / 3961 tests green (15 new), web build exit 0.
The three modules are pure and unit-locked; mounting them into the live row
components is a follow-up, and the visual result belongs in the Chrome audit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
3d1a3c7794 |
D1-A: combat glyphs, boundary-channel blue, reaction primitives, READ-FAB
Additive frontend/visual. Backend untouched (git diff src/ = empty): no grade,
model, classifier or ledger change. The 41->74 registry expansion is HELD for
D1-B. Push scoring untouched.
REVIEW ZERO — classifier coverage bounded the glyph wiring. Three buckets, and
the computation was redone three times before it was right (the frontend keys
GLYPHS by ARCHETYPE NAME while the backend keys `glyph:` by SHAPE NAME, and most
registry keys are unquoted identifiers — the first two passes mis-parsed both):
(a) classifier-backed, already wired: 38
(b) classifier-backed, package SVG exists, NOT wired -> WIRED HERE: 6
striker, grappler, pressure, counter, grinder, finisher — all MMA/combat
archetypes in archetypeService.js that were rendering EMOJI fallbacks
('*', 'x', '>', '<>') where the package ships real 24-grid duotone marks.
(c) package SVG with no classifier -> HELD for D1-B: 39 (wiring them would
render nothing)
(d) classifier-backed but NO package SVG: 2 ('dual threat', 'paint boss') —
a DESIGN gap, not a build gap; flagged for D1-B.
GLYPHS map 38 -> 44 keys, deliberately far short of the package's 83.
BOUNDARY CHANNEL — the blue tokens already existed (--priced-out set) and were
applied on NoMarketState and the scan void box, but PriceTriplet's NO_MODEL
("line not priced") still rendered in neutral text, so the channel was applied
inconsistently. NO_MODEL now renders in the channel, completing "every boundary
state or none". Token-only (no hex fallback and no hex in prose — PriceTriplet's
own test forbids literal hex, and it caught both).
REACTION PRIMITIVES — new web/src/lib/reactions.js + globals.css keyframes at the
exact HANDOFF timings: flash .75s ease-out, boot stagger 60ms steps, reactions
gated 1.5s, WIRE hold 6s. nudge() REFUSES a no-op (null/absent direction -> no
flash) so the primitive cannot be attached to an idle loop — a flash without a
new datum is the UI lying about the feed. Reduced-motion honoured.
READ-FAB — aligned to the exact package geometry: 50px circle, translateY(-14px),
6px void ring (was 46px, marginTop -16, 3px ring).
CARD TOKEN — audit correction: #0E0E14 was already tokenised as --bg-1/--card;
the audit's "1 file" was counting the raw hex, not the token. No change needed.
Floor: 317 suites / 3946 tests green (16 new), web build exit 0.
NOT DONE THIS ORDER (reported, not silently dropped): row-hover rationale and
IntersectionObserver reveal (Phase 3 item 8) and team-gradient chips (Phase 4
item 10) are not implemented — they need the System artboard's row anatomy,
which is a larger port than the rest of D1-A.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
bf7c0a3c08 |
Wave 3: /compare built (real head-to-head); resolution tail scoped, not shipped
No grade, ledger or scoring change. Push scoring untouched.
REVIEW ZERO 0.3/0.4 — THE RESOLUTION TAIL DOES NOT FIRE. The resolver is
POST /api/grading/resolve (routes/grading.js:208), and its fanout at :356-371
covers webPush, telegram and discord — but:
- share-card generation: SPEC'D-NOT-BUILT. Not in the fanout at all (grep
shareCard in grading.js = 0). shareCards/renderer.js exists with ZERO
callers, so the component is built but no step would ever invoke it.
- push notifications: BUILT-NOT-FIRING. In the fanout but gated on
webPush.configured() (VAPID). push_subscriptions = 0 rows and
user_notifications = 0 rows — nothing ever subscribed or delivered.
- Telegram result posts: BUILT-NOT-FIRING (gated on BOT_TOKEN + CHANNEL_ID).
- Discord result posts: BUILT-NOT-FIRING (gated on webhookFor('results')).
- recap (all-Final trigger): SPEC'D-NOT-BUILT. No recap file exists in src/.
AND THE WHOLE TAIL IS UNREACHABLE: nothing calls /api/grading/resolve — there is
no ESPN poller in the repo. The live settlement path is the scheduler's
settleAllOutcomes + settleAllLedgers, which fans out to opsNotify only (ops
alerts), with no user-facing output. So even the built channels have no trigger.
Per the order's own rule, ShareCard, /notifications, result posts and recap are
therefore ALL SCOPED, none shipped — no dead shells over a silent pipeline.
BUILT — /compare. Semantics (0.2): a same-market head-to-head, two players with
every row a measure BOTH sides are scored on, aligned via alignRows so the
numbers are comparable — deliberately not two disconnected graded props. Reads
the live /api/stats/player/:name?sport= aggregate. Honest-absent three ways: an
unresolved side reads NO DATA while the other still renders; a measure only one
side has renders a dash, never 0; if neither resolves the page refuses to
compare. NO VERDICT — it shows measures and says the reader draws the call.
Two pre-existing tests (vyndrPhaseE, vyndrParityQA) asserted the in-development
placeholder; both superseded rather than deleted — they now assert the stronger
properties against the real page (live fetch, no sample players, NO VERDICT,
NO DATA, "not a zero").
Floor: 316 suites / 3930 tests green (10 new), web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
ff7f5d8d2d |
Wave 1: wire /intelligence, /slip, /parlay + /marketplace honesty pass
Wiring + one copy pass. No grade, ledger, model or scoring change (diff empty
across intelligence/, ledgerService, outcomeService, gradeSlateService).
REVIEW ZERO — each surface proven with real data BEFORE wiring:
0.1 /intelligence vs /system are NOT duplicates. System.dc.html is a
multi-surface artboard (TERMINAL + INTELLIGENCE + WIRE sections), not the
design for a distinct /system route; its INTELLIGENCE section is already
realised as the live app/intelligence/page.tsx. No /system page exists and
none should be built as a second copy — the prod 404 is correct.
0.2 /intelligence renders live and gates SERVER-side, not by blur: the proxy
requires auth and limits by tier (desk 50 signals / non-desk 8), and
returns 401 to an anonymous caller (verified live). No leak.
0.3 /slip parses a real DraftKings slip end to end: 3/3 legs,
needs_review false, Aaron Judge total_bases over 1.5 @ -115. Honest limit
recorded: parsers are layout-rigid, an unsupported layout yields ZERO legs
rather than wrong ones (never-guess), so real-world OCR hit-rate across
layouts is CANNOT DETERMINE until user slips arrive.
0.4 /parlay direct route hits the real correlation builder on the same
ParlayContext the drawer uses.
0.5 /marketplace advertised four unbuilt things but made NO performance or
profit claim, and its capture was already real (/api/waitlist upserts to a
waitlist table). The gap was tense, not fabrication.
WIRED: Nav MORE gains Intelligence, Slip Reader and Marketplace; Parlay Lab
re-pointed from the drawer hash to /parlay (the drawer is unaffected —
ParlayPanel stays mounted with its floating badge).
GATING: /intelligence added to GATED_ROUTES because its feed 401s signed-out, so
an ungated link would land visitors on a permanently empty page. /parlay stays
OPEN deliberately — it is the free parlay funnel and gating it would be a
monetization regression.
/marketplace honesty pass: every item body now opens "Not built yet." /
"Not written yet." / "Not produced yet." with what is planned; the subhead states
it is not a purchase, not a pre-order and not a promise of a ship date; the
playbook item carries "No profit claim, no promised return". The capture stays
real — no fake button. Unit-locked.
Floor: 315 suites / 3920 tests green (12 new), web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
0997334f8b |
Order B: retire edge_pct display. Promotion gate NOT passed — no flip.
THE PROMOTION WAS NOT PERFORMED. Champion grade path byte-identical (diff empty
across intelligence/, gradeSlateService, snapshotService). Projection, p_win and
the CLV instrument untouched.
REVIEW ZERO IS A GATE AND THREE OF FOUR PREREQUISITES FAIL:
0.1 scores are ESTIMATED priors from the founding spec, not measured. The
premise's cited values are not in the code either — the module holds
nba:points .80 and mlb:total_bases .55; there is no NBA 0.72 and no WNBA
score at all.
0.2 VERSION-BOUNDARY TAG DID NOT LAND — config/modelEras.js has zero shading
references. It was deliberately not applied twice (nothing had been
promoted) and reported both times. The order's own rule says STOP.
0.3 NO ROLLBACK FLAG EXISTS — zero occurrences of SHADING_ENABLED /
EDGE_SHADING / shadingEnabled anywhere in src/.
0.4 takeable tags DID land (migration 034, 1246/1254 rows). PASS.
AND THE APPROVED DELTA DOES NOT MATCH THE MEASURED ONE. Approved: 43.6% of
grades re-letter, efficient markets tighten and soft hold. Measured on all 1250
live rows: 97.4% change (1217), 79.8% move UP, 17.6% down, resulting in 79.0%
A-family (MLB 93.4%) against the champion's 0.2%. And rows_actually_shaded = 0
of 1250 — 96.5% of markets are unscored (f=1) and the one scored market present
is the anchor (f=1.0 by construction). The entire re-letter comes from switching
to edge-vs-fixed-bar grading, NOT from efficiency shading, which is inert on
this board. That is an unapproved grading-basis change riding along, which the
order's own "no new scaling changes riding along" guardrail forbids.
Flipping would re-letter 97.4% of an append-only public record, move 79.8% of
grades UP and mint A's on 79% of the board, on a letter whose measured
correlation with outcomes is r ~ 0.005 — the exact scenario the permanent
founder ruling forbids.
SHIPPED — ORDER B (independent of the promotion, and a live falsehood):
edge_pct display retired from GradeResultCard (confidence strip, EDGE stat cell
now honest-absent, alt-ladder rung) and SoccerGradeResult. DeskShowcase kept
(already honest). Computation and the board's signed-edge sort fallback SURVIVE
— deleting them would re-break the sort fixed on 2026-07-29; a test asserts all
three survive and the sort still orders agrees -> disagrees -> absent.
Fixed two build-breakers the retirement caused (orphaned edgeColor import,
orphaned edge_pct destructure; edge_pct stays on the props contract). Two
pre-existing tests superseded rather than deleted: they asserted the edge figure
is sign-coloured, and now assert the stronger property that no edge percentage
renders at all.
Floor: 314 suites / 3908 tests green (9 new), web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
c2c7abbb65 |
Edge-shading challenger: built + measured. Flooding NOT fixed — input scale is the bug
Challenger only. Champion grade byte-identical (verified by diff). Nothing promoted, no live grade re-lettered, no ledger row deleted or re-settled. BUILT src/services/challengers/efficiencyShading.js (measured-never-served): adjusted_edge = raw_edge * f(efficiency); grade = band(adjusted_edge) against ONE fixed bar (A+>=10, A>=5, B>=3, C>=1, D>=0, F<0) that never moves. f(e) = E_SOFTEST/e bounded to (0,1] — soft markets intact (never amplified), sharp shaded toward but not past zero, unscored -> f=1 and FLAGGED. A fence test asserts no production grade path imports it. Cross-market behaviour is unit-proven: the same raw 6% edge grades A in soft mlb:total_bases and B in sharp nba:points. MEASURED on 1250 live ledger rows — Phase 2.5's answer is NO, the flooding is not gone: challenger 79.0% A and 80.9% A/B (MLB 93.4% A) vs champion 0.2% A. TWO findings explain why, and they are the point of the order: 1. The shading is a NO-OP on the live board: rows_actually_shaded = 0 of 1250. 96.5% of rows are UNSCORED (f=1), and the one scored market present (mlb:total_bases) is the anchor so its f is 1.0 by construction. mlb:strikeouts and nba:points do not appear in the ledger at all (our basketball is wnba, not nba). Challenger vs baseline: 0 rows changed. 2. Placement was never the bug — the INPUT SCALE is. Against a fixed 5% bar the RAW edge already clears A on 100% of MLB doubles, 89.6% of hits, before any shading. MLB median raw edge is 60%, twelve times the bar. Decisive test: apply the sharpest score in the spec (f=0.647) to EVERY row — the maximum the design permits — and 75.8% still clear A (MLB 91.7%). Since f is bounded <= 1, no achievable shading can close a 12x overshoot. Moving the multiply from the threshold to the edge does not change the outcome. This is edge_pct behaving as the 2026-07-29 diagnosis described: a price-free (proj-line)/line gap whose scale is a function of line size. It is not a betting edge, so no fixed betting-edge bar is meaningful against it. 2.6 efficient-market over-suppression: CANNOT DETERMINE — zero live rows are shaded, so there is no efficient market in the data to over-suppress. Phase 3: takeable tagging was completed in the previous order (migration 034, 1246/1254 rows) and is not repeated. The model-version boundary is again NOT applied: nothing promoted, so no boundary exists. Unblocking needs the input replaced, not the multiply moved: p_win vs fair_prob (both already computed) instead of edge_pct, plus scores FIT from our own record for the markets we actually grade. Floor: 313 suites / 3899 tests green (9 new), web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc |
||
|
|
2bfaeff572 |
Ledger takeable tagging (deferred C2); efficiency challenger BLOCKED
Champion grade UNCHANGED. Push scoring untouched. Additive tags only — nothing
deleted, nothing re-settled.
PART A — THE EFFICIENCY CHALLENGER: BLOCKED, NOT BUILT.
Review Zero came back ABSENT on all three inputs:
0.1 efficiency scores DO NOT EXIST (zero occurrences of market_efficiency /
marketEfficiency / efficiency_score in src/ or web/src/).
0.2 base thresholds DO NOT EXIST (engine1.js has zero `edge` references — the
grade is not an edge-vs-threshold comparison; grade_thresholds.json holds
PROBABILITY bands).
0.3 the +/-0.05 additive efficiency nudge DOES NOT EXIST. The only 0.05s on
the grade path are featureCache.teammate_absence_bump, a bvp_advantage
cutoff, and p*0.9+0.05 inside probabilityEstimator (the 0.5*0.1 term of
the shrink-toward-0.5). There is no additive scaling to replace.
So a challenger differing from the champion in EXACTLY ONE thing cannot be
constructed: there is no additive scaling to swap, no base threshold to
multiply, and engine1.js has zero `sport` references so market cannot reach the
grade. A threshold must exist first — that is R1 of
specs/full-output-grade-mapping.md, an explicitly held separate order. Shipping
R1+R4 together would make the Phase-3 delta report misleading: the re-letter
would be driven mostly by switching to probability grading while being
presented as the efficiency fix.
0.4 coverage: the spec names 5 scores; the live ledger has 11 markets and only
MLB total_bases maps to one. 9 of 11 have no score, so "all scored markets"
cannot be satisfied without inventing 9 numbers.
PART B — LEDGER TAKEABLE TAGGING: BUILT (the deferred C2).
New src/config/takeableStandard.js: floor on the minus side, UNCAPPED plus.
Deliberately NOT valueEngine.isTakeable (the -160..+200 PROMOTION band) — a
+400 prop is not promotable but IS takeable; a test asserts the two diverge on
the plus side and agree at the floor so they can never quietly merge. Absent
price returns null, never false (Number(null) === 0 would tag a missing price
takeable). The floor is POLICY not derived (C1 could not derive one) and is
labelled so; each row records takeable_floor so a re-derivation can re-tag.
Migration 034 (applied + tracked): ledger_entries.takeable boolean +
takeable_floor numeric, nullable, partial index. Forward tagging in
ledgerService at row build; backfill in one statement.
Result: 1254 rows, 1246 tagged (781 takeable / 465 below floor), 8 NULL with
null_despite_price = 0 (the NULLs are genuinely priceless rows). Settled 1163
and graded 1254 unchanged.
PART C — the model-version boundary tag is DELIBERATELY NOT APPLIED: no scaling
change shipped, so no boundary exists, and stamping one would mark a model
transition that never happened. modelEras.js is its home when a real one lands.
Floor: 312 suites / 3890 tests green (8 new), web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
72a14dc4cd |
Build /api/props/top-graded server selector: rank with p_win, serve without it
New READ endpoint. No grade, ledger row, lock_line, or scoring write. Push
scoring untouched.
REVIEW ZERO CORRECTED THE PREMISE: the handler NEVER EXISTED in any commit
(searched git rev-list --all for a /top-graded definition in src/ — zero hits).
Not "removed" — the three axios callers (cheatsheetGenerator, gradeOfTheDay,
widget) and the Next proxy were written against a phantom endpoint, so those
three content generators have silently received [] for their entire life.
Contract recovered from the four consumers, not guessed: {props:[...]},
?sport=UPPERCASE (absent = all sports, which gradeOfTheDay relies on) + ?limit,
rows carrying player/stat/line/direction/sport/grade/confidence? plus the
player_name/stat_type aliases and game_id.
POPULATED-PATH RISK FOUND: the board's populated branch had never run in prod,
and dashboard/page.tsx:463 calls g.stat.replace(/_/g,' ') UNGUARDED (g.player
also feeds the row key, /scan URL and heading; sport must be UPPERCASE for
SportPill). toRow requires non-empty string player+stat and a finite line,
uppercases sport, and DROPS unrenderable rows — a shorter board beats a broken
one.
THE LEAK BOUNDARY (why this is server-side): the browser cannot rank on p_win
for all tiers because stripModelPrice deliberately withholds it from unentitled
tiers. Order of operations is
read cache -> RANK with p_win (every tier) -> map rows incl. model fields
-> stripModelPrice(rows, tier) -> serialize
so a free caller receives the paid RANKING without the paid VALUES. Tier comes
from resolveTierFromRequest, which FAILS CLOSED to 'free'. Cache-Control is
private under a bearer token, public otherwise (the /api/snapshot precedent).
ONE SHARED DEFINITION, no drift: new src/utils/gradeRanking.js
(takeablePWin/descNullsLast/rankGrades). heroPropService now imports
takeablePWin instead of its inline copy (behaviour unchanged — it was that
logic verbatim); the selector imports rankGrades; web/src/lib/slateAdapter
keeps its mirror (the browser cannot import src/, S25) and a test cross-checks
the two on identical fixtures (playerName.js precedent). Board is grade-first
("top GRADES"), hero is p_win-first ("top read") — they differ BY DESIGN and
agree within the leading tier.
HONEST LIMIT: the Next proxy (cachedBackendJson) sends no Authorization header
and caches under a shared key, so via the dashboard every viewer gets the
free-tier payload — correct order, no paid values. That is the SAFE behaviour;
forwarding auth into a shared cache is exactly how a paid payload leaks to
anonymous viewers. Per-tier delivery through the proxy needs a tier-keyed cache
and is not done here.
Verified on real prod snapshot data (anonymous path): MLB 8 props, WNBA 10,
0 paid-field leaks, render-contract safe on every row, sport uppercase.
Floor: 311 suites / 3882 tests green (18 new — leak test uses POPULATED p_win,
not today's nulls: entitled gets p_win and it drove the order, unentitled gets
a byte-identical order with all five MODEL_FIELDS absent and no trace in
JSON.stringify, while book/fair market facts survive). Web build exit 0.
Dashboard visual is auth-gated -> tagged for the Chrome audit, not faked.
Held: edge_pct rescale/retirement (Order B); board columns/contract unchanged;
tier-keyed proxy caching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
b85b351993 |
Grade-board sort: signed signal, takeable-gated p_win, missing sorts LAST
Display ORDERING only. No grade, ledger row, lock_line, scoring, or edge_pct
scale/display change. Push scoring untouched.
Two defects removed from selectTopGrades (wrong at ANY scale, independent of
edge_pct's separate retirement):
1. edge: Math.abs(numOr(g.edge, -Infinity)) — abs() on an already-
direction-signed value ranked the model's strongest DISAGREEMENTS level
with its strongest agreements (177 public ledger rows carry a negative
edge; positive = the model AGREES with the graded side).
2. Math.abs(-Infinity) === Infinity, so a row with NO edge sorted FIRST —
absent data presented as the top pick (the Number(null) class).
New key: grade -> confidence -> takeable-gated p_win (nulls LAST) -> SIGNED
edge (nulls LAST) -> input order. Scales are never mixed in one comparator.
Takeable band = web valueState.isTakeable, asserted byte-equal to the hero's
config/valueEngine.isTakeable (-160..+200) incl. strict-null.
Alt-line ladder (analyzeViaEngine1:506) no longer sorts by edge_pct: ordered
highest-p_win-first derived analytically at zero added compute — P(stat >= k)
is monotone non-increasing in k, so p_win-desc is line-ASC for an over and
line-DESC for an under. base stays marked; no consumer depends on
alt_lines[0]; deskShowcaseService.rungsOf already re-sorted by line.
THREE PREMISE BREAKS found report-first, before code:
- /api/props/top-graded 404s in prod (absent from src/) so the dashboard
board renders receipts/empty — the edge sort orders nothing there today.
The prior order's "97.3% of rows tie" was a LEDGER measurement wrongly
extrapolated to that board. Fix is correct-in-itself and lands when the
feed is restored.
- p_win cannot be a client-side key for all tiers: snapshotGating strips it
for unentitled tiers ("shipping p_win is shipping the model price").
Verified live: prod /api/snapshot carries p_win on 0/8 MLB, 0/25 WNBA.
- Ladder rungs carry no per-rung price, so the takeable gate is inapplicable.
Verified on real data, both sports, both paths: unentitled — WNBA (n=25)
ordering CHANGED, MLB (n=8) unchanged, signed edge non-increasing in every
(grade,confidence) tie group (20 pairs, 0 violations); entitled — 40 real
ledger rows with p_win+locked_odds, p_win-descending, untakeable chalk NOT
promoted (Trea Turner .757 @-275 does not beat Rhyne Howard .745 @-120)
(36 pairs, 0 violations).
Hero consistency, stated honestly: same signal + same gate, different
precedence BY CONTRACT (board = grade-tier-first "top GRADES"; hero =
p_win-first "top read"). Identical within the leading tier (verified); across
tiers the board may lead with an A the hero doesn't pick. Not a contradiction.
Floor: 310 suites / 3864 tests green, web build exit 0. Dashboard + Desk
visuals are auth/feed-gated -> tagged for the Chrome audit, no visual faked.
Held: edge_pct rescale/display retirement (Order B); building the missing
/api/props/top-graded selector; exposing p_win to unentitled tiers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
|
||
|
|
41b86e3874 |
Hero ranking fix: rank by champion p_win among takeable, honest empty state
Review Zero found the hero's ACTUAL behavior was worse than "unknown": it ranks on ev_pct (heroPropService v2), but ev_pct is NULL on served grades and Number(null)===0 made Number.isFinite(Number(null)) TRUE — so every prop tied at EV 0 and the "top read" was really the FIRST takeable A/B prop in cache order (arbitrary, dressed as ranked). v3: rank by the CHAMPION's p_win (the only signal with a promising, not proven, edge — its takeable-MLB-over CLV survived the skew audit) among A/B, TAKEABLE- priced reads (isTakeable band -160..+200, same as the proof/audit). Strict null guard kills the Number(null)=0 bug. Takeable filter is mandatory (raw p_win crowns -300 chalk). NO backfill: nothing qualifies → honest empty state (available:false, reason:'no_qualifying_read'), never a weak recent read. p_win is RANKING-ONLY, server-side — toHero never exposes it and the route strips it. Framing unchanged in substance (model number vs book number, grade, timestamp) — no proven-edge / +EV / best-bet claim, no CLV/ROI/edge number. Display-only: reads snapshot caches, writes to nothing (no grade/ledger/lock_lines). Full suite 3852 green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1 |
||
|
|
2ab2eeaa7d |
Wire BookComparison to the prop card (display-only, honest states)
BookComparison.tsx was built but UNROUTED (dead). Route it to the GradeResultCard via a new self-fetching BookComparisonPanel that reads the live /api/books feed (source:'bookprices' — the snapshot-locked, fenced, byte-identical store). Contract fix (Review Zero 0.1): books frequently sit at DIFFERENT lines (WNBA DK 21.5 / FD 18.5; MLB 2/3), so BookComparison now renders EACH book's own line per-row — never one shared header line implying a false same-number comparison. Honest states: single-book (the common case for MLB) → one book, "One book posting this prop.", NO crown/second row; multi-book → all books' own line+price, NONE crowned (BOOK_CROWN_ENABLED=false — no best-price claim, verified live crowned:false); no books → renders NULL (panel self-hides), never a placeholder. No regression: only the always-empty inline d.books section was replaced; grade, projection, PropLine line, and PriceTriplet price are untouched (wiring test asserts them). Freshness (0.4): bookprices is written in the SAME snapshot that locks the grade (intraday refresh touches neither gradedAt.line nor bookprices) — same fresh, no stale-label needed. Web-only → grade byte-identical trivially. Full suite 3851 green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1 |
||
|
|
afb56b144b |
NexaPay purge: VYNDR is Stripe-only — remove all NexaPay traces
NexaPay was cross-project contamination (from another venture) — never a real VYNDR payment path. Purged; Stripe path untouched. Removed: - web/src/services/nexapay.ts (createPaymentLink/getTransaction/HMAC verify) - web/src/app/api/webhook/nexapay/route.ts (the only importer; Next-registered, reachable — now gone) - NexaPay comments in email.ts + checkout/route.ts - Active NexaPay entries in docs/SYSTEM-MANIFEST.md (route list, NEXAPAY_* env table, service row) + stale claim in wiring-data-train.md - sw.js precache entry for the deleted webhook chunk Verified: ZERO NexaPay in code (web/src, src, tests). Full suite 3833 green (count unchanged — nothing depended on it, confirming it was dead). Web build exit 0. sw.js parses clean. Stripe checkout untouched (Next→Express→Stripe). FLAGGED FOR KEV (a repo delete cannot close these): - Coolify env: remove NEXAPAY_API_KEY / NEXAPAY_WEBHOOK_SECRET / NEXAPAY_API_URL - Revoke the NexaPay API key + webhook secret at NexaPay's dashboard; de-register the webhook if an account was ever configured - DB column user_profiles.nexapay_customer_id is orphaned (no reader/writer) — drop via a follow-up migration (migration 011 left as history) Cross-project check: ZERO Noctem-Supabase refs; VYNDR references only its own Supabase (zmdnczhtdxcddsxzttub). NexaPay was the sole contamination found. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1 |
||
|
|
6bc18d823c |
Honesty pass: remove every live fabrication (REMOVE/HIDE only, no feature cut)
Six live untruths corrected — no grade/snapshot/scorer/pipeline touched: 1. /compare — hardcoded Jokic A+/Wembanyama A + fake VERDICT replaced with an honest in-development state; removed from Nav + BottomTabBar (route still resolves, never the sample). Real two-player build is later. 2. Pricing — founder Desk $34.99→$44.99 (matches lib/checkout.js), Analyst $14.99; removed the struck $19.99/$44.99 "regular" numbers and DeskShowcase's stale $34.99. First-100 counter is real (ClaimMeter → Stripe countFounderSeats); no fake "first 50" desk claim added (no such counter exists). 3. FAQ "NexaPay" → Stripe (verified: live checkout is Next→Express→checkout.stripe.com). 4. FAQ + Features "Brier/CLV published from day one" removed (not surfaced yet) — returns when real. Backend Brier compute untouched. 5. MobileEdgeBoard removed from the Slate — its edge% feed was a miscalibrated placeholder (masked >40% as "—"); phones now show the real game cards. 6. Price triplet — never-computed model/EV now derives NO_MODEL (honest absent, MODEL "—" / "NOT PRICED", no verdict) instead of QUARANTINE's false "we suppressed our price / a leg is poisoned" copy. Fixes grade card + LiveHeroProp. Full suite 3833 green, web build exit 0. Tests updated to the new honest contracts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1 |
||
|
|
125919f86a |
Scanner reskin: amber → blue boundary channel + build never-built S6/S7 states
Semantic COLOR fix, not cosmetic. The S6/S7 build predated the current Scanner
States spec: it used AMBER (the quarantine / model-suppressed channel) for the
"no market / line not priced" case, telling users "model suppressed" when the
truth is "the board never priced this." Corrected to the HANDOFF Session-3
blue-boundary law: BLUE (--priced-out #8FB2DE) = no-market boundary; amber stays
QUARANTINE; red stays REFUSAL.
Phase 1 (reskin): NoMarketState → dashed BLUE void box + blue header/copy; the
S7 rows → spec format (o 27.5 · BK −114 · ◆ −105 · OPEN READ ▸) at 44px,
390-legible. Input-area surfacer pills reskinned to the blue channel too.
Phase 2 (never-built states, only those Phase 0 confirmed against live data):
- GREEN CTA with LIVE player count ("PLAYER · N PRICED PROPS ▸"), degrading
honestly to the board path ("N PROPS LIVE · TONIGHT'S BOARD ▸") at 0 — count
from the SAME fresh index as the rows (pricedCountForPlayer), can't disagree.
- CASE A none-priced DEFAULT: "WE PRICE THESE FOR [player]" — the player's other
priced stats (pricedStatsForPlayer, filter by nameKey).
- Typed-line-mismatch blue fact line ("o X ISN'T PRICED · NEAREST ↓").
FLAGGED / not built (no shells): Case C off-slate quiet-stop needs schedule/
roster membership the pricedLines index doesn't carry (out of the presentation
fence). Spec CONTRADICTION: Case B says "fair previews amber," but the law
reserves amber for quarantine — fair renders NEUTRAL ◆ (blue-dim), not amber, to
avoid blurring the channel.
Free-tier gate VERIFIED before rendering FAIR: fair_odds is the de-vigged MARKET
price (valueState: "never hide the honest fair number"), NOT the gated
model_odds — no paid leak. Carried through indexPricedLines (additive; keying/
refresh/onPick/stale-tap all unchanged — the proven S7 data path is untouched).
Scan A byte-identical; PRICED_NUDGE_ENABLED still the kill switch; reversible.
Build exit 0; priced + parity suites green (67).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
|
||
|
|
3440738d9e |
Landing hero → bundle visual system; claims filtered to earned only
Migrate the Landing hero (Landing-only component; LiveHeroProp/triplet untouched) to the design bundle's visual language: mono "SPORTS INTELLIGENCE TERMINAL" eyebrow, tightened 56px/800 headline "Every line, graded before you bet it.", dual mono CTAs (OPEN THE TERMINAL / SEE THE PUBLIC RECORD), proof chips. Claims audited against the held list — the bundle's marketing copy carries claims we have not earned; those did NOT ship: - "CLV-VERIFIED" chip + "verified against closing lines" — HELD (C4 broken) - "312 props / 47 games" count — fabricated demo → ABSENT (no count) Shipped copy is earned only: pre-graded, edge-ranked, settled in public, misses included; PUBLIC LEDGER / MISSES INCLUDED / 5 FREE READS chips. HELD + FLAGGED for a design pass (fabrication-backed, no honest designed state — check-don't-freelance): the edge-board demo (A+/A don't emit), the "grades calibrate · A+ hit 80%" claim (calibration unmeasured), and the tier-record band (real data is B/C-only, no ROI). Not built with fake data. Tokens via var(--g-a/--void/--border/…) with bundle-hex fallbacks (documented-intentional pattern). Build exit 0; parity QA green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
4f3f433aae |
Complete S7 priced-line nudge: freshness on a long-open page + reversible gate
REPORT-FIRST correction. This order's premise — "S7 is a shell that doesn't update per selection" — is not what the code does. pricedForSelection is a useMemo on [pricedIndex, selectedPlayer, stat] and setSelectedPlayer/setStat fire on every user pick, so the chips already update per selection, and the prior session's verification of that stands. The genuine gap was FRESHNESS: the snapshot fetch depended on [sport] only, so pricedIndex was fetched once per sport-change and never refreshed. The pricing cron re-prices at five UTC hours, so a scanner left open across a cron boundary surfaced hour-stale priced lines. That is the real defect, and the only one fixed. FRESHNESS. The fetch is now a refreshPriced callback re-run when the held snapshot is older than PRICED_STALE_MS (30s, matching the /api/snapshot cache) at the moment of use — on selection change and on window focus — so a long-open page never shows a stale line. Sport change still clears the index first, so the old sport's lines never flash. STALE-TAP was already safe and is unchanged: the scan submit re-fetches the live snapshot server-side, so a chip that's gone stale between render and tap either lands on a real triplet (still priced) or degrades to the honest empty state (rotated away) — proven in the prior session and re-confirmed here (an off-snapshot line returns no market and shows the empty state). REVERSIBLE GATE. The whole nudge sits behind one PRICED_NUDGE_ENABLED flag: false empties the surfaced set, so the scanner falls back to S6's link-only empty state with the chips gone. Shipping enabled only after the cases are proven this session; the flag is the instant revert lever. DISPLAY-LAYER ONLY. Only scan/page.tsx changed. GradeResultCard, PriceTriplet, gradeAdapter, valueState, both scan routes and the pure pricedLines helper are byte-identical — Scan A and the scan-submit resolution are untouched, and the change is independently revertible. Tests 3765 passed / 303 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
e311f53738 |
Read-card scan: honest no-market empty state + surface real priced lines
The Session-78 diagnosis stands: the join works, and a marketless scan rightly
shows no triplet. This makes that absence legible and points the user at what IS
priced, without fabricating a market.
PHASE 0 gate — design-check, reachability, timing, all clear. Design-check: the
bundle has the triplet's own REFUSAL language ("we'd rather show nothing than a
number we can't stand behind") as the honesty precedent, and a designed
EmptyState component whose actions give a path forward — so the empty state is
built in the established visual language, not freelanced. Reachability: the
scanner already fetches games/odds/search per selection; the snapshot is one
more public, 30s-cached fetch per sport, re-run when the sport changes.
Staleness: the snapshot rotates 5x/day and every scan re-validates the market
server-side at submit time, so a surfaced line that goes stale degrades to the
empty state on tap rather than a vanishing triplet — the stale-tap guard is
inherent, not bolted on.
Reversibility was the design constraint. The working card, price triplet, grade
adapter, valueState and the scan route are BYTE-IDENTICAL — a test asserts none
of them even reference the new empty state. Everything new lives in two added
files (lib/pricedLines.js, components/vyndr/NoMarketState.tsx) and additive
blocks in the scan page. Removing them leaves the Scan-A path untouched.
Non-fabricating by construction: indexPricedLines only keeps snapshot rows that
carry a real book price, keyed by exact player+stat via nameKey. A different
stat priced for the same player surfaces nothing for the picked stat; an
off-slate player surfaces nothing; nothing is suggested, interpolated, or
rounded to a nearest line. The empty state shows no market numbers of its own —
only real priced lines as one-tap chips, or a link to the live board when there
are none.
Framing is help, not restriction: a "PRICED TONIGHT" chip row sits under the
free-typed line input, and the scanner still accepts any player, stat and line.
Tapping a chip pre-fills the priced line and re-scans it — the market is
re-resolved server-side, so the tap either yields a real triplet or degrades to
the honest empty state.
Path forward, not a wall: a marketless scan no longer dead-ends in blank space.
It states truthfully that the board didn't price that line, keeps the grade, and
routes the user to the priced lines for that exact player+stat or to tonight's
board.
Tests 3760 passed / 303 suites, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
|
||
|
|
6dd6f59481 |
Layer 3 Step 6: platoon splits, regressed hard
The highest-value adjuster and the thinnest sample in baseball. The regression is not a refinement here, it is the entire feature: applying raw splits would adjust projections on noise, which is worse than not building it. PHASE 0 — both gates clear, and one was already closed. Splits are a statsapi pull, one call per hitter (statSplits with sitCodes vl,vr). The batter-handedness join that Session 69 recorded as pending is in fact DONE: statcast_aggregates carries bats for 604 of 604 batters, 210 left, 327 right, 67 switch. STATE said pending; the data says otherwise, and the note is corrected. Point-in-time holds as long as the split is fetched before first pitch, since a season split queried this afternoon cannot contain tonight — but a historical backtest would use season-final numbers and leak, so clean measurement is forward-accruing. THE SPINE — regressed = (PA x observed + K x prior) / (PA + K), with K = 600 PA and the prior being the hitter's OWN blended rate rather than the league's. The question a platoon adjustment answers is whether he is DIFFERENT against this hand than he normally is, so his own line is the correct null and a hitter with no evidence of a split correctly gets nothing. K is deliberately conservative: platoon skill is famously slow to stabilise, with the half-signal point for right-handed batters near a thousand PA. THE MAKE-OR-BREAK TEST, both halves. A .310 average against left-handed pitching on 30 PA gets 4.8% weight and moves the projection by 0.003 — essentially nothing, which is the correct answer rather than a limitation. The SAME .310 on 400 PA gets 40% weight and moves it by 0.023, eight times as far. A test asserts that ratio stays above five, so if the regression ever breaks the suite says so instead of the projections quietly drifting onto noise. Real data behaves exactly as the mechanism predicts and is worth recording: Josh Bell hits .259 against lefties and .248 against righties, which looks like a platoon split until the sample speaks — 126 PA earns 17% weight and the adjustment lands at 1.005. Aaron Judge, 76 PA against lefties, comes out at 0.999. Neither is material. Most hitters will get nothing from this adjuster, and that is the honest output, not a failure. Honest-absent has five distinct routes, all returning exactly 1.0: no batter handedness, no pitcher handedness, no splits, a stat platoon says nothing about, and a missing side falling back to the prior rather than to zero. INDEPENDENT of the environment. Park and weather compose into one coefficient because they both describe the stadium; platoon describes this hitter against this pitcher's hand, so it rides its own slot with its own label. Entangling them would make both harder to attribute when the instrument scores them. Directional, mirrored on the under, capped at 15%, and inverted for strikeouts where a higher rate means a higher prop rather than a better hitter. Tests 3729 passed / 300 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
9f60ceba10 |
Layer 3 Step 5: weather modulation composed onto the park base
Completes the coupled environment: effective = park_base x weather_mod. Weather tilts the park, it never overrides it — a wind-out night at Oracle Park is still Oracle Park. PHASE 0 — both feeds are free and keyless. statsapi /venues gives every park's coordinates in one call; Open-Meteo returns hourly temperature, wind speed and wind direction for those coordinates hours before first pitch, which is when we project. Verified live. THE SPINE — two weather values, two purposes, never crossed. The FORECAST we held at projection time drives the live adjustment AND is what the instrument measures, because it is what we actually knew. It lands on the ledger row beside p_win. The ACTUAL goes only to game_context as raw material for future self-derived weather factors, and is read by nothing that scores a projection. Using the actual to measure tonight would be scoring ourselves on information we did not have. The actual is also pulled from Open-Meteo's ARCHIVE endpoint rather than the forecast endpoint, because asking a forecaster after the fact returns a re-forecast, not what happened. WIND IS PARK-ORIENTATION CONDITIONED. Wind direction is meteorological — the direction it comes FROM — so blowing out to centre means arriving from the opposite bearing. Getting that backwards would invert every wind adjustment in the system, so the 180-degree rotation is commented at the site and pinned by a test on all three cases: straight out, straight in, and crosswind. Centre-field bearings are public geometry, in the same class as the dome list; a park missing from the table gets no wind effect at all rather than a guessed one, and keeps its temperature effect. THREE HONEST DO-NOTHING STATES, all multiplier 1.0, none fabricating an effect. Dome: weather does not apply, and the PARK factor still does — verified that a domed venue keeps its sub-1.0 park base while weather stands down. Forecast absent: none available for this park and time. Sub-threshold: a real forecast below a meaningful bar, because manufacturing a 0.3% nudge on a light breeze is false precision. Weather also says nothing about a strikeout prop and returns not-applicable rather than a neutral it might later be tempted to fill. Conservative and ledger-tunable: every magnitude is an env var, the total is capped at 12%, and nothing here is asserted. This is a nominated challenger that earns its place on the instrument or is cut. Induced at Wrigley, whose centre field bears 32 degrees: wind from 212 at 15 mph computes as 15 mph straight out, weather 1.12 composed with park 1.06 for an effective 1.187 and a +0.043 nudge; the under mirrors exactly; the pitcher's home-runs-allowed prop moves with the hitter's, since both are P(over) on a ball leaving the park. Wind in drops the coefficient to 0.955. A calm 72-degree evening, a dome, and a missing forecast all return 1.0 by three different honest routes, with the park base still applying in each. One correction to the order worth recording: it describes a wind-out night as helping the hitter and hurting "the pitcher there's HR-allowed" as opposite sides. In prop terms both go the same way — the HR-allowed OVER is more likely too. The sign lives in the stat, exactly as established for park factors, and the implementation follows that rather than the phrasing. Migration 036. Tests 3707 passed / 299 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
f33091ddb8 |
Layer 3 Step 4b: public park base, source-pluggable, plus game-level capture
PHASE 0 — the settle path sees a player's game-log line, not the game. It knows date and teams, never venue or final totals. But the grain is far cheaper than per-prop or even per-game: ONE statsapi schedule call per game DATE returns every game that day with venue, linescore and scoring plays. Fifteen games, one call, verified live. PUBLIC BASE — the ingestion was already done. The static FanGraphs table from Session 15 is the public base; this converts its 100-indexed values into the multipliers the composable architecture wants (Coors 128 becomes 1.28) rather than ingesting a second copy of a number we already hold. It is labelled COMMODITY in the code, not just in a comment. Every resolution carries a provenance record, and the public one reads proprietary: false with the note "Commodity: a public number. Not a VYNDR derivation." The proprietary label exists but belongs only to the self-derived version, and only once it beats this base on the instrument. A surface rendering a park effect can state which it is rather than implying the flattering one. Honest-absent where even the PUBLIC number is thin: a relocated club in a temporary venue gets no factor, because a public number for a park with one season behind it is no more trustworthy than ours would be. SOURCE-PLUGGABLE is the architectural point. resolveParkBase() is the only accessor, public and derived return identical shapes, and callers never branch on source — so when self-derived factors clear their floor they swap into the same slot with nothing downstream to rewrite. A derived source with no factor available returns absent rather than silently falling back to public, because a silent fallback would make a proprietary claim out of a commodity number. GAME-LEVEL CAPTURE starts now because it cannot start retroactively. Game grain, deduped on game_id, never copied onto prop rows — a game's totals belong to the game, and duplicating them per prop is how one fact starts disagreeing with itself. Every field is tied to a named future derivation: venue for park factors, runs for the run environment, HR totals for HR factors. Nothing else is stored. Only Final games are captured, since an in-progress total is not a result, and a game with no scoring plays reports HR as absent rather than zero. HR totals come from scoring plays, which is complete because every home run scores at least the batter. The accrual target is stated rather than promised: 150 home games per venue at roughly 81 per season means about two seasons before a self-derived factor can be nominated, and accrualStatus() reports live progress per venue so the wait is measurable. Induced: Coors home runs +0.061 for the hitter and identically +0.061 for the pitcher's home-runs-allowed at the same park, mirrored on the under; San Francisco negative; Tampa flagged weather-N/A with its factor still applying; the Athletics' temporary venue absent; strikeouts untouched. A real 2025-07-19 capture produced 15 games across 15 venues, 12 with HR totals, zero duplicate game ids. Migration 035. Induce with POST /api/internal/gamectx/:date, progress at /gamectx/accrual. Tests 3688 passed / 298 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
3ac91c3d96 |
Layer 3 Step 4: derived park factors, composable for weather
PHASE 0 GATE — the answer is BOTH, and the important half was already here. A STATIC FanGraphs park-factor table has existed since Session 15 (src/data/parkFactors.js) and computeFeatures already consumes it, so park is not a new idea in this codebase. What was missing is OUR derivation. I nearly built a second source of truth before finding it; the new service lives at src/services/parkFactors.js and the two are deliberately distinct. That discovery changes the point of this order rather than just its scope. If the champion already sees a park factor, adding one to the challenger risks double-counting — which is exactly the redundancy the Session-72 harness exists to catch. So park ships as a NOMINATED CHALLENGER whose job is to be tested for marginal contribution, not as an assumed improvement. Checked and worth noting: the static table reaches computeFeatures but NOT probabilityEstimator, so it does not currently touch p_win at all. DERIVATION, not ingestion. statsapi gives every game with venue, linescore and scoringPlays in one call per date range — and since every home run scores at least the batter, HR totals are fully recoverable from scoring plays. Derived from 5,055 real games across 2022-2025: Coors tops the run environment at 1.099, Dodger Stadium tops home runs at 1.106, Oracle Park and PNC suppress them at 0.923 and 0.917. Eighteen parks cleared the floor, eighteen did not and are honestly absent. COMPOSABLE BY CONSTRUCTION — the architectural point. Park emits a multiplier around 1.0, never an additive nudge, because weather has to modulate it next order: effective = park_base x weather_mod. Additive terms do not compose correctly (a 5% park and an 8% wind are 1.05 x 1.08, not +13%), and the challenger converts the multiplier to log-odds so stacking stays correct. A test multiplies a placeholder weather term onto the park base to prove the shape composes with no rearchitecting. DIRECTIONAL BY PROP-OWNER: home_runs and home_runs_allowed both key off hr_base in the same direction, because the sign lives in the STAT, not the park. Coors inflates the hitter's home run prop and the pitcher's home-runs-allowed prop identically. THREE HONEST STATES, deliberately distinct. Absent (thin sample, adjust nothing), present (adjust), and weather_na for domes — where the park factor STILL APPLIES because a dome has a real run environment, and the flag exists so next order's weather modulation correctly does nothing there. N/A is not absent; conflating them would either drop a valid park factor or apply wind indoors. Structural breaks: a season deviating past the threshold starts a new regime only if the FOLLOWING season confirms it — one odd year is noise, two consecutive years on the same side is a rebuilt park. Only post-break seasons are used, so a humidor or moved wall cannot be diluted by the stadium that preceded it. Factors regress toward neutral by sample size, so a two-season park cannot assert a Coors-sized coefficient, and fine conditioning stays unavailable until its own larger floor. Tests 3669 passed / 297 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
927e867a23 |
Layer 3 Step 3: Tier-1 mappings live; Tier-2 nomination harness
PHASE 0 GATE — historical out-of-sample testing is NOT available, and the reason matters. statcast_aggregates is overwritten nightly by design (Layer 1 is a full re-pull upsert), so it holds season-TO-DATE numbers with no point-in-time history. Classifying a player for a 15 July game using today's aggregate would feed the model games from 15-21 July — look-ahead leakage, and the resulting "out-of-sample" verdict would be worthless. The harness therefore reads the archetype vector RETAINED at grade time (Session 70's instrument) and runs FORWARD-ACCRUAL, not historical. Reported rather than worked around. CANONICAL NAMES ASSERTED. Every mapping references the axis keys the classifier actually emits, and a test walks both maps against BATTER_AXES / PITCHER_AXES. A key that does not exist would look active and never fire — a mapping that appears wired while silently doing nothing is the exact failure this guards. TIER 1 IS LIVE, tautological and directional: PUNCHOUT/WHIFF raises strikeouts; SINKER/SEAM lowers home runs allowed and FLY BALL/ELEVATOR raises them (a ball on the ground cannot leave the park); SURGEON ARM/PINPOINT lowers walks allowed; SLUGGER/BOMBER raises total bases and home runs; TECHNICIAN/SURGEON raises hits and lowers strikeouts; GRINDER/SNIPER raises walks. Each adjusts only its named stat, mirrors exactly on the under side, and leaves an average player untouched. SPEED IS HONESTLY ABSENT. BURNER/stolen-bases has no axis to key on — SB is a statsapi field that never reached the aggregate store, so Layer 2 shelved it. The mapping is an empty object rather than an invented one. THE TIER-2 HARNESS tests MARGINAL CONTRIBUTION, not correlation. A ground-ball arm obviously correlates with fewer home runs; the question is whether the archetype explains the PROJECTION'S RESIDUAL (outcome minus p_win). If the projection already knows it, the residual carries no signal and the mapping is rejected as redundant — that hurdle is what catches double-counting. The split is by DATE, never random, because rows from one game share a pitcher, a park and a lineup and would leak across a random split. Direction is validated from the held-out data and a contradicted sign is REJECTED, never silently flipped to whatever the data says, which would be fitting noise. LIFECYCLE ENCODED — nominated, live, claimed. A mapping that survives runs live and is measured; only the quantified public claim waits for the ledger. Nothing sits dark. One fixture bug worth recording: my first synthetic generator aliased the carrier selector against the outcome draw and manufactured a 0.038 effect where the generator had put zero. The harness rejected it correctly — it just gave the sign reason instead of the redundancy reason, which is how I found it. The draw now uses a coprime modulus. Real candidate run end to end, GROUND-BALL to hits-allowed: INSUFFICIENT, 0 of 200 settled rows, because no settled row carries p_win yet (Session 70's instrument starts recording at the next new lock). That is the correct verdict and the expected one. Tests 3654 passed / 296 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
f2da9dd7e8 |
Layer 3 Step 2: archetype-aware CHALLENGER, measured not claimed
The champion (probabilityEstimator -> p_win) keeps serving and grading users, completely unchanged. The challenger is a second probability computed from the same inputs at the same instant, landing on the same ledger row so it joins to the same outcome and the same close. Identical conditions, one difference — the only clean A/B. NOTHING IS CLAIMED. Running a challenger is honest beta; asserting it is better before the settled ledger says so is not. Promotion stays a later decision gated on Brier and calibration over sufficient segmented volume. INTERPRETABLE, NOT A RE-ESTIMATION. The challenger is the champion's probability adjusted by the Layer-2 axes, applied in log-odds space so a nudge cannot push past 0 or 1 and means the same thing at p=0.5 as at p=0.9. Every deviation is attributable to a named axis and a signed nudge, stored as challenger_adjustments, and the total is capped at 0.45 log-odds — a lean on a real signal, never a re-forecast. Only mechanically obvious stat/axis relationships are mapped; a speculative mapping would be the same guessing this layer exists to replace. IDENTICAL WHERE THERE IS NO SIGNAL, by construction. An unremarkable player, a thin sample, an unmapped stat or a missing classification all return the champion's probability byte-for-byte with an empty adjustment list and a stated reason. The experiment therefore differs only where archetype-awareness could possibly help or hurt, with no dilution from rows the treatment never touched. Induced on real players. Judge home runs over: 0.42 -> 0.447, via BOMBER +0.22 and WHIFF RISK -0.11 — two real opposing signals netting positive. The same prop under mirrors it exactly to -0.027. Judge strikeouts: delta exactly 0, because WHIFF RISK and GRINDER cancel — an honest "no lean" with both signals still recorded. Skubal strikeouts over: 0.60 -> 0.702 via WHIFF, TRAPDOOR and CANNON all aligned; his hits-allowed goes the other way, 0.50 -> 0.392, because a strikeout arm makes hits less likely. Josh Bell and a 12-PA sample are untouched. Isolation is structural: adjust() is pure, the champion field is read and never written, the served snapshot payload is still the untouched champion object, and a challenger failure is caught so it can never break the pipeline it is measured inside. Statcast aggregates load once per snapshot run rather than per prop, so grade-time I/O stays at zero. Migration 034. Tests 3634 passed / 295 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
c5580f333e |
Layer 3 Step 1: wire the measurement instrument
Step 0 found we have been flying without one. p_win lives only in model_snapshots, which has 1,000 rows and ZERO settled outcomes; the closing line lives only in closing_captures, which carries no link to a result; and ledger_entries, the row that actually settles, carries no probability at all. So "is the projection calibrated" and "does it beat the market" have never been answerable — the entire measurable universe was 35 rows recovered by a lossy in-memory join. PHASE 0 — closing coverage verified BEFORE reuse, because an instrument built on a partial close measures a biased subset. closing_captures holds 70,254 rows of which 13,364 are usable, and the 56,890 refusals are candidates we never graded plus one-sided prices — not refusals of our props. Coverage on graded props since capture started is 83/83, 100%. Safe to reuse, with the honest caveat that capture only began 2026-07-20. THE FOUR-TUPLE NOW LANDS ON ONE ROW. ledger_entries gains p_win, fair_prob_lock, archetype_vector and projection_locked_at at LOCK time, and closing_prob plus closing_captured_at from the append-only capture store. The join is the whole point: calibration is p_win against outcome, market-comparison is p_win against the close, and both become plain SQL on one record instead of a join that silently drops 90% of the rows. p_win and the archetype vector are IMMUTABLE — written once at lock via the existing ignoreDuplicates upsert, never re-derived at settle. A re-derivation would measure a projection we never made. The archetype is stored as the VECTOR, not the label. "Did archetype-awareness help?" can only be answered against the axes that were live at grade time, and a single text column cannot express a blend. A grade with no archetype stores null rather than an empty object. HONEST-ABSENT BOTH WAYS. A past game with no usable capture is marked market_unavailable_reason and never given an imputed line; calibration still scores on those rows, only market-comparison is absent. And a game that has not started yet is NOT declared closeless — a close can still arrive, and premature absence is as dishonest as imputation in the other direction. One bug caught before it shipped: the scheduler hook iterated a SPORTS identifier that does not exist in that scope. Inside its try/catch it would have thrown ReferenceError every tick and silently never run — the instrument would have looked wired and captured nothing. Now iterates cadence.ALL_SPORTS. The baseline accrues FORWARD. Historical p_win and closes are gone, discarded before this existed. Calibration and market-comparison stay honest-absent until volume accrues. Tests 3614 passed / 294 suites, web build exit 0. Migration 033 applied. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
063e9fb3f7 |
STATE: Session 69 — multi-axis archetypes live, FLEX fabrication deleted
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
528cb1a6d0 |
Layer 1: Statcast mechanism-data ingestion (backfill + nightly refresh)
The data foundation for the archetype and projection layers, built as the pattern every sport inherits. Layers 2 and 3 are not touched. PHASE 0 GATE — both match rates measured live, both 100%. Batters 40/40; PITCHERS 66/66 across five real rosters (CLE, DET, MIN, NYY, LAD) joined by MLBAM id against the 713-pitcher Savant feed. Zero honest-absent on identity, because the join is an integer both systems use natively — and the snapshot pipeline already stores it per graded row. SOURCE — five Baseball Savant CSV leaderboards, free and public, pulled with axios and the CSV parser savantAdapter already runs in prod. pybaseball is deliberately NOT used: it is an MIT wrapper over these same URLs, and adding it would reintroduce a Python runtime in a stack where the existing Python service is already offline. min=1 on every feed, not Savant's default min=q, so the long tail arrives and OUR minimum-sample gate decides what is thin — explicit and testable rather than silently dropped upstream. Measured: 1,354 rows per season (604 batters, 750 pitchers), all five feeds in about five seconds. Pitcher mechanism includes arm angle, GB/FB/LD, chase and whiff; batters get exit velo, launch angle, barrel and hard-hit, chase and z-swing. Handedness rides in free on the movement feed (677 pitchers); batter handedness stays absent pending a roster join rather than being guessed. BACKFILL AND REFRESH ARE THE SAME CALL — a full re-pull upserted on (sport, season, source_id). Idempotent and self-healing: a missed night self-corrects on the next run, with no incremental who-played bookkeeping to drift out of sync. At 1,354 rows the simple thing is also the robust one. HONESTY RULES, each with a test: a metric the feed did not carry is null and never 0; a thin sample is STORED and flagged rather than dropped or inflated, because thin and missing are different claims; an unjoined player is stored with a null player_key and joins later; and if every feed comes back empty the job REFUSES to write, so a bad night can never blank a good table. Freshness is treated as a truth property. updated_at on every row, and the scheduler pages on a failed run AND on silent staleness — a job that stops being scheduled never produces a failure, so staleness has to alarm on its own. Never-built is deliberately not stale: different condition, different fix, and paging on a fresh install teaches the operator to ignore the alarm. Nightly at STATCAST_HOUR_UTC (default 11 UTC, after every game is final), kill switch STATCAST=0, and induce-able at POST /api/internal/statcast/refresh with a freshness probe at /statcast/status — we verify a refresh by running it, not by waiting for the slot. Migration 030 applied. Promoted columns for the classification-critical metrics plus a metrics JSONB carrying every raw field, so Layer 2 can reach something we did not promote without a re-ingest. Raw per-pitch stays out of Postgres on purpose: one season is ~0.85 GB against a 500 MB plan ceiling, and it is re-pullable from the free source if Layer 3 ever needs it. Pattern documented in docs/MECHANISM-DATA.md for NBA tracking and NFL Next Gen. Tests 3581 passed / 292 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
4e2f488341 |
Forward model_price_locked to the hero — a paywall was wearing poison's copy
Live induction on the deployed landing page caught this; markup review and the unit suite both passed it. With the model leg stripped for anonymous visitors, LiveHeroProp forwarded book/fair/model/ev/quarantine to PriceTriplet but NOT model_price_locked, so deriveValueState fell through to the missing-model-price branch and the card rendered "MODEL READ WITHHELD" — the quarantine state, whose copy says we suppressed our own price because a leg is poisoned. Nothing was poisoned. The real reason was the paywall, and the two must never share a face: one says our data is untrustworthy, the other says you don't have this tier. Now forwarded, with a test asserting both the separation in deriveValueState and the forwarding at the call site. Tests 3559 passed / 291 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |
||
|
|
aaa41134d4 |
Close the second leak path: /api/hero-prop bypassed the snapshot gate
The landing hero reads the snapshot from Redis DIRECTLY via heroPropService, so it never passed through routes/snapshot.js and was still serving model_odds, ev_pct, value and takeable to anonymous visitors after the first fix. Same strip, same tier resolution, same private-cache rule for authenticated callers; the Next proxy now forwards the bearer token. PRODUCT CONSEQUENCE, FLAGGED RATHER THAN BURIED: the landing hero is served to anonymous visitors, so it now renders BOOK and FAIR with the model leg LOCKED instead of the full triplet it showed this morning. That follows the stated free-tier rule exactly, but it does trade a strong marketing moment (VALUE +21.1% VS FAIR on the shop window) for consistency of the gate. Reversing is one line — add 'model_price' to the free tier in src/config/tiers.js, or special-case the hero route — and is a product call, not a correctness one. Tests 3557 passed / 291 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj |