Compare commits

...

14 Commits

Author SHA1 Message Date
builtbykev 387ae4d54e Handoff doc: ground-truth build state at 71d3b7b
Written from the repo, not summary -- every claim grep- or run-verified,
UNKNOWN where the repo cannot say.

State: HEAD and gitea main in sync at 71d3b7b, tree clean, 4,539 tests
passing, web build exit 0. Push remote is gitea, never origin.

DONE this arc, existence-verified: E1 movement strip, F9-F11 offseason hub
shell, E10 Report template, E12 /report archive, content engine + studio
API + preview, Wave-D1 motion primitives, honest served grade, archetype
doctrine. Also verified done and previously mis-boarded: D1 glyphs (39 of
83 -- every one that maps to a real archetype), A1 card token, B1 boundary
channel, book comparison, THE WIRE.

GATED with each gate named and each absence verified by 0-file grep: F5
article media and E16/F8 on the card-system reconciliation, in-season hub
IA on the content formula, E9/E15 on model accrual, E2/E6 on licensing,
E3 crown on measurement, E13 on another order. NO UNGATED WAVE-2 TARGETS
REMAIN -- the next move is a decision, not a build.

The two open decisions are stated with what each unblocks. Card-system
reconciliation is the cheapest: it frees F5 and E16/F8 together, and it
exists because I built a 1080x1350 renderer without checking whether a
designed card system existed. It did.

Accrual clock recorded at 0 eligible dates on all four items, with the
first trigger (10 calibration dates) and the attempt-floor-not-trust-floor
caveat carried forward.

Standing doctrine carried: Truth Law including designer samples and the
inverse Number(null) breach (zero is a real fact); the 83-glyph taxonomy
with 44 dormant slots; the repaired champion and the standing flag that
every prior factor verdict was measured against the broken baseline;
calibration withdrawn; unissuable A with a B+ 0.663 ceiling; both CI
guards; and compose-don't-fork, which this arc failed three times and
caught twice.

Both parallel workstreams written as seed prompts.

specs/HANDOFF-2026-08-08.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-08 00:10:34 -04:00
builtbykev 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
2026-08-07 22:17:59 -04:00
builtbykev 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
2026-08-07 21:24:36 -04:00
builtbykev 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
2026-08-07 20:31:55 -04:00
builtbykev 7a318ccce0 Media/hub design inventory: the specs already exist, and so does a gap audit
READ-ONLY. Nothing designed, built or mounted.

THE HEADLINE: this inventory was largely already done. specs/
design-vs-build-gap-audit.md is a 61-item design-vs-build audit from
2026-07-31, every claim grep-verified, with a wave-ordered build plan. The
useful work is reconciling it, not redoing it.

WHAT ALREADY HAS A FULL DESIGN SPEC (and is NOT built):
- Offseason hub -- Vyndr Offseason.dc.html, 119KB: hub home desktop+390, an
  NBA Summer-League variant with an OUTLOOK ONLY / NOT GRADED honesty
  block, season-long board, season-read reveal with WHAT WOULD CHANGE THIS
  READ, news/outlook feed row anatomy, quiet-wire empty state.
- Article media (S3) -- hero template plus four hero graphic archetypes,
  all GENERATED data visuals never stock, inline figures with a one-pull-
  quote max and a caption law that every figure names its data, article
  card, OG 1200x630 with the master already rasterised.
- Movement strip (E1) -- steps not curves, green only when the move favours
  the read, FLAT as hairline. THIS CORRECTS MY 2026-08-07 BOARD, which
  listed it UNKNOWN and possibly satisfied by GradeShift. It is a specified
  primitive that does not exist.

THE WIRE is BUILT (vyndr/Ticker on real ticker exhaust) and is a DIFFERENT
thing from NewsWire.tsx, which is the offseason news/outlook feed.

THE ONE REAL DESIGN GAP: the hub's information architecture across seasons.
The Offseason file specifies an OFFSEASON hub; nothing specifies what that
surface is IN-season, or how content, articles, wire and the live slate
share one navigation. Every component exists on paper; their composition
into a year-round media surface does not.

A FINDING AGAINST MY OWN RECENT WORK: I built a card renderer at 1080x1350
without checking whether a designed card system existed. It does -- five
master sizes with defined layouts and rasterised PNGs in exports/. The
content engine's cards are a parallel invention. Not wrong, but they should
conform to E16/F8 rather than diverge, and that is a design decision.

CORRECTION: the order lists content API, preview page, book-comparison
mount and guard widening as still pending. All four shipped at c575a70,
and book comparison was never unmounted -- my earlier board grepped only
web/src/app and missed component-level mounting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 19:18:36 -04:00
builtbykev 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
2026-08-07 18:51:52 -04:00
builtbykev 74aa75945e Content engine: posts that structurally cannot lie
PHASE 0 — contentEngine makes Truth Law structural, not careful. Copy is
token-substituted and an unbacked {token} REFUSES to render -- there is no
code path that produces a plausible default. The fact contract is asserted
before any string is built. Card and copy render from ONE fact object, so
a caption and a card cannot disagree. No live model writes factual claims:
the voice is in the template, the facts are pulled, and the voice-polish
port is deliberately unwired, because an LLM that can rewrite a sentence
can rewrite a number.

18 tests carry the proof. The one that matters most: ZERO IS PRESENT.
"0 cleared B+" is our most honest possible post, and treating 0 as missing
would be the Number(null)===0 breach wearing its opposite coat -- it would
silently delete exactly the post the brand is built on.

PHASE 1 — three templates, generating real posts from tonight's data:
hot hitters off the repaired full-season log, the honesty flex off the
real servedGrade distribution (2,140 graded / 70 cleared B+ / 42% not
separable / A unissuable), and streaks verified from settled outcomes only.

THE ENGINE CAUGHT A BUG IN ITSELF, and it is the sharpest lesson here. The
first run published "No hitter is meaningfully hot tonight -- we could
dress up a middling week as a streak. We don't." That was FALSE: the
box-score cache spans only the settled window, every player had under 20
games, and the pool was empty. A broken pull was publishing as considered
editorial judgement -- the fourth appearance of this class tonight and the
first where our OWN HONESTY COPY was the disguise.

Fixed structurally rather than by patching the number: an absent() variant
may now DECLINE to speak, and the template separates "no candidates at
all" (SKIP with a reason) from "candidates judged, none hot" (honest
absence). Both locked by test. Source corrected to mlbStatsAdapter.fullLog,
the same log the repaired champion reads.

PHASE 2 — cardRenderer emits SVG rather than canvas: it is text, so it
diffs in review and its numbers are greppable, which matters when the
whole claim is that the numbers are real. VYND white + R green, slashed-Y,
scanlines, mono. The card never formats its own facts -- every string
arrives pre-rendered and gate-checked.

PHASE 3 — scripts/generate-content.js writes copy + card per template to
.content-out/<date>/. Template N+1 is a registry entry: requires, pull,
copy, card, absent. Queued as stubs, not built: hot takes, daily reads,
"grades we DIDN'T give", cross-sport streak variants (the streak template
is already sport-agnostic -- settled outcomes and a noun).

FULLY ISOLATED: read-only on every source, zero writes to serving, model
or ledger tables. Serving fingerprint verified unchanged. The accrual clock
is untouched at 0 eligible dates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 16:34:57 -04:00
builtbykev 08791520fc Board: mechanical inventory of every outstanding item, read from the repo
Inventory only, no build. Every line cites a file or a query; unverifiable
items are marked UNKNOWN rather than asserted.

Corrections to the assumed board:
- Scanner is DONE on the blue-boundary format (scan/page.tsx:715-717), not
  still amber.
- Screen conversion is DONE except ONE file -- RouteStub survives only in
  app/notifications/page.tsx across 47 route dirs.
- Terminal is deliberately retired (S57 redirect), not debt.
- ev_pct DOES render, in 5 frontend files. That flag was stale.
- edge_pct is NOT a scale bug: (model-line)/line is correct arithmetic that
  explodes on small lines (a 5.5 projection on a 0.5 line is a legitimate
  1000%). Live top values 900/860/700 on 68,364 rows. It is a display
  defect, not a broken computation, and it reaches a user only via
  alt_lines typing -- the grade card computes edge independently.
- All FOUR sports have real archetype registries (nba 15, mlb 15, soccer 6,
  wnba 5) and ACTIVE_SPORTS runs all four. Only MLB BATTERS is MODELED;
  everything else is scaffolding with a sound base rate and no proven
  factors. MLB pitchers: pitcherEngine.js exists at 261 lines and is read
  by ZERO serving code.
- Book comparison is built and mounted NOWHERE -- the same built-but-unread
  class the reachability guard exists for, and NOT covered by it, since
  that contract is grade fields only.

UNKNOWN and marked as such: opp_rank_stat prod population, internal-key
rotation, the ~9% void/DNP rate, and whether MovementStrip is a distinct
spec item or satisfied by GradeShift/MarketBreadth.

The ordered board puts five PARTIAL items first and flags accrual
sensitivity: seven items are isolated design/guard work buildable today
with zero clock impact; five touch the serving path and would muddy the
accruing repaired-champion dates. edge_pct is the sharpest tension -- a
real honesty defect a user can see, whose fix touches serving mid-accrual.

Clock today: 0 eligible dates, all four re-audit items WAITING.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 15:13:02 -04:00
builtbykev 55b210cb95 Fix the dormant basketball window-bug before it ships; guard the class
PHASE 0 — audit. espnStatsAdapter's slice(0,20) was already fixed at
494c83c, so the ESPN branch of getStatRows inherits a full log and is
CORRECT. MLB's l20_avg is a real seasonTotal/games aggregate, verified,
also CORRECT. TWO basketball defects remained:

  DEFECTIVE  nbaGameLogFeatures   m20 = avg(vals.slice(0, 20))
             -- l20_avg is the season reference projectionFor reads, and
             slicing to 20 made it a twenty-game average wearing a season
             label. The MLB l20 bug, unfixed for basketball.
  DEFECTIVE  getStatRows python   getGameLogs(playerName, sp, 20)

PHASE 1 — both fixed via a named SEASON_LOG_DEPTH = 100, past an 82-game
season so a request can never truncate one. API COST: ZERO. The count is a
request parameter, so asking for a season is the same single call. No
extra request, no extra quota.

MEASUREMENT DEFERRED, EXPLICITLY: basketball is offline, there are no
settled basketball rows, and resolution before/after CANNOT be measured
now. This is a code fix, not a measured improvement -- exactly like the
deferred MLB sibling paths.

PHASE 2 — the window guard asserts the property on source: no fixed N may
stand in for a season. It immediately caught TWO MORE instances I had
missed in Phase 1 -- a hardcoded 20 at featureCache:377 and
gameLogService's own `count = 20` DEFAULT, which would have handed a
twenty-game window to any caller that omitted the argument. That is a
seventh path, found by the guard rather than by me.

Retro-confirmed: run unchanged against 981a05c it goes 3 failed / 6
passed, flagging the basketball slice, the game-log request and the
season-reference check.

The class in full, now six paths across two sports, every one of which
looked like ordinary code. `slice(0, 20)` is unremarkable; what made it a
defect was the QUESTION it answered -- "what is this player's season
rate?" -- and no test could see that mismatch because the value produced
was always a plausible number.

PHASE 3 — THIS CLOSES THE NON-ACCRUAL ARC. Everything buildable without
settled rows is built: push unblocked (it was the wrong remote, not the
firewall), grade surface honest and rendering, reachability guarded,
champion repaired across every path including dormant basketball, and the
window class guarded so it cannot return.

The program is now correctly IDLE on modeling. Today's count: 0 eligible
dates, all four re-audit items WAITING. FIRST TRIGGER: 10 eligible
calibration dates on the repaired champion, at which point the resumption
order is calibration re-fit, hits factor lift, prior verdict re-audit,
rbi lineup-slot gate.

No basketball measurement claimed. No NBA chain/archetype build. MLB
serving verified byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 15:00:24 -04:00
builtbykev 981a05cbd6 Render-reachability guard: make built-but-unread a CI failure
Three consecutive orders shipped a backend-correct field that never
reached a screen, all on a green suite: gradeBands (required by no
serving code), served_grade (dropped at the adapter boundary),
GradeScaleLegend (imported by nothing). Each was caught by luck on a later
re-check, and in two of the three I had already reported the wiring done.

WHY GREEN TESTS COULD NOT SEE IT: backend tests stop at the API payload.
They prove a field is PRODUCED and say nothing about whether it is
CONSUMED. Invisible by construction, not an oversight in any one test.

THE TRAP, NAMED: the difficulty pools in the backend, so by the time a
field exists on the payload it feels finished. What remains is a
three-line adapter change nobody considers worth verifying, so it gets
claimed rather than traced. The last inch is the one with no friction,
which is exactly why it gets skipped. "I added the field" and "a user can
see it" are different claims and only the first is fun.

THE GUARD traces each promised field the whole way: payload -> adapter
consumes -> component renders -> component is MOUNTED. Mounted is
transitive to a Next entry point (page/layout/template), the only thing
that puts a pixel on screen, depth-limited so an import cycle cannot hang
the suite.

Container rows are exempted EXPLICITLY, not silently: served_grade carries
container:true plus a rendersVia list, and a separate assertion checks
every named part actually renders. The exemption is auditable and cannot
hide an unrendered field.

The guard tests itself -- an orphan component must report unmounted, and
the contract must be non-empty, since an empty contract passing vacuously
is how this would most plausibly rot.

RETRO-PROOF: run unchanged against 3591c76, before the wiring, it goes
11 failed / 8 passed and names the exact bugs -- "the ceiling stance /
grade scale legend - its component is MOUNTED, not merely written", "the
served grade object - the adapter consumes it", "whether the band
separates from the baseline - a component actually renders it". Green on
the current tree.

HONEST SCOPE LIMIT: gradeBands is NOT in the contract and would not be
caught. It is a backend module, not a promised user-facing field, and it
is correctly unwired -- every band collapses to base-rate at current
resolution. Out of scope by design, not oversight.

Now in the standing suite, so the three-gate floor is tests green
(including reachability) + build exit 0 + fingerprint. No serving or model
change. p_win never mutated. No Bonferroni slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 14:44:17 -04:00
builtbykev 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
2026-08-07 14:36:03 -04:00
builtbykev 3591c7626e Total grade cutover + the ceiling stated as a position
PHASE 0 caught my own repeat of the failure I diagnosed one order ago.
91927a4 attached `served_grade` BESIDE the old letter and left `grade`
alone -- so the honest grade reached nobody, exactly as gradeBands had
been built-correct-and-unread. grep showed served_grade appearing in one
file (where I set it) and all 14+ consumers -- scan route, dashboard,
parlay, newsletter, desk, content templates, retention -- still reading
`.grade`, i.e. still the dishonest letter.

CUTOVER IS NOW TOTAL: legacy.grade IS the honest letter. Overwriting the
one field every consumer already reads cuts every surface over at once
instead of editing fourteen call sites and missing one. engine1's index is
preserved as `engine_grade` and verified read by ZERO serving code.

Confidence follows the letter: it came from a grade-band midpoint of the
OLD letter, so leaving it would have paired a served B+ with a C's
confidence. Both now derive from p_win, kept on the existing 0-100 scale.

MEASURED BLAST RADIUS before shipping: 303 of 47,991 non-refused
snapshots (0.6%) have a grade but no p_win, and now render NO READ instead
of a letter. That is correct -- their old letter came from the retired
index carrying 0.48% resolution, i.e. noise -- and NO READ is a rendered
state with a reason, so never-blank holds.

PHASE 1 — the ceiling is now a STATED POSITION, not a confusing absence.
servedGrade.SCALE_LEGEND plus web GradeScaleLegend.tsx say it plainly: we
do not issue A grades, no band has hit at a rate that would justify one,
our honest ceiling is a strong B+ (~66% realized vs ~60% baseline), and if
the model earns an A the legend changes and we say why. The
separates_from_base_rate flag renders per band -- C+/C/C- are labelled
"we cannot separate this from the baseline", which is most of any slate.

PHASE 3 hand-verified across every state: B+ with 3 factors (basis
forecast_plus_matchup_factors), B+ with none (forecast_only), C flagged
not-separable, F, and three refusal states rendering NO READ with reasons.
never-blank PASS, no-manufactured-A PASS.

Test fallout was real and is documented rather than papered over: engine
BEHAVIOUR assertions moved to engine_grade, suppression assertions stayed
on grade (a suppressed prop has no letter either way), and the confidence
78 -> 95 change is the grade-band midpoint being replaced by p_win.

No A-threshold loosening. No calibrated number leaks (deployed set empty).
p_win never mutated. Ten frozen modules verified unchanged including
engine1 and probabilityEstimator. No Bonferroni slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 14:07:02 -04:00
builtbykev 91927a4a8a Serve an honest grade: the letter was carrying 1/6 the information of the
number beside it

PHASE 0 corrects the order's premise. A grade letter has been served all
along -- engine1.gradeProp builds it from an additive factor index,
computed INDEPENDENTLY of p_win. gradeBands is orphaned for a different
reason than assumed: it defines what a letter MEANS from realized
outcomes, and every band collapses to base-rate at current resolution.

The measurement that changed this order, on 3,417 settled props:

  grade  n      realized  mean p_win
  A         8    0.500      0.647     <- the TOP grade did WORST
  B       985    0.640      0.700
  C     1,695    0.602      0.676
  D       303    0.558      0.604
  F       426    0.535      0.588

  letter resolution 0.00116 (0.48% of variance)
  p_win  resolution 0.00715 (2.98%)
  -> the letter carried 0.16x the information of the number beside it

Concretely, from the hand-verify: Christian Encarnacion's 0.95 over
graded C and his 0.05 under ALSO graded C -- same hitter, opposite
forecasts, same letter. The gap was never that grades don't ship; it is
that the weaker of two available signals shipped as the headline.

PHASE 1 — model/servedGrade.js derives the letter from p_win with bands
anchored on MEASURED realized rates (B+ 0.663 / B 0.646 / C+ 0.615 /
C 0.589 / C- 0.548 / D 0.512 / F 0.447, base 0.6005).

NO MANUFACTURED A, structurally: A+/A/A- are UNISSUABLE, not rare. The
realized rate plateaus at 0.65-0.68 above p_win 0.70, so no band has
earned a top letter; a test sweeps every p_win 0..1 and asserts none
produces one. Even 0.99 tops out at B+ with its realized 0.663 attached.
Raising that ceiling later is a deliberate, visible act.

Bands that cannot separate SAY so -- C+/C/C- carry
separates_from_base_rate false and copy naming it, which is the honest
description of a forecast explaining 3% of variance. Every grade states
its basis (forecast_only vs forecast_plus_matchup_factors, naming which
factors fired) and calibrated:false. engine1.grade is preserved as
engine_grade so nothing downstream breaks.

PHASE 2 — refusals render real states: insufficient_data -> "not enough
history to call this one"; juiced_no_edge -> "the book has priced the vig
past any edge on this side". 1,870 refused snapshots carry exactly those
two reasons and both now surface.

PHASE 3 — hand-verified on 12 real served props. Freeman/Rice/Encarnacion
0.95 overs now B+ (was B, C, B); the 0.05 unders now F (was C). Refused
doubles render NO READ with their reason. never-blank PASS,
no-manufactured-A PASS.

Serving change; nine frozen model modules unchanged including engine1;
p_win never mutated; no calibrated number leaks (deployed set empty); no
Bonferroni slot.

STILL TRUE: the forecast explains ~3% of outcome variance. This order did
not make the model better. It made the letter stop overstating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 04:53:02 -04:00
builtbykev 9159b7e1b9 Diagnose the push block: it was the wrong remote, not the firewall
The GATE-0 theory is REJECTED for VYNDR, tested rather than assumed. Both
remotes are ALREADY HTTPS -- there is no git@ SSH URL in this repo to be
blocked -- and both hosts answer on 443: git.builtbykev.com HTTP 200 in
0.64s, github.com HTTP 200 in 0.17s, Gitea's git endpoint 200, GitHub's
401 (auth required, reachable). No connectivity failure of any kind.
COLYRA's HTTPS-remote fix was right for COLYRA; VYNDR was already in the
state that fix produces, so applying it would have minted a new token to
solve a problem that did not exist -- and the pre-existing credential
would have made the new token look like the cure.

The real cause is in the error text, which named it exactly and was
misread all session: "could not read Username for 'https://github.com'".
Every attempt used `git push origin main`, and origin is GitHub
(kev3109/betonblk) with no stored credential. The WORKING remote is
`gitea` (git.builtbykev.com/builtbykev/vyndr.git), and a valid credential
for it sat in ~/.git-credentials the entire time. The habit of typing
`origin` is what kept twenty commits local. Nothing was blocked.

FIX: git push gitea main. Pushed 6452926..ecf78b9, 21 commits, verified by
git ls-remote matching local HEAD exactly.

No firewall rule touched, no token created, no code or model change --
infra only. The bundle and patch series stay as belt-and-braces; they are
no longer the only copy.

docs/GIT-PUSH-DIAGNOSIS.md records it so no future session re-reaches for
an infrastructure theory when the error message names a credential and a
specific host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 04:24:21 -04:00
52 changed files with 4186 additions and 47 deletions
+2
View File
@@ -29,3 +29,5 @@ out/
.vercel/
.seq-cache/
.content-out/
+65
View File
@@ -0,0 +1,65 @@
# `/api/content-studio` — the agent-ready contract
The endpoint Kev's `/studio` page reads today and an autonomous poster reads
later. **The page is a thin client**: no posting logic, no fact handling. Wiring
a bot means pointing it here — nothing on this side changes.
Distinct from `/api/content` (Session 29), which serves structured content
*objects* by data level. This serves finished **posts**.
## Auth
`x-internal-key: $VYNDR_INTERNAL_KEY` — private, never public. The browser never
holds the key; the Next proxy at `web/src/app/api/content-studio/[...path]`
attaches it server-side.
## `GET /api/content-studio/:date?`
`:date` optional, defaults to today ET.
```jsonc
{
"date": "2026-08-07",
"count": 3,
"posts": [{
"id": "honesty_flex",
"label": "The Honesty Flex",
"sport": "mlb",
"status": "pending", // pending | approved | skipped | regenerate_requested
"ok": true,
"skipped": false,
"reason": null, // why it was skipped, when it was
"honest_absence": false, // a real "nothing tonight" post, not a failure
"copy": "WE GRADED 2140 PROPS TONIGHT...",
"card": { "title": "...", "lines": [...] },
"card_svg": "<svg ...>", // ready to render or rasterise
"fact_contract": ["graded", "ceiling_letter", "..."], // REQUIRED fields
"facts": { "graded": 2140, "...": "..." } // what backed it
}]
}
```
**`fact_contract` + `facts` are the point.** An agent (or a reviewer) can check
what a claim rests on instead of trusting the sentence. A post whose contract
could not be met never appears with invented values — it arrives `skipped` with
a `reason`, or as an `honest_absence`.
## `POST /api/content-studio/:date/:id/status`
```jsonc
{ "status": "approved" } // approved | skipped | regenerate_requested
```
Editorial state only, stored in Redis for 14 days. **Approving a post changes
nothing about the model** — status never touches a serving, model or ledger
table.
## For the agent build
1. `GET` the date → filter `ok && !skipped`.
2. Post `copy`; rasterise or attach `card_svg`.
3. `POST` status `approved` on success.
4. **Never** synthesise a claim not present in `facts`. The engine refuses to
render an unbacked token; an agent must not reintroduce one downstream.
Adding a template changes the payload not at all — a new `id` simply appears.
+64
View File
@@ -0,0 +1,64 @@
# git push in this environment — it was never the firewall, and never missing credentials
## What actually happened
Every push attempt this session used `git push origin main` and failed with:
```
fatal: could not read Username for 'https://github.com'
```
That error names the cause exactly, and it was misread all session as "no git
credentials on this machine." Two things were true instead:
1. **`origin` is GitHub** (`github.com/kev3109/betonblk.git`) and has **no stored
credential.**
2. **`gitea` is the working remote** (`git.builtbykev.com/builtbykev/vyndr.git`)
and **a valid credential for it was on the machine the entire time.**
The habit of typing `origin` is what kept twenty commits local. Nothing was
blocked.
## The GATE-0 firewall theory — tested and REJECTED for VYNDR
The theory was that GATE-0 (Hetzner `mastermind-core-fw`, inbound deny-by-default
except 80/443, SSH/22 restricted to Tailscale + Kev's IP) was blocking an
SSH-based push, as it did for COLYRA.
**It does not apply here. Measured:**
| check | result |
|---|---|
| `git remote -v` | **both remotes are already HTTPS** — no `git@…:…` URL anywhere |
| `curl -I https://git.builtbykev.com` | **HTTP 200** in 0.64s |
| `curl -I https://github.com` | **HTTP 200** in 0.17s |
| Gitea git endpoint over 443 | **HTTP 200** |
| GitHub git endpoint over 443 | HTTP 401 (auth required, reachable) |
There was no SSH remote to be blocked and no connectivity failure of any kind.
COLYRA's HTTPS-remote fix was the right fix for COLYRA's problem; **VYNDR was
already in the state that fix produces.**
Applying it here would have meant creating a new Gitea token to solve a problem
that did not exist — and the pre-existing credential would have made the new
token look like the cure.
## The fix
```
git push gitea main # not origin
```
Result: `6452926..ecf78b9`, 21 commits, verified by `git ls-remote` matching
local `HEAD`.
## Standing note
- **`gitea` is VYNDR's push remote.** `origin` (GitHub) is unauthenticated on this
machine and will always fail.
- Read the error text before reaching for an infrastructure theory. `could not
read Username for 'https://github.com'` is a *credential* message naming a
*specific host* — it is not a connectivity message, and it named the wrong
remote, not a wrong protocol.
- The `~/vyndr-full-history-2026-08-07.bundle` and patch series stay as
belt-and-braces. They are no longer the only copy.
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env node
'use strict';
/**
* generate-content — tonight's posts, from tonight's real data.
*
* READ-ONLY on every source. This writes nothing to any serving, model or
* ledger table, so it has zero effect on the repaired-champion accrual clock.
*
* SUPABASE_URL=... node scripts/generate-content.js
* -> .content-out/<date>/<template>.txt and .svg
*/
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const { createClient } = require('@supabase/supabase-js');
const engine = require('../src/services/content/contentEngine');
const { toSvg } = require('../src/services/content/cardRenderer');
const sg = require('../src/services/model/servedGrade');
const { knownNumber } = require('../src/utils/known');
for (const t of ['hotHitters', 'honestyFlex', 'streakList']) {
engine.registerTemplate(require(`../src/services/content/templates/${t}`));
}
const BOX = path.join(process.cwd(), '.seq-cache', 'batting-lines.json');
const OUT = path.join(process.cwd(), '.content-out');
async function page(sb, t, sel, ob, f) {
const o = [];
for (let i = 0; ; i += 1000) {
const { data, error } = await f(sb.from(t).select(sel)).order(ob, { ascending: true }).range(i, i + 999);
if (error) throw new Error(`${t}: ${error.message}`);
if (!data || !data.length) break;
o.push(...data);
if (data.length < 1000) break;
}
return o;
}
(async () => {
const sb = createClient(process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY, { auth: { persistSession: false } });
const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
// ── SOURCE 1: full-season hitter form (the REPAIRED window, not last10) ──
const lines = fs.existsSync(BOX) ? JSON.parse(fs.readFileSync(BOX, 'utf8')).lines : {};
const byPlayer = new Map();
for (const [k, b] of Object.entries(lines)) {
const [d, key] = k.split('|');
if (!byPlayer.has(key)) byPlayer.set(key, []);
byPlayer.get(key).push({ d, hits: b.hits, name: b.name });
}
// The box-score cache spans only the settled snapshot window, so it holds far
// fewer than a season per player. Season form comes from the SAME full log the
// repaired champion reads.
const mlb = require('../src/services/adapters/mlbStatsAdapter');
const hitterFormFull = async (names) => {
const out = [];
for (const n of names.slice(0, 60)) {
try {
const res = await mlb.getPlayerStats(n);
const log = (res && res.found && Array.isArray(res.fullLog)) ? res.fullLog : [];
const vals = log.map((g) => knownNumber(g && g.stat && g.stat.hits)).filter((v) => v !== null);
if (vals.length < 20) continue;
const rate = (a) => a.filter((v) => v > 0).length / a.length;
out.push({ name: n, season_games: vals.length, season_rate: rate(vals), recent_rate: rate(vals.slice(-10)) });
} catch { /* absent player -> absent row */ }
}
return out;
};
const cacheForm = async () => [...byPlayer.entries()].map(([key, games]) => {
games.sort((a, b) => a.d.localeCompare(b.d));
const vals = games.map((g) => knownNumber(g.hits)).filter((v) => v !== null);
if (vals.length < 20) return null;
const rate = (arr) => arr.filter((v) => v > 0).length / arr.length;
return {
name: games[games.length - 1].name || key,
season_games: vals.length,
season_rate: rate(vals),
recent_rate: rate(vals.slice(-10)),
};
}).filter(Boolean);
const hitterForm = async () => {
const names = [...byPlayer.values()].map((g) => g[g.length - 1].name).filter(Boolean);
const full = await hitterFormFull([...new Set(names)]);
return full.length ? full : await cacheForm();
};
// ── SOURCE 2: the real served-grade distribution ──
const snaps = await page(sb, 'model_snapshots', 'p_win, refused, stat, game_date', 'id',
(q) => q.eq('sport', 'mlb').eq('game_date', date));
const gradeDistribution = async () => {
const usable = snaps.filter((r) => !r.refused && knownNumber(r.p_win) !== null);
const by = {}; let flat = 0;
for (const r of usable) {
const g = sg.gradeFor({ p_win: knownNumber(r.p_win) });
by[g.letter] = (by[g.letter] || 0) + 1;
if (g.separates_from_base_rate === false) flat += 1;
}
return { total: usable.length || null, by_letter: by, not_separable: flat };
};
// ── SOURCE 3: streaks verified from SETTLED ledger outcomes only ──
const led = await page(sb, 'ledger_entries', 'player_name, player_key, game_date, outcome, stat', 'id',
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'hits').in('outcome', ['hit', 'miss']));
const settledStreaks = async () => {
const by = new Map();
for (const r of led) {
if (!by.has(r.player_key)) by.set(r.player_key, []);
by.get(r.player_key).push(r);
}
const out = [];
for (const [, rows] of by) {
rows.sort((a, b) => String(b.game_date).localeCompare(String(a.game_date)));
let n = 0;
for (const r of rows) { if (r.outcome === 'hit') n += 1; else break; }
if (n >= 3) out.push({ name: rows[0].player_name, streak: n, verified_from_settled: true });
}
return out;
};
const deps = { date, hitterForm, gradeDistribution, settledStreaks, servedGrade: sg };
const results = await engine.generateAll(deps);
const dir = path.join(OUT, date);
fs.mkdirSync(dir, { recursive: true });
for (const r of results) {
if (!r.ok) { console.log(`\n[SKIP] ${r.id}${r.reason}`); continue; }
fs.writeFileSync(path.join(dir, `${r.id}.txt`), r.copy);
fs.writeFileSync(path.join(dir, `${r.id}.svg`), toSvg(r.card));
console.log(`\n${'='.repeat(64)}\n${r.id.toUpperCase()}${r.honest_absence ? ' [HONEST ABSENCE]' : ''}\n${'='.repeat(64)}\n${r.copy}`);
}
console.log(`\n\noutput: ${dir}`);
process.exit(0);
})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
+53
View File
@@ -0,0 +1,53 @@
# The 83-glyph taxonomy — doctrine
**RULED. This is the shared law the archetype chat and the build chat both obey.**
## The rule
**83 designed glyphs = the full four-sport archetype taxonomy** (MLB / WNBA /
NBA / Soccer). The artwork is complete; the *models* are not.
**A glyph renders ONLY where its archetype is modeled and proven.**
| set | count | state |
|---|---|---|
| designed glyphs | **83** | complete, in `specs/design-reference/assets/glyphs/` |
| backend registry archetypes | **41** | `archetypeService.ARCHETYPES` |
| **mapped and wired** | **39** | live, colours matching the registry exactly |
| **designed, no backend archetype** | **44** | **DORMANT** — slots for WNBA/NBA/Soccer |
| registry archetypes with no glyph | **2** | `DUAL THREAT`, `PAINT BOSS`**design gap** |
## Why dormancy rather than wiring
Wiring the 44 would mean **inventing 44 archetypes to consume artwork**. An
archetype that exists because a glyph exists is decoration presented as
classification — a mark on a card asserting the model recognised something it
cannot produce. **Decoration-as-data is forbidden.**
The dormant glyphs are not waste. They are **designed slots**, and each activates
when its sport's archetype system is built and clears the two-part gate — the
same bar every factor in this programme faces.
## What this preserves
- **The full design vision.** All 83 marks stay in the package; none is deleted
or redrawn.
- **The Truth Law.** No glyph appears for an archetype the model cannot produce.
- **The activation path.** Building WNBA/NBA/Soccer archetypes lights their
glyphs automatically — the artwork is already there and already colour-matched.
## Consequences for build
1. Do **not** add a registry archetype to consume a glyph. The archetype must be
earned by a classifier that produces it from real features.
2. Do **not** render a dormant glyph as a placeholder, sample, or "coming soon"
mark on any data surface.
3. `DUAL THREAT` and `PAINT BOSS` are **flagged to the design side** — they are
modeled archetypes with no mark, the mirror image of the dormant 44.
4. When a sport's archetypes ship, wiring is a MANIFEST lookup, not new art.
## Status
MLB archetypes render live. WNBA, NBA and Soccer have archetype **registries**
but no proven factor model, so their marks stay dormant — consistent with
`specs/BOARD-2026-08-07.md`, which records only MLB batters as MODELED.
+132
View File
@@ -0,0 +1,132 @@
# The real board — read from the repo, 2026-08-07
Inventory only. Every line cites a file or a query. Anything unverifiable is
marked UNKNOWN rather than asserted.
---
## PHASE 0 — Design / terminal
| item | state | evidence |
|---|---|---|
| **Scanner blue-boundary format** | **DONE** | `scan/page.tsx:715-717`*"the blue boundary channel (`--priced-out`): line · BK odds · ◆ fair"* and *"renders NEUTRAL, not amber (blue-boundary law)"*. Amber survives only as an unrelated CTA (`:983`) and a status line (`:846`). |
| **MovementStrip** | **NOT-STARTED** *(as named)* | No component by that name. Movement renders via `GradeShift.tsx`, `GameCard.tsx`, `MarketBreadth.tsx`, `FuturesBoard.tsx`. **UNKNOWN whether the spec wants a distinct strip or is satisfied by these.** |
| **THE WIRE** | **PARTIAL** | `components/vyndr/NewsWire.tsx` exists and is mounted — but only inside `ExploreHub.tsx`. No standalone wire surface. |
| **Book comparison** | **PARTIAL — built, unmounted** | `vyndr/BookComparisonPanel.tsx` + `components/BookComparison.tsx` both exist; grep of `web/src/app` returns **zero** mounting pages. Same built-but-unread class the reachability guard was written for; **not covered by that guard** (contract is grade fields only). |
| **Article media** | **NOT-STARTED** | No component. `/blog` exists. |
| **Offseason hub** | **NOT-STARTED** | No component, no route. |
| **Terminal** | **DONE — deliberately retired** | `app/terminal/page.tsx` redirects to `/dashboard`; layouts preserved unrouted in `components/intel/TerminalTemplates.tsx` (S57). Not debt. |
| **Screen conversion (S3639 arc)** | **DONE except one** | 47 route dirs under `app/`. `RouteStub` survives in exactly **one** file: `app/notifications/page.tsx`. Every other screen has a real page. |
**Sports surfaces that exist as pages:** `soccer` (414 lines), `desk` (160),
`intelligence` (160), `marketplace` (123). No `fight` route.
---
## PHASE 1 — Sports × role
Archetype registry (`archetypeService.ARCHETYPES`): **nba 15, mlb 15, soccer 6,
wnba 5** — all four sports have real archetype registries, not names only.
`snapshotService.ACTIVE_SPORTS = ['mlb','nba','wnba','soccer']` — the pipeline
runs all four.
| sport · role | state | evidence |
|---|---|---|
| **MLB · batters** | **MODELED** | The whole session. `hitsFactors` (3 proven factors, transmitting), `servedGrade`, repaired champion, settled ledger. |
| **MLB · pitchers** | **SCAFFOLDED** | `model/pitcherEngine.js` (261 lines, own FLAME/SCALPEL/SINKER archetypes) — **read by no serving code** (grep: zero non-test consumers). Strikeouts n=57 settled vs a 500 gate. Base rate repaired by the shared `getStatRows` MLB fix. |
| **WNBA** | **SCAFFOLDED** | Archetypes exist; settles via ESPN box scores (376 rows historically); no factor model. Base-rate path fixed this session. |
| **NBA** | **SCAFFOLDED, dormant** | Archetypes exist; offline (Python service down, off-season). Base-rate paths fixed dormant at `55b210c`. |
| **Soccer** | **SCAFFOLDED** | 6 archetypes, a real `/soccer` page, feature extractor exists. No factor model, no settled outcomes. |
**Nothing but MLB batters is MODELED.** Everything else is scaffolding with a
sound base rate and no proven factors.
---
## PHASE 2 — Wiring / data-integrity debt
### `edge_pct` — the flag is half right, and the diagnosis was wrong
**Not a scale bug.** `analyzeViaEngine1.edgePctFor` computes
`(model line) / line`, signed by direction — arithmetically correct. It
*explodes on small lines*: a 5.5 projection against a 0.5 line is a legitimate
1000%. Live top values: **900, 860, 700, 700, 660** on **68,364 rows**.
**Consuming surfaces: 15 backend files + 10 frontend files.** But
`edge_pct` itself reaches a user only through `alt_lines` typing in
`scan/page.tsx:64` — the grade card's `edge` is computed independently in
`gradeAdapter` via `computeEdge`. So the blast radius is smaller than the file
count implies.
**`ev_pct` DOES render** — 5 frontend files (`PriceTriplet`, `GradeResultCard`,
`LiveHeroProp`, scan page + route). The "ev_pct renders nowhere" flag is **stale**.
45,125 rows carry it.
**Classification: PARTIAL — a real display defect (a 900% edge is not a sentence
we can defend), not a broken computation.**
### `opp_rank_stat`
`refreshTeamStats` is wired into `snapshotService:361`. **UNKNOWN whether it
populates in production** — not verifiable from the repo, needs a live probe.
### A-emit
**Resolved.** `servedGrade.UNISSUABLE = ['A+','A','A-']`; post-cutover serving
cannot emit one. Historical snapshot rows still carry engine1 A's — that is
retired data, not live behaviour.
### Other
| item | state |
|---|---|
| `VYNDR_INTERNAL_KEY` | present in `app.js`, `preflight.js`. **Rotation status UNKNOWN** from the repo. |
| void / DNP handling | handled in `ledgerService` (`:64`, `:698`) — absence means DNP/postponed/not-final. **The ~9% rate is UNVERIFIED** here. |
| **Guard coverage gap** | reachability contract covers **grade fields only** — 0 references to `edge_pct`/`ev_pct`. Book comparison and other unmounted components are **not** guarded. |
---
## PHASE 3 — THE BOARD, ordered (half-done first)
| # | item | lane | state | serving-path? |
|---|---|---|---|---|
| 1 | **Book comparison — built, unmounted** | design | PARTIAL | **N** |
| 2 | **`edge_pct` display defect (900%)** | integrity | PARTIAL | **Y** |
| 3 | **THE WIRE — only inside ExploreHub** | design | PARTIAL | **N** |
| 4 | **MLB pitchers — engine unwired** | sports | PARTIAL | **Y** |
| 5 | **`/notifications` RouteStub** | design | PARTIAL | **N** |
| 6 | Guard coverage → non-grade surfaces | integrity | NOT-STARTED | **N** |
| 7 | Article media | design | NOT-STARTED | **N** |
| 8 | Offseason hub | design | NOT-STARTED | **N** |
| 9 | MovementStrip *(if distinct from GradeShift)* | design | UNKNOWN | **N** |
| 10 | WNBA / NBA / Soccer factor models | sports | NOT-STARTED | **Y** |
| 11 | `opp_rank_stat` prod population | integrity | UNKNOWN | **Y** |
| 12 | Internal-key rotation | ops | UNKNOWN | **N** |
### Accrual sensitivity
**Isolated — buildable now without touching the re-audit clock (7 items):**
1, 3, 5, 6, 7, 8, 9 — all design/guard work, zero serving-path contact.
**Serving-path — will muddy the accruing repaired-champion dates (5 items):**
2, 4, 10, 11, and any model work. Every one changes what a snapshot writes, so
rows produced after the change are not comparable to rows before it. **If any of
these ship, the eligible-date clock arguably restarts** — the same reasoning that
voided the shadow duel.
**#2 (`edge_pct`) is the sharpest tension on the board:** it is a real honesty
defect a user can see, and fixing it touches the serving path mid-accrual.
### The clock, today
```
eligible dates: 0 — no settled rows carry engine1@2026-08-07-fullwindow
calibration_refit 0/10 WAITING (~2 weeks)
hits_factor_lift 0/10 WAITING (~2 weeks)
prior_verdict_reaudit 0/14 WAITING (3+ weeks)
rbi_lineup_slot_gate 0/14 WAITING (3+ weeks)
```
Two tracks are now visible: **modeling is date-blocked**; **seven design/guard
items are buildable today with no clock impact.**
+111
View File
@@ -0,0 +1,111 @@
# The content engine — posts that structurally cannot lie
## Phase 0 — architecture
`src/services/content/contentEngine.js`. Three mechanisms make Truth Law
structural rather than careful:
1. **Copy is token-substituted.** Every factual claim is a `{token}` resolved
against pulled facts. An unbacked token **refuses to render** — there is no
code path producing a plausible default.
2. **The fact contract is asserted first.** A template declares required fields;
they are checked *before any string is built*.
3. **Card and copy share one fact object.** They cannot diverge.
**No live model writes factual claims.** The voice is in the template, the facts
are pulled. A voice-polish port is reserved and deliberately unwired — an LLM
that can rewrite a sentence can rewrite a number.
**Read-only on every source.** Zero writes to serving, model or ledger tables, so
zero effect on the accrual clock.
### The Truth-Law proof — 18 tests
| the guard | what it prevents |
|---|---|
| unbacked token refuses | `{edge}` rendering as `undefined` or an empty hole |
| card tokens gated too | a caption that's honest beside a card that isn't |
| `render()` throws directly | a caller bypassing the gate |
| `null` never renders as `"null"` | absence dressed as data |
| **`0` IS present** | *"0 cleared B+"* is our most honest post — deleting it would be `Number(null)===0` in reverse |
| `NaN`/`Infinity` absent | arithmetic failures are not facts |
| contract gap names the field | a silent half-post |
| pull failure skips | a post built on a dead source |
## Phase 1 — three templates, real output
**HOT HITTERS** (from the repaired full-season log, not a ten-game slice):
> Jahmai Jones is hitting 60% over his last 10. His season number is 26%.
> That gap is the whole point. Everybody else is guessing at it.
**THE HONESTY FLEX** — the differentiator, and every number is ours:
> WE GRADED 2140 PROPS TONIGHT. 70 CLEARED B+.
> That's 3%. The other 42% we can't separate from the baseline, and we say so on the card instead of calling them leans.
> We do not issue A+, A, A-. No band of this model has ever hit at a rate that would justify one.
> Everybody else's card is all A's. Ask them what their A actually hits.
**STREAK LIST** — verified from settled outcomes only:
> Nathan Church has a 7-game hit streak. Live, verified off settled results only.
> Every game in these ran to a final. We don't count a pending night to make a number look better.
### The bug the engine caught in itself
The first run emitted *"No hitter is meaningfully hot tonight — we could dress up
a middling week as a streak. We don't."*
**That was false.** The box-score cache spans only the settled snapshot window,
so **every** player had fewer than 20 games and the pool was empty. A broken pull
was publishing as considered editorial judgement — **the fourth appearance of
this class tonight, and the first where our own honesty copy was the disguise.**
Fixed structurally: an `absent()` variant may now **decline to speak**. The
template separates *no candidates at all* (SKIP with a reason) from *candidates
judged, none hot* (honest absence). Both cases are locked by test.
Source corrected to `mlbStatsAdapter.fullLog` — the same log the repaired
champion reads.
## Phase 2 — the card
`cardRenderer.js`, SVG rather than canvas: it is text, so it diffs in review and
its numbers are **greppable** — which matters when the entire claim is that the
numbers are real. A card whose contents can't be inspected without opening an
image is a poor fit for a Truth-Law product.
Brand: VYND white + R green `#00D4A0`, slashed-Y, scanline field, mono
throughout. The card never formats its own facts — every string arrives already
rendered and gate-checked, so caption and card cannot disagree. A test asserts
the pulled number appears in the emitted SVG.
## Phase 3 — posting-ready, and extending it
```
SUPABASE_URL=... node scripts/generate-content.js
-> .content-out/2026-08-07/hot_hitters.txt + .svg
-> .content-out/2026-08-07/honesty_flex.txt + .svg
-> .content-out/2026-08-07/streak_list.txt + .svg
```
Kev posts; the engine generates.
### Adding template N+1 — registry entry only, no engine change
```js
registerTemplate({
id, sport, requires: ['dotted.paths'],
pull: async (deps) => facts, // the ONLY place data enters
copy: () => 'text with {tokens}',
card: () => ({ title, subtitle, lines }),
absent: (gaps, facts) => ({ copy, card }) // or { skip: 'reason' }
});
```
**Queued (stubs, not built):** hot takes · daily honest reads · *"grades we
DIDN'T give"* · cross-sport streak variants (the streak template is already
sport-agnostic — it takes settled outcomes and a noun, so NFL TD streaks or NBA
made-three streaks need only that sport's settled data).
## Isolation
Read-only throughout. `p_win`, the model and the serving path are untouched;
the eligible-date clock is unaffected. **0 eligible dates today, unchanged.**
+198
View File
@@ -0,0 +1,198 @@
# VYNDR build handoff — 2026-08-08
Written from the repo, not from summary. Every claim below was grep- or
run-verified at `71d3b7b`. Where something could not be verified from the repo
it says UNKNOWN.
## Repo state
```
HEAD 71d3b7b78692c31ba0596ec88874986ff1b58116
gitea main 71d3b7b78692c31ba0596ec88874986ff1b58116 (in sync)
tree clean — 0 modified/untracked
tests 4,539 passing / 4 skipped
web build exit 0
```
**Push remote is `gitea`, not `origin`.** `origin` is GitHub and has no
credential on this machine; every push must be `git push gitea main`. This cost
twenty commits of false "no credentials" diagnosis — see
`docs/GIT-PUSH-DIAGNOSIS.md`.
---
## BUILD STATE per surface
### DONE this arc — verified present
| surface | file | lines |
|---|---|---|
| **E1 movement strip** | `web/src/components/vyndr/MovementStrip.tsx` | 113 |
| **F9F11 offseason hub shell** | `web/src/app/offseason/page.tsx` + `components/vyndr/OffseasonHub.tsx` | 21 + hub |
| **E10 Report issue template** | `src/services/report/reportTemplate.js` | 169 |
| **E12 /report archive** | `web/src/app/report/page.tsx` + `components/vyndr/ReportArchive.tsx` + `src/routes/report.js` | 17 + comp + route |
| **Content engine** | `src/services/content/contentEngine.js` + 3 templates + `cardRenderer.js` | 172 + |
| **Content studio API + preview** | `src/routes/contentStudio.js` + `web/src/app/studio/page.tsx` | 175 + 131 |
| **Wave-D1 motion primitives** | `web/src/lib/motion.js` (E17/E18/E27/E28) | 100 |
| **Honest served grade** | `src/services/model/servedGrade.js` | 142 |
| **Archetype doctrine** | `specs/ARCHETYPE-TAXONOMY-DOCTRINE.md` | 53 |
Also DONE and verified: **D1 glyph library** (39 of 83 wired — every glyph that
maps to a real archetype; colours match the registry exactly), **A1 card token**
(`--bg-1`, 32 consumers), **B1 boundary channel** (`--priced-out`, 4 consumers),
**book comparison** (mounted via `GradeResultCard → scan/page`), **THE WIRE**
(`vyndr/Ticker`).
### GATED — verified absent, with the gate named
| surface | gate | verified |
|---|---|---|
| **F5 article media** | card-system reconciliation | `ArticleHero`/`StatCallout`/`PullQuote` = 0 files |
| **E16/F8 share cards + crops** | card-system reconciliation *and* the resolution tail has no share-card generation step | `ShareCard.tsx` has **0 importers** |
| **In-season hub IA** | the content formula (social chat) | no spec exists anywhere |
| **E9 calibration curve** | model accrual — 0 eligible dates | `CalibrationCurve` = 0 files |
| **E15 Price Triplet MODEL leg** | model — EV layer | renders honest `NO_MODEL` |
| **E2 BookChip tiles / E6 push-to-book** | licensing / affiliate approval | every book `enabled:false` |
| **E3 best-number crown** | measurement — measured flat | `CrownBadge` = 0 files |
| **E13 Scanner S6** | another build order | — |
**There are no ungated Wave-2 targets left.** The next move is a decision, not a
build.
---
## THE TWO OPEN DECISIONS
### 1. Card-system reconciliation — unblocks F5 *and* E16/F8 together
The content engine emits **1080×1350 SVG** cards (`src/services/content/cardRenderer.js`).
The design package defines **five master sizes** with layouts and rasterised PNGs
in `specs/design-reference/exports/`: settle 1080×1350, story 1080×1920, square
1080, X 1200×675, record 1080×1350, article OG 1200×630.
**I built the renderer without checking whether a designed card system existed.
It did.** They are a parallel invention — not wrong, but they must become one
visual language before F5 (whose hero graphics are generated data visuals) or
E16 can proceed.
The decision: does the content engine conform to the E16 masters, or do the
masters absorb the engine's generator? **Cheapest unblock on the board — it
frees two gated items at once.**
### 2. In-season hub information architecture
`Vyndr Offseason.dc.html` specifies an **offseason** hub, and its shell is built.
Nothing specifies what that surface is **in-season**, or how content, articles,
wire and the live slate share year-round navigation. Every component exists; the
composition does not.
The spec is explicit that **sport state lives in the sport tab** (`NFL · CAMP 5D`)
and never in a separate offseason tab — so this is not a new route, it is a mode.
Gated on the social chat's content formula, which determines the recurring slots.
---
## ACCRUAL CLOCK
```
champion marker: engine1@2026-08-07-fullwindow
calibration_refit 0/10 WAITING (~2 weeks)
hits_factor_lift 0/10 WAITING (~2 weeks)
prior_verdict_reaudit 0/14 WAITING (3+ weeks)
rbi_lineup_slot_gate 0/14 WAITING (3+ weeks)
blocked: no settled rows yet carry the repaired champion marker
```
**FIRST TRIGGER: 10 eligible calibration dates → calibration re-fit.** Then, in
order: hits factor lift → prior verdict re-audit → rbi lineup-slot gate.
**Thresholds are ATTEMPT floors, not TRUST floors.** Reaching 10 dates means the
re-fit *can be measured*, not that it is trustworthy. A 10-date map is thin,
deploys PROVISIONAL with auto-demotion, and its interval will be wide.
**No measurement on reconstructions of the retired forecast.** Anything requiring
settled data waits. `src/services/model/reAuditEligibility.js` enforces it —
eligibility is a version marker, counted in DATES not rows, and a mixed table
counts only the repaired rows.
---
## STANDING DOCTRINE
**Truth Law.** No fabricated data anywhere — including **designer sample data**.
The design files' numbers (Nabers 1,120.5, Wembanyama +420→+330, Nº 128, DAY
RECORD 94) are a *spec for what a live feed renders*, never content to paste.
Tests assert none of it ships. `Number(null) === 0` is the classic breach; note
its inverse also bites — **zero is a real fact** ("0 cleared B+" is our most
honest post).
**83-glyph four-sport taxonomy.** A glyph renders ONLY where its archetype is
modeled and proven. 39 wired; **44 dormant slots** for WNBA/NBA/Soccer. Wiring
them would mean inventing 44 archetypes to consume artwork — decoration
presented as classification, forbidden. `DUAL THREAT` and `PAINT BOSS` are
modeled archetypes with no mark: the mirror gap, flagged to design.
**Repaired champion.** The forecaster read `res.last10`**ten games** — as its
season rate. Fixed to the full season log with recency weight 0.40 → 0.20.
Resolution: hits 0.00251 → 0.00817. **Every factor verdict in the programme was
measured against the broken baseline and may deserve re-audit** — direction
unknown, not pre-priced.
**Calibration is WITHDRAWN.** `CALIBRATION_DEPLOYED = []`. The maps were fitted
on the retired forecast; refitting today would refit it again. Served `p_win` is
repaired-champion raw.
**Grade doctrine.** `A+`, `A`, `A-` are **unissuable** — structurally, not rarely.
Ceiling is **B+ at 0.663 realized against a 0.6005 baseline**. Bands that cannot
be separated from the baseline SAY so. The served letter derives from `p_win`;
`engine1.grade` is preserved as `engine_grade` and read by no serving code.
**Two guards, both in CI:**
- `renderReachability.test.js` — a promised field must trace payload → adapter →
component → **mounted**. Built-but-unread killed three surfaces before this.
- `baseRateWindow.test.js` — no fixed N may stand in for a season. Six paths
across two sports fell to that class.
**Compose, don't fork.** The recurring failure this arc: building beside an
existing thing instead of on it (cards vs E16, `MovementStrip` nearly vs
`gradeShift`, `reportTemplate` nearly vs `newsletterService`). Check for the
existing implementation first — twice it was already there.
---
## PARALLEL WORKSTREAM SEEDS
### A. Social strategy chat
> VYNDR's content engine ships posts from real data (`src/services/content/`,
> three templates, Truth-Law enforced — an unbacked token refuses to render).
> Review `/studio` output and `specs/CONTENT-ENGINE.md`. Produce: the content
> formula/calendar (what posts recur, on what cadence), and **the card-format
> decision** — the engine's 1080×1350 renderer vs the five designed masters in
> `specs/design-reference/exports/`. That decision unblocks F5 article media and
> E16/F8 on the build side. Voice: `specs/VOICE.md` governs; the engine templates
> encode a sharper register — decide whether that becomes the house voice.
### B. Multi-sport archetype build
> VYNDR has archetype registries for MLB (15), NBA (15), Soccer (6), WNBA (5) —
> but **only MLB batters is MODELED**; the rest have no proven factor model, so
> their glyphs stay dormant per `specs/ARCHETYPE-TAXONOMY-DOCTRINE.md`. 44 of 83
> designed glyphs await their sport. Pick a sport, build its archetype
> classifier from real features, and take it through the two-part gate
> (`src/services/model/factorGate.js` — must MOVE the prediction and improve
> out-of-sample Brier, cumulative-Bonferroni corrected). Prerequisite: that
> sport needs settled outcomes. WNBA settles via ESPN box scores; NBA and Soccer
> do not settle at all yet, which is the real first task there.
---
## Where to start in the next chat
1. Take one of the two open decisions (card system is cheapest — unblocks two).
2. Or run a parallel workstream seed above.
3. **Do not** start model work — it is date-blocked and will stay so for ~2 weeks.
4. Verify before building: this arc corrected its own board four times. `git
grep` beats any summary, including this one.
+92
View File
@@ -0,0 +1,92 @@
# What "media hub" already means in VYNDR's design language
Read-only inventory. Nothing designed, built or mounted.
## PHASE 0 — the design source of truth exists, and so does a gap audit
| artifact | what it is |
|---|---|
| `specs/DESIGN-SPEC.md` | **DESIGN SPEC v2** — governing laws, colour contract, entity layer, data-display standard, motion, archetype system, conversion architecture, empty/error system. 84 lines, supersedes v1. |
| `specs/design-reference/` (Jul 22) | **The design package itself** — 7 `.dc.html` surface files, 83 archetype glyph SVGs + MANIFEST, 7 rasterised share-card PNG masters. |
| `specs/design-reference/HANDOFF.md` | The code handoff: exact tokens, brand, glyph library, behaviour, laws. |
| **`specs/design-vs-build-gap-audit.md`** | **A 61-item design-vs-build audit already exists** (2026-07-31, audited at `bf7c0a3`), every claim grep-verified, with a wave-ordered build plan. |
**The inventory this order asks for was largely already done on 2026-07-31.** The
useful work is reconciling it, not redoing it.
## PHASE 1 — per media surface: spec, and build state
| surface | design spec? | built state | evidence |
|---|---|---|---|
| **Offseason hub** | **YES — a full surface file** | **NOT-STARTED** | `Vyndr Offseason.dc.html` (119KB) defines hub home (NFL desktop + 390), an **NBA Summer-League variant with an `OUTLOOK ONLY / NOT GRADED` honesty block**, a season-long board (open→NOW→VYNDR triplet), a season-read reveal with `WHAT WOULD CHANGE THIS READ`, a news/outlook feed with row anatomy, and a **quiet-wire empty state**. Gap audit: F9/F10/F11, *"design complete, no blocker but big."* |
| **Article media** | **YES — surface S3** | **NOT-STARTED** | Hero template + **4 hero graphic archetypes** (line path / distribution / mark-at-scale / matchup card — *all generated data visuals, never stock*), inline figures (stat-callout triptych, comparison bars, pull quote — **one max**), **caption law: every figure names its data**, article card, OG 1200×630 (master already rasterised in `exports/`). Gap audit F5. Zero components built. |
| **THE WIRE** | **YES** | **BUILT** (E19) | Spec'd in `Vyndr System.dc.html`: timestamped entry, ~6s hold, tag coloured by meaning. Built as `vyndr/Ticker` on real `/api/ticker` exhaust. **`NewsWire.tsx` is a different thing** — the offseason news/outlook feed, mounted in ExploreHub. |
| **Movement strip** | **YES — E1, a named primitive** | **ABSENT** | *"steps not curves, green only when the move favours the read, FLAT = hairline + `FLAT · [N]D`"*, row 86×20 silent / full-width annotated on reveal. **This corrects my 2026-08-07 board, which listed it UNKNOWN / possibly-satisfied-by-GradeShift.** It is a specified primitive that does not exist. |
| **Share-card masters ×5** | **YES — E16** | **ABSENT as product** | settle 1080×1350, story 1080×1920, square 1080, X 1200×675, record 1080×1350, article OG 1200×630. PNG masters in `exports/`; `ShareCard.tsx` has **0 importers**. Gap audit blocks it on the resolution tail having no share-card generation step. |
| **Calibration curve** | YES — E9 | ABSENT | dots vs dashed perfect line, dot size = sample, buckets under N30 hollow. |
| **The Report email / `/report` archive** | YES — E10/E12 | PARTIAL / ABSENT | `newsletterService` builds an email; the designed dark-billboard-over-light-paper hybrid is not built. `/report` redirects to `/blog`. |
| **ExploreHub** | **NO SPEC** | BUILT | Not a designed surface. It is where `NewsWire` currently lives — a de-facto host, not the intended hub. |
## PHASE 2 — what an on-site media hub would consist of
**From surfaces already designed** (no new design needed):
1. **Offseason hub shell** — F9/F10/F11, fully spec'd including its honesty block and quiet-wire empty state.
2. **News/outlook feed** — spec'd row anatomy; `NewsWire.tsx` is a partial implementation of it.
3. **Article media** — S3, fully spec'd hero + figure system.
4. **THE WIRE** — built, reusable.
5. **Share/OG cards** — designed at exact sizes, masters exported.
### The gap that needs design work authored
**Only one thing is genuinely un-designed: the hub's INFORMATION ARCHITECTURE
across seasons.** The Offseason file specifies an *offseason* hub; there is no
spec for what the same surface is **in-season**, or how content, articles, wire
and the live slate share one navigation. Every component exists on paper; **their
composition into a year-round media surface does not.**
### One finding that touches work I just shipped
**I built a card renderer at 1080×1350 without checking whether a designed card
system existed. It does** — five master sizes with defined layouts, and
rasterised PNGs in `exports/`. The content engine's cards are a parallel
invention. They are not wrong, but they should conform to E16/F8 rather than
diverge, and that reconciliation is a design decision, not a code one.
## PHASE 3 — the map
| surface | spec? | built | part of hub? | needs design work? |
|---|---|---|---|---|
| Offseason hub shell | ✅ | ❌ | **core** | no — build it |
| News/outlook feed | ✅ | partial | **core** | no |
| Article media (S3) | ✅ | ❌ | **core** | no — build it |
| THE WIRE | ✅ | ✅ | yes | no |
| Movement strip (E1) | ✅ | ❌ | adjacent | no |
| Share/OG cards (E16) | ✅ | ❌ | yes | **reconcile with content-engine cards** |
| Content-engine posts | ❌ | ✅ | **yes** | **yes — no spec exists** |
| ExploreHub | ❌ | ✅ | host today | **yes — is it the hub or a placeholder?** |
| **Year-round hub IA** | ❌ | ❌ | **the spine** | **YES — the real design gap** |
### Coordination with the social-strategy chat
These should be decided **there first**, then reflected on-site:
- **The content formula/calendar** determines what the hub's recurring slots
*are*. Designing slots before the formula exists risks a surface shaped around
guesses.
- **Card format reconciliation** — off-site posts and on-site share cards should
be one system. E16's five masters vs the content engine's renderer is the same
decision on both sides.
- **Voice**: `specs/VOICE.md` governs; the content engine templates encode a
sharper register. Whether the hub speaks in that register is a brand call.
### Correction to the still-buildable list
The order lists content API, preview page, book-comparison mount and guard
widening as pending. **All four shipped at `c575a70`.** Book comparison was never
unmounted — my earlier board grepped only `web/src/app` and missed
component-level mounting.
**Genuinely buildable now, design-complete, no clock impact:** Offseason hub
(F9F11), article media (F5), movement strip (E1), calibration curve (E9), the
Report email + archive (E10/E12). The gap audit's own wave ordering already
sequences these.
+113
View File
@@ -0,0 +1,113 @@
# Built-but-unread — the defect class, and the guard that ends it
## The class
Three consecutive orders shipped a backend-correct field that never reached a
screen. All three passed a fully green suite.
| order | what was built | why it never rendered |
|---|---|---|
| grade bands | `gradeBands.js`, six orders of work | required by no serving code |
| `91927a4` | `served_grade` on the payload | dropped at the adapter boundary |
| `3591c76` | `GradeScaleLegend.tsx` | imported by nothing |
Each was caught by luck on a later re-check, and in two of the three I had
already **reported the wiring as done**.
### Why green tests could not see it
Backend tests stop at the API payload. They prove a field is **produced** and say
nothing about whether it is **consumed**. The failure is invisible to them by
construction — not an oversight in any individual test.
### The cognitive trap, named
The difficulty pools in the backend. Deriving the grade, proving the factors,
measuring resolution — that is where the thinking happens, and by the time a
field exists on the payload it *feels* finished. The remaining step is a
three-line adapter change that nobody considers worth verifying, so it gets
claimed rather than traced. **The last inch is the one with no friction, which is
exactly why it is the one that gets skipped.**
Nothing here is a frontend-competence problem. It is that "I added the field" and
"a user can see it" are different claims, and only the first one is fun.
---
## The contract
`tests/unit/renderReachability.test.js` holds the promised-field contract — the
things the grade product commits to a user seeing:
| promise | payload | adapter | component |
|---|---|---|---|
| the served grade object | `served_grade` | `served_grade` (container) | GradeResultCard |
| what this grade means | `served_grade.meaning` | `gradeMeaning` | GradeResultCard |
| whether the band separates | `separates_from_base_rate` | `separatesFromBaseRate` | GradeResultCard |
| what the band realized | `band_realized_rate` | `bandRealizedRate` | GradeResultCard |
| which factors moved the read | `factor_adjustment` | `factorsApplied` | GradeResultCard |
| the ceiling stance | — | — | GradeScaleLegend |
Adding a served field without adding it here is allowed. Adding it **here**
without wiring it to a mounted component is not.
---
## The guard
For each promised field it traces the whole path:
```
payload field -> adapter consumes it -> component renders it -> component is MOUNTED
```
**"Mounted" is transitive to a Next entry point** (`page`/`layout`/`template`) —
the only thing that puts a pixel on a screen. A component that exists and renders
its field perfectly but is imported by nothing fails. Depth-limited so an import
cycle cannot hang the suite.
**Container rows are exempted explicitly, not silently.** `served_grade` is
consumed by the adapter but not rendered directly, so it carries
`container: true` plus a `rendersVia` list — and a separate assertion checks that
**every named part actually renders.** The exemption is auditable; it cannot hide
an unrendered field.
The guard also tests itself: it asserts that an orphan component reports
unmounted, and that the contract is non-empty (an empty contract would pass
vacuously — the way this guard would most plausibly rot).
### Retro-proof
Run unchanged against the tree at `3591c76`, before the wiring:
```
Tests: 11 failed, 8 passed
● the ceiling stance / grade scale legend — its component is MOUNTED, not merely written
● the served grade object — the adapter consumes it
● whether the band separates from the baseline — a component actually renders it
● what this grade means — the adapter consumes it
● which proven factors moved the read — a component actually renders it
...
```
**It names the exact three bugs.** Green on the current tree.
### One honest scope limit
`gradeBands` is **not** in the contract and would not be caught. It is a backend
module, not a promised user-facing field, and it is correctly unwired — every
band it produces collapses to base-rate at current resolution. The guard covers
*promised* fields; a backend module that should not yet render is out of scope by
design, not by oversight.
---
## In the deploy floor
The guard runs in the standing suite, so it is part of the three-gate floor:
**tests green** (now including reachability) + web build exit 0 + post-deploy
fingerprint. A future order that adds a served field without wiring it to a
mounted component fails CI rather than a hand-check three orders later.
No serving or model change in this order. `p_win` never mutated. No Bonferroni
slot.
+132
View File
@@ -0,0 +1,132 @@
# The grade surface — it wasn't missing, it was serving the weaker signal
## PHASE 0 — what actually reaches a user
**Correction to the order's premise: a grade letter has been served all along.**
`engine1.gradeProp` produces it from an additive factor index, and it is
**not derived from `p_win` at all** — the two are computed independently and both
ride the payload.
`gradeBands` is orphaned for a different reason than assumed: it defines what a
letter *means* from realized outcomes, and every band it produces collapses to
base-rate at current resolution. It was never the missing link to the surface.
### The measurement that changed the order
3,417 settled props, pooled across the four batter stats:
| grade | n | realized | mean p_win |
|---|---|---|---|
| **A** | 8 | **0.5000** | 0.6466 |
| B | 985 | 0.6396 | 0.7002 |
| C | 1,695 | 0.6024 | 0.6755 |
| D | 303 | 0.5578 | 0.6042 |
| F | 426 | 0.5352 | 0.5875 |
```
grade-letter resolution 0.00116 (0.48% of variance)
p_win resolution 0.00715 (2.98%)
=> the letter carries 0.16x the information of the number beside it
```
**The top grade hit worse than the bottom grade.** And concretely, from the
hand-verify: **Christian Encarnación's 0.95 over graded `C`, and his 0.05 under
also graded `C`** — same hitter, opposite forecasts, same letter.
The gap was never that grades don't ship. It is that **the weaker of two
available signals was shipping as the headline.**
---
## PHASE 1 — the honest grade
`model/servedGrade.js` derives the letter from `p_win`, with bands anchored on
**measured realized rates**, not targets:
| letter | p_win ≥ | realized | separates from base rate? |
|---|---|---|---|
| B+ | 0.780 | 0.663 | yes |
| B | 0.700 | 0.646 | yes |
| C+ | 0.640 | 0.615 | **no** |
| C | 0.560 | 0.589 | **no** |
| C- | 0.480 | 0.548 | **no** |
| D | 0.350 | 0.512 | yes |
| F | 0.000 | 0.447 | yes |
Base rate 0.6005.
### No manufactured A — structurally
**`A+`, `A` and `A-` are UNISSUABLE.** Not rare — absent by construction. The
realized rate plateaus at 0.650.68 above p_win 0.70 (the 0.9+ bucket does no
better than the 0.8 bucket), so no band of this forecast has earned a top letter.
A test sweeps every p_win from 0 to 1 and asserts none produces one. Even a 0.99
forecast tops out at B+ with its realized 0.663 attached.
When resolution improves enough to earn an A, the ceiling gets raised
deliberately and visibly — not by a threshold quietly drifting.
### Bands that cannot separate SAY so
`C+ / C / C-` carry `separates_from_base_rate: false` and copy that names it —
*"a base-rate read; the model sees nothing that separates this."* That covers the
bulk of the board, and it is the honest description of a forecast explaining 3%
of variance.
### The basis is stated, never implied
Each grade carries `basis`: `forecast_plus_matchup_factors` (naming which of the
three proven factors fired) or `forecast_only`, plus `calibrated: false`
calibration is withdrawn and nothing here rides on a number that doesn't exist.
`engine1.grade` is preserved as `engine_grade` so nothing downstream breaks and
the two stay comparable.
---
## PHASE 2 — the refusal surface
Refusals render a real state, never a blank or a fabricated number:
- `insufficient_data`**NO READ***"not enough history to call this one"*
- `juiced_no_edge`**NO READ** — *"the book has priced the vig past any edge on
this side"*
1,870 refused snapshots carry exactly these two reasons, and both now surface.
`projectionFor` reads the repaired full-window reference, so refusals are
computed on the repaired champion.
---
## PHASE 3 — hand-verified on real served props
| prop | p_win | OLD | NEW | separates | state |
|---|---|---|---|---|---|
| Freddie Freeman hits 0.5o | 0.95 | B | **B+** | true | graded |
| Christian Encarnación hits 0.5o | 0.95 | **C** | **B+** | true | graded |
| Ben Rice hits 0.5o | 0.95 | B | **B+** | true | graded |
| Christian Encarnación hits 0.5u | 0.05 | **C** | **F** | true | graded |
| Ben Rice hits 0.5u | 0.05 | C | **F** | true | graded |
| Eliezer Alfonso Jr doubles 0.5o | — | — | **NO READ** | — | refused |
| Eliezer Alfonso Jr doubles 0.5u | — | — | **NO READ** (vig) | — | refused |
| Paul Goldschmidt doubles 0.5o | — | — | **NO READ** | — | refused |
```
never-blank check: PASS — every prop renders a label and a meaning
no-manufactured-A check: PASS
```
The Encarnación rows are the clearest evidence: under the old letter his 0.95 and
his 0.05 were both `C`. Under the new one they are `B+` and `F`.
---
## Invariants
Grades ride on repaired-champion raw `p_win` plus factors where they fire. No
calibrated number leaks — the deployed set is empty and `calibrated: false` is
stated on every grade. `p_win` never mutated. Nine frozen model modules verified
unchanged, `engine1` included. No Bonferroni slot — no new factor.
**Still true and unchanged:** the forecast explains ~3% of outcome variance. This
order did not make the model better. It made the letter stop overstating it.
+84
View File
@@ -0,0 +1,84 @@
# Wave D1 — and why E1/E9 were not it
## PHASE 0 — the audit's actual wave order
The order proposed E1 + E9 as Wave-1 primitives and instructed me to follow the
audit if it disagreed. **It disagrees.**
| item | the audit's placement |
|---|---|
| **E9 calibration curve** | **WAVE D3 — gated on MODEL work**: *"resolve n≥20 vs N30, accrue buckets"* |
| **E1 movement strip** | **WAVE D6 — large surface builds**, alongside crown / disagreement axis / SPLIT |
**Neither is Wave 1.** And E9's gate is live right now: calibration is
**withdrawn** (`CALIBRATION_DEPLOYED = []`) at **0 eligible dates**, so a
calibration curve today could only render its empty state. Building it now would
produce a component whose entire purpose is unavailable.
**WAVE D1, per the audit:** glyph library · card token + `vy-glitch` naming ·
`nudge()`/boot-stagger/row-hover/IntersectionObserver · boundary-channel blue ·
team-gradient chips + READ-FAB.
## PHASE 1 — three of the five Wave D1 items were already done
Checked rather than assumed, and the audit (2026-07-31) has aged:
| item | audit said | measured now |
|---|---|---|
| **D1 glyph library** | PARTIAL — 38 of 83 wired (46%) | **COMPLETE for everything wireable.** 83 designed glyphs, 41 registry archetypes, **39 map — and all 39 are wired, with colours matching the registry exactly (0 disagreements).** |
| **A1 card token `#0E0E14`** | BUILT-BUT-DRIFTED — "in only 1 file" | **BUILT-TO-SPEC.** It is the `--bg-1` token in `globals.css`, consumed by **32 files** via `var(--bg-1)`. The audit counted literal hex occurrences, which is what a *correctly tokenised* value looks like. |
| **B1 boundary channel** | PARTIAL — hex in 2 files | **BUILT.** `--priced-out: #8fb2de` + `--priced-out-dim` are tokens with a documented colour law in `globals.css`, consumed by 4 files. |
**The 44 unwired glyphs are not a wiring gap.** They have no backend archetype —
wiring them would mean **inventing 44 archetypes to consume artwork**, which is
the fabrication this programme refuses. That is the 41-vs-74 scope question, and
it is Kev's call, not a build task. Separately, **2 registry archetypes have no
designed glyph** (`DUAL THREAT`, `PAINT BOSS`) — a design gap, not a code one.
## PHASE 2 — what was genuinely absent, and is now built
`web/src/lib/motion.js` — the four primitives, all previously 0 files:
| primitive | the rule it enforces |
|---|---|
| **E17 `nudge()`** | ≤180ms. Confirms an action *registered* without claiming it finished — a long pulse reads as latency, the opposite of a perceived-speed primitive. |
| **E18 `bootStagger()`** | **capped at 240ms.** Uncapped, row 40 waits 1.1s and the stagger *becomes* the latency it exists to disguise. |
| **E27 `rowHover()`** | returns handlers, not CSS — so a tap on touch cannot stick a hover state that never clears. |
| **E28 `revealOnIntersect()`** | returns an unobserve fn in every path, and **reveals IMMEDIATELY** without IntersectionObserver or under reduced motion. Content is never hidden behind a capability check. |
**Reduced motion is honoured, not softened** — every primitive skips outright,
and server-side assumes reduced. 10 tests, the sharpest being that
`bootStagger` under reduced motion returns `opacity: 1` and not merely
`delay: 0`: if the CSS animation supplies the opacity, skipping the animation
without forcing it leaves the row invisible forever.
## PHASE 3 — guard registration and Wave-2 readiness
The reachability guard now carries a **PRIMITIVES** section: a module built to be
embedded must declare its exports *and name its intended consumers*. A primitive
imported by nothing is the same built-but-unread class as an unmounted
component. 24 checks green.
### Wave-2 readiness
**Ungated — design complete, no blocker:**
- **F9/F10/F11 offseason hub** (Wave D6) — the largest, fully spec'd
- **F5 article media** (Wave D6) — hero + figure system fully spec'd
- **E10/E12 The Report email + `/report` archive** — design exists
- **E1 movement strip** (Wave D6) — spec'd, absent, now has its motion primitives
**Gated, and by what:**
| item | gate |
|---|---|
| E9 calibration curve | **model** — calibration withdrawn, 0 eligible dates |
| E15 Price Triplet MODEL leg | **model** — EV layer |
| E16 share cards + F8 crops | **resolution tail** *and* the card-system reconciliation |
| E2 BookChip tiles / E6 push-to-book | **licensing / affiliate approval** |
| E13 Scanner S6 | another build order |
**The card-system reconciliation** (content-engine 1080×1350 vs E16's five
masters) gates E16/F8 and should be settled with the social chat, since off-site
posts and on-site share cards are one system.
Isolated throughout: read-only, no serving or model change, **accrual clock
unchanged at 0 eligible dates**.
+7
View File
@@ -204,6 +204,9 @@ app.use('/api/content', contentRoutes);
// Session S7 (a1) — THE VYNDR REPORT: public double-opt-in subscribe
// (forwards to the self-hosted Listmonk; graceful no-op without env).
app.use('/api/newsletter', require('./routes/newsletter'));
// E12 — The Report archive. Public, read-only; every issue carries its own
// day record, because the archive is a ledger too.
app.use('/api/report', require('./routes/report'));
// A1 S9 — Slip Reader: OCR a bet-slip screenshot into legs (auth +
// per-tier daily quota inside the router). Values are user-slip values.
app.use('/api/slips', require('./routes/slips'));
@@ -212,6 +215,10 @@ app.use('/api/slips', require('./routes/slips'));
// the public surface; the Next.js admin route proxies through with
// the key kept server-side.
app.use('/api/internal', internalRoutes);
// Content STUDIO — finished posts (copy + card + fact-contract) for review and,
// later, for an autonomous poster. Distinct from /api/content (Session 29),
// which serves structured content objects by data level.
app.use('/api/content-studio', require('./routes/contentStudio'));
// A1 S3 — partner attribution report. Internal-key gated (router-level
// requireInternalAuth); no Next proxy on purpose — never browser-facing.
app.use('/api/partners', require('./routes/partners'));
+175
View File
@@ -0,0 +1,175 @@
'use strict';
/**
* /api/content-studio — the generated-post review surface.
*
* NOT to be confused with `/api/content` (Session 29), which serves structured
* content OBJECTS by data level. This serves finished POSTS from the content
* engine: copy, branded card, and the fact-contract each was built from.
*
* ── API-FIRST, ON PURPOSE ────────────────────────────────────────────────
* This is the contract Kev's preview page consumes today and an autonomous
* poster consumes later. The page is a thin client; no posting logic lives in
* it. Pointing a bot here needs no change on this side, which is the entire
* reason it is an API before it is a screen.
*
* ── READ-ONLY WHERE IT COUNTS ────────────────────────────────────────────
* It serves generated content. Beyond the read-only pulls the engine already
* makes, it touches no serving, model or ledger table — zero effect on the
* repaired-champion accrual clock. Approval status lives in Redis, because an
* editorial decision is not a model fact and must never sit beside ledger rows.
*/
const express = require('express');
const { requireInternalAuth } = require('../middleware/internalAuth');
const engine = require('../services/content/contentEngine');
const { toSvg } = require('../services/content/cardRenderer');
const router = express.Router();
router.use(requireInternalAuth({ loopbackOnly: false })); // private: Kev's desk, then an agent's
const STATUS_KEY = (date) => `contentstudio:status:${date}`;
const VALID_STATUS = new Set(['pending', 'approved', 'skipped', 'regenerate_requested']);
const dateET = () => new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date());
function registerTemplates() {
for (const t of ['hotHitters', 'honestyFlex', 'streakList']) {
try { engine.registerTemplate(require(`../services/content/templates/${t}`)); } catch { /* idempotent */ }
}
}
/**
* GET /api/content-studio/:date?
*
* → { date, count, posts: [{ id, label, sport, status, ok, skipped, reason,
* honest_absence, copy, card, card_svg, fact_contract, facts }] }
*
* `fact_contract` is what the post was REQUIRED to have; `facts` is what
* actually backed it. A reviewer — or an agent — can check the claim rather
* than trust the sentence.
*/
// EXPRESS 5 DROPPED THE `?` OPTIONAL-PARAM SYNTAX -- `'/:date?'` throws at mount
// time and takes down every suite that imports app.js. Two explicit routes.
async function handleGet(req, res) {
try {
registerTemplates();
const date = req.params.date || dateET();
const deps = req.app.get('contentStudioDeps') || (await buildDeps(date));
const results = await engine.generateAll({ ...deps, date });
let statuses = {};
try { statuses = (await require('../utils/redis').cacheGet(STATUS_KEY(date))) || {}; } catch { statuses = {}; }
const posts = results.map((r) => {
const t = engine.getTemplate(r.id) || {};
return {
id: r.id,
label: t.label || r.id,
sport: t.sport || null,
status: statuses[r.id] || 'pending',
ok: r.ok === true,
skipped: r.skipped === true,
reason: r.reason || null,
honest_absence: r.honest_absence === true,
copy: r.copy || null,
card: r.card || null,
card_svg: r.card ? toSvg(r.card) : null,
fact_contract: t.requires || [],
facts: r.facts || null,
};
});
res.json({ date, count: posts.length, posts });
} catch (e) {
res.status(500).json({ error: e.message });
}
}
router.get('/', handleGet);
router.get('/:date', handleGet);
/** POST /api/content-studio/:date/:id/status { status } — editorial state only. */
router.post('/:date/:id/status', express.json(), async (req, res) => {
const { date, id } = req.params;
const status = String((req.body || {}).status || '');
if (!VALID_STATUS.has(status)) {
return res.status(400).json({ error: `status must be one of ${[...VALID_STATUS].join(', ')}` });
}
try {
const { cacheGet, cacheSet } = require('../utils/redis');
const cur = (await cacheGet(STATUS_KEY(date))) || {};
cur[id] = status;
await cacheSet(STATUS_KEY(date), cur, 60 * 60 * 24 * 14);
return res.json({ ok: true, date, id, status });
} catch (e) {
return res.status(500).json({ error: e.message });
}
});
/** Real sources. All READ-ONLY. */
async function buildDeps(date) {
const sg = require('../services/model/servedGrade');
const { knownNumber } = require('../utils/known');
const sb = require('../utils/supabase').getSupabaseServiceClient();
const mlb = require('../services/adapters/mlbStatsAdapter');
const gradeDistribution = async () => {
if (!sb) return { total: null };
const { data } = await sb.from('model_snapshots')
.select('p_win, refused').eq('sport', 'mlb').eq('game_date', date).limit(5000);
const usable = (data || []).filter((r) => !r.refused && knownNumber(r.p_win) !== null);
const by = {}; let flat = 0;
for (const r of usable) {
const g = sg.gradeFor({ p_win: knownNumber(r.p_win) });
by[g.letter] = (by[g.letter] || 0) + 1;
if (g.separates_from_base_rate === false) flat += 1;
}
return { total: usable.length || null, by_letter: by, not_separable: flat };
};
const settledStreaks = async () => {
if (!sb) return [];
const { data } = await sb.from('ledger_entries')
.select('player_name, player_key, game_date, outcome')
.eq('sport', 'mlb').is('user_id', null).eq('stat', 'hits')
.in('outcome', ['hit', 'miss']).limit(5000);
const by = new Map();
for (const r of data || []) {
if (!by.has(r.player_key)) by.set(r.player_key, []);
by.get(r.player_key).push(r);
}
const out = [];
for (const [, rows] of by) {
rows.sort((a, b) => String(b.game_date).localeCompare(String(a.game_date)));
let n = 0;
for (const r of rows) { if (r.outcome === 'hit') n += 1; else break; }
if (n >= 3) out.push({ name: rows[0].player_name, streak: n, verified_from_settled: true });
}
return out;
};
const hitterForm = async () => {
if (!sb) return [];
const { data } = await sb.from('model_snapshots')
.select('player_name').eq('sport', 'mlb').eq('game_date', date).limit(400);
const names = [...new Set((data || []).map((r) => r.player_name).filter(Boolean))].slice(0, 40);
const out = [];
for (const n of names) {
try {
const r = await mlb.getPlayerStats(n);
const log = (r && r.found && Array.isArray(r.fullLog)) ? r.fullLog : [];
const vals = log.map((g) => knownNumber(g && g.stat && g.stat.hits)).filter((v) => v !== null);
if (vals.length < 20) continue;
const rate = (a) => a.filter((v) => v > 0).length / a.length;
out.push({ name: n, season_games: vals.length, season_rate: rate(vals), recent_rate: rate(vals.slice(-10)) });
} catch { /* absent player -> absent row */ }
}
return out;
};
return { servedGrade: sg, gradeDistribution, settledStreaks, hitterForm };
}
module.exports = router;
module.exports.__internals = { buildDeps, VALID_STATUS, STATUS_KEY };
+55
View File
@@ -0,0 +1,55 @@
'use strict';
/**
* /api/report — E12, the issue archive.
*
* The spec's law is one line: **"EVERY ISSUE SHOWS ITS OWN DAY RECORD — THE
* ARCHIVE IS A LEDGER TOO."** So an archive row is not a headline with a date;
* it carries the record that issue's reads actually produced. An archive that
* showed only titles would be a blog, and the point of this one is that it
* cannot quietly bury a bad day.
*
* Public and READ-ONLY. Issues live in Redis under `report:issue:{date}`,
* written by the send path; nothing here touches a serving, model or ledger
* table.
*/
const express = require('express');
const router = express.Router();
const INDEX_KEY = 'report:index';
const ISSUE_KEY = (d) => `report:issue:${d}`;
/** GET /api/report — the archive list, newest first. */
router.get('/', async (req, res) => {
try {
const { cacheGet } = require('../utils/redis');
const index = (await cacheGet(INDEX_KEY)) || [];
const issues = Array.isArray(index) ? index : [];
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 60));
res.set('Cache-Control', 'public, max-age=300');
res.json({
count: issues.length,
// Honest empty state is the caller's to render; we simply report zero.
issues: issues.slice(0, limit),
});
} catch (e) {
res.status(500).json({ error: e.message });
}
});
/** GET /api/report/:date — one issue. 404 rather than an invented shell. */
router.get('/:date', async (req, res) => {
try {
const { cacheGet } = require('../utils/redis');
const issue = await cacheGet(ISSUE_KEY(req.params.date));
if (!issue) return res.status(404).json({ error: 'no issue for that date' });
res.set('Cache-Control', 'public, max-age=300');
return res.json(issue);
} catch (e) {
return res.status(500).json({ error: e.message });
}
});
module.exports = router;
module.exports.__internals = { INDEX_KEY, ISSUE_KEY };
+83
View File
@@ -0,0 +1,83 @@
'use strict';
/**
* cardRenderer — one card engine, per-template layouts.
*
* SVG rather than canvas: it is text, so it has no native dependency, it
* diffs in review, and the numbers inside it are greppable — which matters when
* the whole claim is that the numbers are real. A card whose contents cannot be
* inspected without opening an image is a bad fit for a Truth-Law product.
*
* The card never formats its own facts. Every string arrives already rendered
* and already gate-checked by contentEngine, so a caption and a card physically
* cannot disagree.
*/
const BRAND = Object.freeze({
bg: '#05070A',
panel: '#0A0E14',
line: '#1A222E',
green: '#00D4A0', // the R
white: '#FFFFFF', // VYND
dim: '#6B7A8D',
amber: '#FFB347',
mono: "ui-monospace, 'JetBrains Mono', 'SFMono-Regular', Menlo, monospace",
});
const W = 1080;
const H = 1350;
const esc = (s) => String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
/** The slashed-Y wordmark: VYND white, R green. */
function wordmark(x, y, size = 34) {
return `
<g font-family="${BRAND.mono}" font-size="${size}" font-weight="800" letter-spacing="${size * 0.09}">
<text x="${x}" y="${y}" fill="${BRAND.white}">VYND</text>
<text x="${x + size * 2.92}" y="${y}" fill="${BRAND.green}">R</text>
<line x1="${x + size * 1.02}" y1="${y - size * 0.78}" x2="${x + size * 1.42}" y2="${y + size * 0.22}"
stroke="${BRAND.green}" stroke-width="${Math.max(2, size * 0.07)}" opacity=".85"/>
</g>`;
}
/**
* Render a card model to SVG.
* @param {object} card { kind, title, subtitle, lines[], footer }
*/
function toSvg(card = {}) {
const lines = card.lines || [];
let y = 300;
const body = lines.map((l) => {
const text = typeof l === 'string' ? l : l.text;
const style = (typeof l === 'object' && l.style) || 'body';
let out = '';
if (style === 'rule') {
out = `<line x1="72" y1="${y - 18}" x2="${W - 72}" y2="${y - 18}" stroke="${BRAND.line}" stroke-width="1"/>`;
y += 26;
return out;
}
const size = style === 'lead' ? 46 : style === 'stat' ? 40 : 30;
const fill = style === 'stat' ? BRAND.green : style === 'dim' ? BRAND.dim : BRAND.white;
const weight = style === 'body' ? 500 : 800;
out = `<text x="72" y="${y}" font-family="${BRAND.mono}" font-size="${size}" font-weight="${weight}" fill="${fill}">${esc(text)}</text>`;
y += size + (style === 'lead' ? 26 : 18);
return out;
}).join('\n');
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<rect width="${W}" height="${H}" fill="${BRAND.bg}"/>
<rect x="40" y="40" width="${W - 80}" height="${H - 80}" fill="${BRAND.panel}" stroke="${BRAND.line}"/>
${Array.from({ length: 26 }, (_, i) => `<line x1="40" y1="${52 * i + 40}" x2="${W - 40}" y2="${52 * i + 40}" stroke="${BRAND.white}" stroke-width="1" opacity=".02"/>`).join('')}
${wordmark(72, 128)}
${card.title ? `<text x="72" y="212" font-family="${BRAND.mono}" font-size="54" font-weight="800" fill="${BRAND.white}" letter-spacing="1">${esc(card.title)}</text>` : ''}
${card.subtitle ? `<text x="72" y="256" font-family="${BRAND.mono}" font-size="26" font-weight="600" fill="${BRAND.dim}" letter-spacing="2">${esc(card.subtitle)}</text>` : ''}
<line x1="72" y1="272" x2="${W - 72}" y2="272" stroke="${BRAND.green}" stroke-width="2" opacity=".55"/>
${body}
<text x="72" y="${H - 96}" font-family="${BRAND.mono}" font-size="22" font-weight="600" fill="${BRAND.dim}">${esc(card.footer || 'Every number here is measured. Nothing is projected.')}</text>
<text x="72" y="${H - 62}" font-family="${BRAND.mono}" font-size="20" font-weight="500" fill="${BRAND.line}">vyndr · built by Kevon Butler · Detroit</text>
</svg>`;
}
module.exports = { toSvg, BRAND, W, H };
+172
View File
@@ -0,0 +1,172 @@
'use strict';
/**
* contentEngine — posts that structurally cannot lie.
*
* The top-of-funnel is a content flywheel, and the thing that makes it VYNDR's
* rather than anyone's is that the numbers in it are real. That is easy to
* promise and hard to keep, because the failure mode is not a person choosing to
* fabricate — it is a template with a hardcoded adjective, or a field that came
* back null and rendered as "0", or a caption drifting from the card beside it.
*
* So the honesty is enforced by construction rather than by care:
*
* 1. COPY IS TOKEN-SUBSTITUTED. Every factual claim in a template is a
* `{token}` resolved against pulled facts. A token with no backing fact
* REFUSES to render — it cannot fall back to a plausible default, because
* there is no code path that produces one.
* 2. THE FACT CONTRACT IS ASSERTED FIRST. A template declares the fields it
* needs; the engine checks them before any string is built. A missing field
* means SKIP or an honest-absence variant, never invention.
* 3. CARD AND COPY SHARE ONE FACT OBJECT. They cannot diverge, because there
* is only one set of numbers and both read it.
*
* NO LIVE MODEL WRITES FACTUAL CLAIMS. The voice lives in the template; the
* facts are pulled. A voice-polish port is reserved for later and is not wired
* here — an LLM that can rewrite a sentence can rewrite a number.
*
* READ-ONLY. This engine touches no serving or forecast table. It has zero
* effect on the repaired-champion accrual clock.
*/
const { knownNumber } = require('../../utils/known');
/** Resolve `a.b.c` against an object; undefined when any hop is missing. */
function pathValue(obj, path) {
return String(path).split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
}
/**
* A value that may stand as a fact.
*
* `null` and `undefined` are absent. Empty string is absent — it renders as a
* hole in a sentence. Zero and false are PRESENT: "0 props cleared B+" is a real
* and important claim, and treating 0 as missing is the `Number(null) === 0`
* breach wearing its opposite coat.
*/
function isPresent(v) {
if (v === null || v === undefined) return false;
if (typeof v === 'string' && v.trim() === '') return false;
if (Array.isArray(v) && v.length === 0) return false;
if (typeof v === 'number' && !Number.isFinite(v)) return false;
return true;
}
const TOKEN = /\{([a-zA-Z0-9_.]+)\}/g;
/** Every `{token}` in a string. */
function tokensIn(text) {
const out = [];
let m;
const re = new RegExp(TOKEN);
while ((m = re.exec(String(text))) !== null) out.push(m[1]);
return out;
}
/**
* Substitute tokens, refusing on any that is unbacked.
* @throws when a token has no present fact — the whole point.
*/
function render(text, facts) {
const missing = tokensIn(text).filter((t) => !isPresent(pathValue(facts, t)));
if (missing.length) {
throw new Error(`TRUTH LAW: unbacked token(s) ${missing.join(', ')} — refusing to render`);
}
return String(text).replace(TOKEN, (_, t) => String(pathValue(facts, t)));
}
/** Which contract fields are absent from a pulled fact set. */
function contractGaps(template, facts) {
return (template.requires || []).filter((f) => !isPresent(pathValue(facts, f)));
}
const registry = new Map();
/**
* Register a template.
*
* @param {object} t
* id stable key
* sport which sport it speaks for
* requires fact-contract: dotted paths that MUST be present
* pull async (deps) => facts — the ONLY place data enters
* copy (facts) => string with {tokens}
* card (facts) => card model (layout + token strings)
* absent optional (gaps) => honest "nothing tonight" post
*/
function registerTemplate(t) {
if (!t || !t.id) throw new Error('a template needs an id');
for (const k of ['requires', 'pull', 'copy', 'card']) {
if (!t[k]) throw new Error(`template ${t.id} is missing ${k}`);
}
registry.set(t.id, t);
return t;
}
const listTemplates = () => [...registry.values()];
const getTemplate = (id) => registry.get(id) || null;
/**
* Generate one post.
*
* @returns {object} { ok, id, copy, card, facts, skipped, reason }
* Never throws on absent data — absence is an outcome, not an error.
*/
async function generate(id, deps = {}) {
const t = registry.get(id);
if (!t) return { ok: false, id, skipped: true, reason: 'no such template' };
let facts;
try {
facts = await t.pull(deps);
} catch (e) {
return { ok: false, id, skipped: true, reason: `pull failed: ${e.message}` };
}
// ── THE CONTRACT, BEFORE ANY STRING IS BUILT ──
const gaps = contractGaps(t, facts || {});
if (gaps.length) {
if (typeof t.absent === 'function') {
const alt = t.absent(gaps, facts || {}) || {};
// An absence variant may DECLINE to speak. A template that cannot tell
// "nothing happened" from "nothing was fetched" must not publish the
// first sentence when the second is true -- honest-absence copy is a
// perfect hiding place for a broken pull.
if (alt.skip) return { ok: false, id, skipped: true, reason: alt.skip, gaps };
return { ok: true, id, honest_absence: true, copy: alt.copy, card: alt.card, facts: facts || {}, gaps };
}
return { ok: false, id, skipped: true, reason: `fact-contract gap: ${gaps.join(', ')}`, gaps };
}
try {
const copyRaw = t.copy(facts);
const copy = render(copyRaw, facts);
const cardModel = t.card(facts);
// The card's own strings pass the same gate -- a caption and a card cannot
// disagree if both are rendered from one fact object under one rule.
const card = {
...cardModel,
lines: (cardModel.lines || []).map((l) => (typeof l === 'string'
? render(l, facts)
: { ...l, text: render(l.text, facts) })),
title: cardModel.title ? render(cardModel.title, facts) : null,
subtitle: cardModel.subtitle ? render(cardModel.subtitle, facts) : null,
};
return { ok: true, id, copy, card, facts };
} catch (e) {
// A refusal is a skip, never a degraded post.
return { ok: false, id, skipped: true, reason: e.message };
}
}
/** Generate every registered template; skips are reported, never hidden. */
async function generateAll(deps = {}) {
const out = [];
for (const t of listTemplates()) out.push(await generate(t.id, deps));
return out;
}
module.exports = {
registerTemplate, listTemplates, getTemplate, generate, generateAll,
render, tokensIn, contractGaps, isPresent, pathValue,
};
@@ -0,0 +1,73 @@
'use strict';
/**
* TEMPLATE 2 — THE HONESTY FLEX.
*
* The differentiator, and the one that would be worthless if it were padded.
* It publishes the REAL grade distribution: how many props were graded, how few
* cleared the ceiling, and the fact that A is unissuable because no band of this
* model has ever earned one.
*
* Every competitor's card is all A's. Ours says most of tonight is a base-rate
* read — and that claim is only impressive if it is exactly true, so the numbers
* come from servedGrade's own bands rather than from a marketing sentence.
*/
const { knownNumber } = require('../../../utils/known');
module.exports = {
id: 'honesty_flex',
sport: 'mlb',
label: 'The Honesty Flex',
requires: ['graded', 'ceiling_letter', 'ceiling_realized', 'base_rate', 'date'],
async pull(deps) {
const g = await deps.gradeDistribution(); // injected, read-only
const sg = deps.servedGrade || require('../../model/servedGrade');
const total = knownNumber(g && g.total);
const top = (g && g.by_letter && (g.by_letter['B+'] || 0)) || 0;
const flat = (g && g.not_separable) || 0;
const ceiling = sg.BANDS[0];
return {
date: deps.date,
graded: total,
top_count: top,
top_pct: total ? Math.round((top / total) * 100) : null,
flat_count: flat,
flat_pct: total ? Math.round((flat / total) * 100) : null,
ceiling_letter: ceiling.letter,
ceiling_realized: Math.round(ceiling.realized * 100),
base_rate: Math.round(sg.BASE_RATE * 100),
unissuable: sg.UNISSUABLE.join(', '),
};
},
copy: () => `WE GRADED {graded} PROPS TONIGHT. {top_count} CLEARED {ceiling_letter}.
That's {top_pct}%. The other {flat_pct}% we can't separate from the baseline, and we say so on the card instead of calling them leans.
Our ceiling is {ceiling_letter} — those reads land about {ceiling_realized}% against a {base_rate}% baseline. We do not issue {unissuable}. No band of this model has ever hit at a rate that would justify one.
Everybody else's card is all A's. Ask them what their A actually hits.`,
card: () => ({
kind: 'flex',
title: 'TONIGHT, HONESTLY',
subtitle: '{date}',
lines: [
{ text: '{graded} graded', style: 'lead' },
{ text: '{top_count} cleared {ceiling_letter} — {top_pct}%', style: 'stat' },
{ text: '{flat_pct}% we cannot separate from baseline', style: 'dim' },
{ text: '', style: 'rule' },
{ text: 'Ceiling: {ceiling_letter} · lands {ceiling_realized}%', style: 'body' },
{ text: 'Baseline: {base_rate}%', style: 'body' },
{ text: 'We do not issue {unissuable}.', style: 'body' },
],
footer: 'Grades are earned from realized outcomes, not issued on confidence.',
}),
absent: () => ({
copy: 'No slate graded tonight. Nothing to show, so nothing shown.',
card: { kind: 'absence', title: 'NO SLATE TONIGHT', subtitle: 'NOTHING TO PAD', lines: [{ text: 'No props graded. An empty board is an honest board.', style: 'body' }], footer: 'We post the count even when the count is zero.' },
}),
};
@@ -0,0 +1,94 @@
'use strict';
/**
* TEMPLATE 1 — HOT HITTERS.
*
* Reads the REPAIRED full-season log, not a ten-game slice. "Hot" here means a
* recent rate measured against that hitter's own season rate — which is only a
* meaningful comparison now that the season rate is a season.
*/
const { knownNumber } = require('../../../utils/known');
/** A hitter must have this much history before we call him anything. */
const MIN_SEASON_GAMES = 20;
const RECENT = 10;
module.exports = {
id: 'hot_hitters',
sport: 'mlb',
label: 'Hot Hitters',
requires: ['count', 'hitters', 'window', 'date'],
async pull(deps) {
const rows = await deps.hitterForm(); // injected, read-only
// ── EMPTY POOL IS NOT AN HONEST ABSENCE ────────────────────────────────
// The first run of this template emitted "no hitter is meaningfully hot"
// while the real cause was a source holding fewer than 20 games for EVERY
// player. That reads as a considered editorial judgement and is actually a
// broken pull -- the exact failure class that has bitten this codebase four
// times tonight, here wearing the costume of our own honesty copy.
//
// So the two are separated: no candidates at all is a SKIP with a reason;
// candidates present but none hot is the honest absence.
const candidates = (rows || []).length;
const usable = (rows || []).filter((r) =>
knownNumber(r.season_games) !== null && r.season_games >= MIN_SEASON_GAMES
&& knownNumber(r.recent_rate) !== null && knownNumber(r.season_rate) !== null);
const hot = usable
.map((r) => ({ ...r, lift: r.recent_rate - r.season_rate }))
.filter((r) => r.lift > 0)
.sort((a, b) => b.lift - a.lift)
.slice(0, 5);
return {
date: deps.date,
window: RECENT,
candidates,
qualified: usable.length,
count: hot.length || null, // zero hot hitters is an ABSENT list, not "0 hot hitters"
hitters: hot.length ? hot : null,
list: hot.map((h, i) =>
`${i + 1}. ${h.name}${Math.round(h.recent_rate * 100)}% last ${RECENT}, ${Math.round(h.season_rate * 100)}% season`).join('\n'),
top_name: hot[0] ? hot[0].name : null,
top_recent: hot[0] ? Math.round(hot[0].recent_rate * 100) : null,
top_season: hot[0] ? Math.round(hot[0].season_rate * 100) : null,
};
},
copy: () => `WHO'S ACTUALLY HOT — {date}
{top_name} is hitting {top_recent}% over his last {window}. His season number is {top_season}%.
That gap is the whole point. Everybody else is guessing at it.
{list}
Measured off full season logs, not a ten-game window that flatters whoever ran hot last week.`,
card: () => ({
kind: 'list',
title: "WHO'S ACTUALLY HOT",
subtitle: 'LAST {window} vs SEASON · {date}',
lines: [
{ text: '{top_name}', style: 'lead' },
{ text: '{top_recent}% last {window} · {top_season}% season', style: 'stat' },
{ text: '', style: 'rule' },
{ text: '{list}', style: 'body' },
],
footer: 'Rates measured from full season game logs.',
}),
absent: (gaps, facts) => {
// Only speak if there was actually a pool to judge.
if (!facts || !facts.qualified) {
return {
copy: null,
card: null,
skip: `no qualified hitters in the pool (candidates=${(facts && facts.candidates) || 0}, qualified=${(facts && facts.qualified) || 0}) — source problem, not a quiet night`,
};
}
return {
copy: "No hitter is meaningfully hot tonight.\n\nWe could dress up a middling week as a streak. We don't.",
card: { kind: 'absence', title: 'NOTHING HOT TONIGHT', subtitle: 'AND WE WILL SAY SO', lines: [{ text: 'No hitter cleared his own season rate by enough to name.', style: 'body' }], footer: 'An empty list is a real answer.' },
};
},
};
@@ -0,0 +1,65 @@
'use strict';
/**
* TEMPLATE 3 — STREAK LIST.
*
* A streak is only a streak if every game in it settled. This reads the ledger's
* SETTLED outcomes, so a run cannot be extended by a game that is still pending
* or was voided for a DNP — the two ways a streak list quietly inflates.
*
* The structure is sport-agnostic: it takes settled outcome rows and a noun.
* MLB hit streaks today; the same shape carries TD streaks or made-three streaks
* the moment those sports have settled outcomes.
*/
const MIN_STREAK = 3;
module.exports = {
id: 'streak_list',
sport: 'mlb',
label: 'Active Streaks',
requires: ['streaks', 'count', 'noun', 'date'],
async pull(deps) {
const rows = await deps.settledStreaks(); // injected, read-only
const live = (rows || [])
.filter((r) => Number(r.streak) >= MIN_STREAK && r.verified_from_settled === true)
.sort((a, b) => b.streak - a.streak)
.slice(0, 6);
return {
date: deps.date,
noun: deps.noun || 'game hit streak',
count: live.length || null,
streaks: live.length ? live : null,
list: live.map((s) => `${s.name}${s.streak} straight`).join('\n'),
top_name: live[0] ? live[0].name : null,
top_streak: live[0] ? live[0].streak : null,
};
},
copy: () => `ACTIVE STREAKS — {date}
{top_name} has a {top_streak}-{noun}. Live, verified off settled results only.
{list}
Every game in these ran to a final. We don't count a pending night to make a number look better.`,
card: () => ({
kind: 'list',
title: 'ACTIVE STREAKS',
subtitle: 'VERIFIED FROM SETTLED RESULTS · {date}',
lines: [
{ text: '{top_name}', style: 'lead' },
{ text: '{top_streak} straight', style: 'stat' },
{ text: '', style: 'rule' },
{ text: '{list}', style: 'body' },
],
footer: 'Settled games only. Pending and voided nights do not count.',
}),
absent: () => ({
copy: 'No live streaks worth naming tonight.\n\nWe could lower the bar to three-of-four. We keep the bar.',
card: { kind: 'absence', title: 'NO LIVE STREAKS', subtitle: 'THE BAR STAYS WHERE IT IS', lines: [{ text: 'Nothing running long enough to name.', style: 'body' }], footer: 'We do not lower a threshold to fill a card.' },
}),
};
@@ -574,6 +574,51 @@ async function analyzeViaEngine1(rawProp = {}) {
? (Number.isFinite(pOver) ? 1 - pOver : null)
: (Number.isFinite(pOver) ? pOver : null);
if (pWin != null) legacy.p_win = Math.round(pWin * 1000) / 1000;
// ── THE SERVED GRADE ────────────────────────────────────────────────
// Derived from the forecast, not from engine1's additive factor index.
// Measured on 3,417 settled props, that index carried 0.16x the information
// of the p_win printed beside it, and its A grade hit 0.500 while its F hit
// 0.535 -- the top letter did worse than the bottom. Concretely: the same
// hitter's 0.95 over and 0.05 under both graded C.
//
// engine1.grade is PRESERVED on the payload as `engine_grade` so nothing
// downstream breaks and the two remain comparable, but `served_grade` is
// what a user should see.
try {
const sg = require('../model/servedGrade');
const served = sg.gradeFor({
p_win: legacy.p_win,
refused: legacy.refused || legacy.insufficient_data,
refusal_reason: legacy.refusal_reason,
factor_adjustment: factorTrace,
});
legacy.served_grade = served;
// ── TOTAL CUTOVER, NOT A PARALLEL GRADE ────────────────────────────
// `legacy.grade` IS the honest letter now. Attaching served_grade beside
// the old one and leaving `grade` alone would have repeated the exact
// failure diagnosed for gradeBands: built, correct, and read by nobody.
// Fourteen-plus consumers (scan route, dashboard, parlay, newsletter,
// desk, content templates, retention) all read `.grade`, so overwriting
// it here cuts every surface over at once instead of editing each.
//
// The original index is preserved as `engine_grade` for comparison and is
// read by no serving code.
legacy.engine_grade = legacy.grade;
if (served.letter) {
legacy.grade = served.letter;
// Confidence must not contradict the letter. It previously came from a
// grade-band midpoint of the OLD letter, so leaving it would have paired
// a B+ with a C's confidence. Both now derive from p_win -- kept on the
// existing 0-100 scale, since every consumer and every stored row uses it.
legacy.confidence = Math.round(legacy.p_win * 100);
legacy.confidence_basis = 'p_win';
} else {
// A refusal has no letter, and consumers test `!grade` for exactly that.
legacy.grade = null;
}
} catch { /* the grade surface must never break the read */ }
if (factorTrace) {
legacy.factor_adjustment = factorTrace;
legacy.p_win_prefactor = Math.round((dir === 'under' ? 1 - factorTrace.p_before : factorTrace.p_before) * 1000) / 1000;
+23 -3
View File
@@ -29,6 +29,16 @@ const { getTeamInjuries } = require('./injuryParser');
const { getLineMovement } = require('./lineMovement');
const gameLogs = require('./gameLogService');
/**
* How many games to request when asking for a player's history.
*
* A basketball season is 82 games; this is deliberately past it so a request
* never truncates a season into a "season rate". The window-bug class has cost
* this codebase four MLB paths and two basketball ones -- every instance was a
* fixed N standing in for a season.
*/
const SEASON_LOG_DEPTH = 100;
const VECTOR_TTL_SECONDS = 120;
function avg(values) {
@@ -225,7 +235,13 @@ function nbaGameLogFeatures(res, statType) {
if (vals.length) {
const m5 = avg(vals.slice(0, 5)); // most-recent first
const m10 = avg(vals.slice(0, 10));
const m20 = avg(vals.slice(0, 20));
// SEASON, NOT TWENTY. `l20_avg` is the season per-game reference
// `projectionFor` reads, and slicing to 20 made it a twenty-game average
// wearing a season label -- the same defect as the MLB last10 bug
// (929fd81) and the MLB l20 bug (494c83c). The ESPN adapter now returns the
// full season log, so this takes all of it. Name kept: `l20_avg` is read in
// many places, and renaming it is a separate, wider change.
const m20 = avg(vals);
const s10 = stddev(vals.slice(0, 10));
if (m5 != null) out.l5_avg = m5;
if (m10 != null) out.l10_avg = m10;
@@ -306,7 +322,11 @@ async function getStatRows(playerName, sport, statType) {
// NBA/WNBA — Python service first (it's the richer source when it's up),
// then the FREE ESPN per-athlete gamelog. Same order as gameLogFeatures.
const pyLogs = await gameLogs.getGameLogs(playerName, sp, 20);
// SEASON DEPTH, NOT TWENTY. The count is a request PARAMETER, so asking for
// a season costs the same single call -- no extra request, no extra quota.
// The Python service is offline in prod, so this cannot be verified live;
// it is fixed now so basketball never launches on a twenty-game base rate.
const pyLogs = await gameLogs.getGameLogs(playerName, sp, SEASON_LOG_DEPTH);
if (Array.isArray(pyLogs) && pyLogs.length) {
// Python rows are already flat + most-recent-first.
for (const r of pyLogs) push(r && r.date, statFromGameLog(r, statType));
@@ -354,7 +374,7 @@ async function gameLogFeatures(playerName, sport, statType) {
}
}
const logs = await gameLogs.getGameLogs(playerName, sport, 20);
const logs = await gameLogs.getGameLogs(playerName, sport, SEASON_LOG_DEPTH);
// Wave 0 — NBA/WNBA grade unlock. The Python nba_api service (gameLogService)
// is offline in prod, so `logs` is null and this branch used to return {} →
+11 -1
View File
@@ -26,7 +26,17 @@ function pythonPath(sport) {
}
}
async function getGameLogs(playerName, sport, count = 20) {
/**
* DEFAULT IS A SEASON, NOT TWENTY.
*
* A default of 20 meant any caller that omitted the count silently received a
* twenty-game window -- and every consumer of this function treats what it
* returns as the player's history. The window-bug class has cost six paths
* across two sports; a permissive default is how a seventh would arrive.
*/
const DEFAULT_LOG_DEPTH = 100;
async function getGameLogs(playerName, sport, count = DEFAULT_LOG_DEPTH) {
const path = pythonPath(sport);
if (!path) return null;
const cacheKey = `gamelogs:${sport}:${playerName}:${count}`;
+142
View File
@@ -0,0 +1,142 @@
'use strict';
/**
* servedGrade — the letter a user sees, derived from the forecast.
*
* ── WHY THIS EXISTS ──────────────────────────────────────────────────────
* The letter being served came from engine1's additive factor index, computed
* independently of `p_win`. Measured on 3,417 settled props:
*
* grade n realized mean p_win
* A 8 0.500 0.647 <- the TOP grade did worst
* B 985 0.640 0.700
* C 1,695 0.602 0.676
* D 303 0.558 0.604
* F 426 0.535 0.588
*
* letter resolution 0.00116 (0.48% of variance)
* p_win resolution 0.00715 (2.98%)
*
* The letter carried ONE SIXTH the information of the number printed beside it,
* and its best grade hit worse than its worst. So the grade is derived from
* `p_win` here instead — not because p_win is good (2.98% is not good) but
* because shipping the weaker of two available signals as the headline is
* indefensible.
*
* ── NO MANUFACTURED A ────────────────────────────────────────────────────
* S91 established the honest ceiling: once the numbers are truthful this model
* has no 80%-plus reads. The realized rate PLATEAUS around 0.65-0.68 from p_win
* 0.70 upward — the 0.9+ bucket does no better than the 0.8 bucket.
*
* So A and A+ are NOT ISSUABLE. Not "rare" — structurally absent, because no
* band of this forecast has ever realized a rate that would justify one. A test
* asserts that no input produces an A. When resolution improves enough for a
* band to earn it, the ceiling is raised deliberately and visibly, not by a
* threshold quietly drifting.
*
* ── EVERY LETTER CARRIES ITS OWN MEANING ─────────────────────────────────
* The band's realized rate travels with the grade, so the surface can state what
* a B actually means rather than implying a spread the model does not have.
*/
const { knownNumber } = require('../../utils/known');
/**
* Bands over p_win, anchored on MEASURED realized rates (3,417 settled props,
* pooled across the four batter stats).
*
* The realized column is what this band has actually done — not a target, not a
* projection. `separates` says whether the band's realized rate is
* distinguishable from the pooled base rate; today only the extremes are.
*/
const BASE_RATE = 0.6005;
const BANDS = Object.freeze([
{ letter: 'B+', min: 0.780, realized: 0.663, separates: true,
meaning: 'the strongest read this model produces — realized about 66%' },
{ letter: 'B', min: 0.700, realized: 0.646, separates: true,
meaning: 'above this profile\'s base rate — realized about 65%' },
{ letter: 'C+', min: 0.640, realized: 0.615, separates: false,
meaning: 'slightly above base rate, not distinguishable from it' },
{ letter: 'C', min: 0.560, realized: 0.589, separates: false,
meaning: 'a base-rate read — the model sees nothing that separates this' },
{ letter: 'C-', min: 0.480, realized: 0.548, separates: false,
meaning: 'at or below base rate' },
{ letter: 'D', min: 0.350, realized: 0.512, separates: true,
meaning: 'below base rate — the model reads this as weak' },
{ letter: 'F', min: 0.000, realized: 0.447, separates: true,
meaning: 'well below base rate' },
]);
/** Letters this forecast cannot justify. Absent by construction, not by rarity. */
const UNISSUABLE = Object.freeze(['A+', 'A', 'A-']);
/**
* The grade for one served prop.
*
* @param {object} prop { p_win, refused, refusal_reason, factor_adjustment }
* @returns {object} always a renderable state — never null, never blank.
*/
function gradeFor(prop = {}) {
// ── REFUSAL IS A REAL STATE, NOT A BLANK ──
if (prop.refused || prop.insufficient_data) {
return {
letter: null,
state: 'refused',
label: 'NO READ',
meaning: prop.refusal_reason === 'juiced_no_edge'
? 'the book has priced the vig past any edge on this side'
: 'not enough history to call this one',
basis: 'refusal',
separates: false,
};
}
const p = knownNumber(prop.p_win);
if (p === null) {
return {
letter: null,
state: 'no_forecast',
label: 'NO READ',
meaning: 'no forecast could be produced for this prop',
basis: 'absent',
separates: false,
};
}
const band = BANDS.find((b) => p >= b.min) || BANDS[BANDS.length - 1];
const factored = Array.isArray(prop.factor_adjustment && prop.factor_adjustment.applied)
&& prop.factor_adjustment.applied.length > 0;
return {
letter: band.letter,
state: 'graded',
label: band.letter,
meaning: band.meaning,
band_realized_rate: band.realized,
separates_from_base_rate: band.separates,
base_rate: BASE_RATE,
// What the letter was computed from, stated so the surface cannot imply more.
basis: factored ? 'forecast_plus_matchup_factors' : 'forecast_only',
factors_applied: factored ? prop.factor_adjustment.applied.map((a) => a.factor) : [],
// Calibration is withdrawn; nothing here rides on a calibrated number.
calibrated: false,
};
}
/**
* The scale legend — the ceiling stated as a POSITION, not left as an absence.
*
* Without this a user is left to wonder why they never see an A, and the most
* natural guess ("the model is being coy" or "the slate is bad tonight") is
* wrong. The honest answer is that top grades are earned from realized outcomes
* and this forecast has not earned one.
*/
const SCALE_LEGEND = Object.freeze({
headline: 'Grades are earned from realized outcomes, not issued on confidence.',
ceiling: `Our honest ceiling right now is a strong B+ — those reads have landed about ${Math.round(BANDS[0].realized * 100)}% of the time against a ${Math.round(BASE_RATE * 100)}% baseline.`,
no_a: 'We do not issue A grades. No band of this model has hit at a rate that would justify one, and we would rather show you the ceiling than invent a letter above it.',
base_rate_note: 'Grades marked "base-rate read" are ones we cannot separate from the baseline. That is most of any slate, and saying so is the point.',
when_a_returns: 'If the model earns an A, this legend changes and we will say why.',
});
module.exports = { gradeFor, BANDS, UNISSUABLE, BASE_RATE, SCALE_LEGEND };
+169
View File
@@ -0,0 +1,169 @@
'use strict';
/**
* E10 — THE VYNDR REPORT, the designed issue template.
*
* The gap audit had this 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 this is a template over that builder, not a second
* report — the same compose-don't-fork call made for the movement strip.
*
* ── THE SPEC'S STRUCTURAL LAW ────────────────────────────────────────────
* "Hybrid: dark billboard header that survives every client, light paper body
* Gmail can't wreck. 600px, stacked, no webfont dependence."
*
* That is an engineering constraint, not a look. Gmail strips `<style>` blocks,
* Outlook ignores flexbox, and a dark body renders as a black rectangle in
* several clients. Hence: tables, inline styles only, 600px fixed, system-font
* stacks, and no image required to read the issue.
*
* Its content law is equally short: *"One email per slate day. Top read, what
* changed, the record. Nothing else."*
*
* ── FACT-CONTRACTED ──────────────────────────────────────────────────────
* Same discipline as the content engine: a section whose data is absent is
* OMITTED, never filled. There is no code path producing a placeholder figure,
* and the designer's sample values (Nabers 1,120.5, Nº 128, 9-4) are a spec for
* what a live issue renders — never content to paste.
*/
const { knownNumber } = require('../../utils/known');
/** Design tokens, inlined because email clients drop stylesheets. */
const T = Object.freeze({
billboard: '#06060B', // dark header — survives every client
paper: '#F7F6F2', // light body — Gmail cannot wreck it
ink: '#1A1A22',
greenOnDark: '#00D4A0',
greenOnPaper: '#00A57D', // the green SHIFTS on paper for contrast
rule: '#D8D5CC',
dim: '#6B6B76',
mono: "'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace",
sans: "-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif",
});
const WIDTH = 600;
const esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** Present means renderable. Zero is present; empty and null are not. */
const has = (v) => v !== null && v !== undefined && !(typeof v === 'string' && v.trim() === '')
&& !(Array.isArray(v) && v.length === 0) && !(typeof v === 'number' && !Number.isFinite(v));
/**
* The movement line — E1's law expressed in email-safe text.
*
* A `<canvas>` or SVG strip cannot be relied on in email, so the primitive's
* RULE travels even though its rendering cannot: green only when the move
* favours the read, and a flat market says FLAT rather than showing nothing.
*/
function movementText(m) {
if (!m || !has(m.from) || !has(m.to)) return null;
const from = knownNumber(m.from);
const to = knownNumber(m.to);
if (from === null || to === null) return null;
if (from === to) return { text: `FLAT${has(m.days) ? ` · ${m.days}D` : ''}`, colour: T.dim };
const favours = m.dir === 'toward';
return {
text: `${from}${to}`,
colour: favours ? T.greenOnPaper : '#B0762A', // amber-on-paper for against
};
}
const row = (inner) => `<tr><td style="padding:0 28px;">${inner}</td></tr>`;
/**
* @param {object} issue
* number, date_label, read_time
* top_read { grade, subject, line_text, movement:{from,to,dir,days}, model, best_book, note }
* changed [{ time, tag, text }]
* record { line, hit, miss, pct|null, note|null }
* honesty { graded, cleared_ceiling, ceiling_letter, ceiling_realized, base_rate, unissuable }
* @returns {object} { html, text, omitted[] } — omitted names what had no data.
*/
function renderIssue(issue = {}) {
const omitted = [];
const parts = [];
// ── DARK BILLBOARD HEADER ──
parts.push(`<tr><td style="background:${T.billboard};padding:26px 28px;">
<div style="font-family:${T.mono};font-size:20px;font-weight:800;letter-spacing:2px;color:#FFFFFF;">VYND<span style="color:${T.greenOnDark};">R</span></div>
<div style="font-family:${T.mono};font-size:11px;letter-spacing:2px;color:#8A8A96;padding-top:8px;">
THE REPORT${has(issue.number) ? ` &middot; N&ordm; ${esc(issue.number)}` : ''}${has(issue.date_label) ? ` &middot; ${esc(issue.date_label)}` : ''}${has(issue.read_time) ? ` &middot; READ TIME ${esc(issue.read_time)}` : ''}
</div></td></tr>`);
// ── TOP READ OF THE DAY ──
const tr = issue.top_read;
if (tr && has(tr.subject) && has(tr.grade)) {
const mv = movementText(tr.movement);
parts.push(row(`<div style="padding:22px 0 0;">
<div style="font-family:${T.mono};font-size:10px;letter-spacing:2px;color:${T.dim};">TOP READ OF THE DAY</div>
<div style="font-family:${T.sans};font-size:19px;font-weight:700;color:${T.ink};padding-top:10px;">
<span style="font-family:${T.mono};color:${T.greenOnPaper};font-weight:800;">${esc(tr.grade)}</span>
&nbsp;${esc(tr.subject)}${has(tr.line_text) ? ` <span style="font-weight:400;">${esc(tr.line_text)}</span>` : ''}
</div>
${mv || has(tr.model) || has(tr.best_book) ? `<div style="font-family:${T.mono};font-size:12px;color:${T.dim};padding-top:8px;">
${mv ? `<span style="color:${mv.colour};font-weight:700;">${esc(mv.text)}</span>` : ''}
${has(tr.model) ? ` &middot; VYNDR ${esc(tr.model)}` : ''}
${has(tr.best_book) ? ` &middot; BEST: ${esc(tr.best_book)}` : ''}
</div>` : ''}
${has(tr.note) ? `<p style="font-family:${T.sans};font-size:14px;line-height:1.6;color:${T.ink};padding-top:12px;margin:0;">${esc(tr.note)}</p>` : ''}
</div>`));
} else omitted.push('top_read');
// ── WHAT CHANGED ──
if (Array.isArray(issue.changed) && issue.changed.length) {
const rows = issue.changed.map((c) => `<div style="padding:7px 0;border-top:1px solid ${T.rule};">
<span style="font-family:${T.mono};font-size:11px;color:${T.dim};">${esc(c.time)}</span>
<span style="font-family:${T.mono};font-size:11px;font-weight:800;color:${T.ink};">&nbsp;${esc(String(c.tag).toUpperCase())}</span>
<span style="font-family:${T.sans};font-size:13px;color:${T.ink};">&nbsp;${esc(c.text)}</span></div>`).join('');
parts.push(row(`<div style="padding:26px 0 0;">
<div style="font-family:${T.mono};font-size:10px;letter-spacing:2px;color:${T.dim};padding-bottom:6px;">WHAT CHANGED</div>${rows}</div>`));
} else omitted.push('changed');
// ── THE RECORD ── (dark band: survives every client, per spec)
const rec = issue.record;
if (rec && has(rec.line)) {
parts.push(`<tr><td style="background:${T.billboard};padding:18px 28px;margin-top:20px;">
<div style="font-family:${T.mono};font-size:10px;letter-spacing:2px;color:#8A8A96;">THE RECORD</div>
<div style="font-family:${T.mono};font-size:16px;font-weight:800;color:#FFFFFF;padding-top:6px;">${esc(rec.line)}</div>
${has(rec.note) ? `<div style="font-family:${T.mono};font-size:11px;color:#8A8A96;padding-top:6px;">${esc(rec.note)}</div>` : ''}
</td></tr>`);
} else omitted.push('record');
// ── THE HONESTY BLOCK ── real figures or nothing.
const h = issue.honesty;
if (h && has(h.graded) && has(h.ceiling_letter)) {
parts.push(row(`<div style="padding:22px 0 0;">
<div style="font-family:${T.mono};font-size:10px;letter-spacing:2px;color:${T.dim};">HONESTLY</div>
<p style="font-family:${T.sans};font-size:13px;line-height:1.65;color:${T.ink};padding-top:8px;margin:0;">
We graded ${esc(h.graded)} props${has(h.cleared_ceiling) ? ` and ${esc(h.cleared_ceiling)} cleared ${esc(h.ceiling_letter)}` : ''}.
${has(h.ceiling_realized) && has(h.base_rate) ? `Those reads land about ${esc(h.ceiling_realized)}% against a ${esc(h.base_rate)}% baseline. ` : ''}
${has(h.unissuable) ? `We do not issue ${esc(h.unissuable)} — no band of this model has hit at a rate that would justify one.` : ''}
</p></div>`));
} else omitted.push('honesty');
const html = `<!doctype html><html><body style="margin:0;padding:0;background:${T.paper};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${T.paper};">
<tr><td align="center">
<table role="presentation" width="${WIDTH}" cellpadding="0" cellspacing="0" style="width:${WIDTH}px;max-width:${WIDTH}px;background:${T.paper};">
${parts.join('\n')}
<tr><td style="padding:22px 28px 30px;">
<div style="font-family:${T.mono};font-size:10px;color:${T.dim};line-height:1.7;">
One email per slate day. Top read, what changed, the record. Nothing else.<br>
No outcome is promised. 21+. <a href="{{ UnsubscribeURL }}" style="color:${T.dim};">Unsubscribe</a>.
</div></td></tr>
</table></td></tr></table></body></html>`;
const text = [
`THE REPORT${has(issue.number) ? ` No ${issue.number}` : ''}${has(issue.date_label) ? `${issue.date_label}` : ''}`,
tr && has(tr.subject) ? `\nTOP READ: ${tr.grade} ${tr.subject}${has(tr.line_text) ? ` ${tr.line_text}` : ''}` : '',
tr && has(tr.note) ? tr.note : '',
rec && has(rec.line) ? `\nRECORD: ${rec.line}` : '',
'\nOne email per slate day. Top read, what changed, the record. Nothing else.',
].filter(Boolean).join('\n');
return { html, text, omitted, width: WIDTH };
}
module.exports = { renderIssue, movementText, T, WIDTH, has };
+21 -6
View File
@@ -1,3 +1,13 @@
/**
* NOTE ON `engine_grade` (2026-08-07 grade cutover).
*
* The user-facing `grade` now derives from `p_win`, not engine1's additive
* factor index -- that index carried 0.16x the information of p_win on 3,417
* settled props and its A hit worse than its F. The engine's OWN decision
* (graded vs suppressed) is preserved as `engine_grade`, so behaviour
* assertions read that; suppression assertions still read `grade`, since a
* suppressed prop has no letter either way.
*/
// Fix 2 (Session 7f) — verifies the end-to-end shape from
// computeFeaturesForProp → engine1 → adapter → concrete reasoning.
@@ -46,8 +56,13 @@ describe('analyzeViaEngine1 — happy path', () => {
});
// Adapter-collapsed grade.
expect(out.grade).toBe('A');
expect(out.confidence).toBe(78);
expect(out.engine_grade).toBe('A');
// Confidence now derives from p_win (0.95 -> 95), not from a grade-band
// midpoint of the old letter. The old 78 was the midpoint for engine1's "A"
// -- a number that carried no information beyond the letter it was looked up
// from, and which would now contradict the served B+.
expect(out.confidence).toBe(95);
expect(out.confidence_basis).toBe('p_win');
expect(out.player).toBe('Jalen Brunson');
expect(out.stat_type).toBe('points');
expect(out.line).toBe(25.5);
@@ -104,7 +119,7 @@ describe('analyzeViaEngine1 — happy path', () => {
player: 'P', stat_type: 'points', line: 24.5, direction: 'over', sport: 'nba',
});
expect(out.grade).toBe('D');
expect(out.engine_grade).toBe('D');
expect(out.reasoning.summary).toContain('Playing on the road');
expect(out.reasoning.summary).toContain('OKC');
expect(out.reasoning.summary).toContain('top-tier defense');
@@ -185,7 +200,7 @@ describe('analyzeViaEngine1 — graceful degradation', () => {
player: 'P', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
expect(out.grade).toBe('B');
expect(out.engine_grade).toBe('B');
expect(out.insufficient_data).toBeUndefined();
expect(out.projection).toBe(28.4); // the REAL model reference, never the line
});
@@ -205,7 +220,7 @@ describe('analyzeViaEngine1 — interface verifications', () => {
player: 'X', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
expect(out).toBeDefined();
expect(out.grade).toBeDefined();
expect(out.engine_grade).toBeDefined();
});
test('every legacy field DemoScan reads is present', async () => {
@@ -222,7 +237,7 @@ describe('analyzeViaEngine1 — interface verifications', () => {
});
// DemoScan reads: grade, confidence, reasoning.summary,
// kill_conditions_triggered[].code, edge_pct, line, player, stat_type.
expect(out.grade).toBeDefined();
expect(out.engine_grade).toBeDefined();
expect(typeof out.confidence).toBe('number');
expect(typeof out.reasoning.summary).toBe('string');
expect(Array.isArray(out.kill_conditions_triggered)).toBe(true);
+106
View File
@@ -0,0 +1,106 @@
'use strict';
/**
* THE WINDOW GUARD — a fixed N must never stand in for a season.
*
* This class has now cost six paths across two sports:
*
* MLB getStatRows res.last10 -> a 10-game "season rate"
* MLB mlbGameLogFeatures res.last10 -> l20_avg was 10 games
* NBA espnStatsAdapter rows.slice(0, 20) -> every downstream rate capped at 20
* NBA nbaGameLogFeatures vals.slice(0, 20) -> l20_avg was 20 games
* NBA getStatRows (python) getGameLogs(.., 20) -> a 20-game base rate
* NBA gameLogFeatures getGameLogs(.., 20) -> same
*
* Every one looked correct in isolation. `slice(0, 20)` is unremarkable code;
* what made it a defect was the QUESTION it was answering — "what is this
* player's season rate?" — and no test could see that mismatch, because the
* value it produced was always a plausible number.
*
* So this asserts the property directly on the source: the base-rate and
* season-reference paths must not narrow to a fixed window. Basketball is
* OFFLINE, which is exactly why it is guarded now — a regression there would
* otherwise surface only when it had already served grades.
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
/** Body of a named function, for targeted assertions. */
function fnBody(src, name) {
const i = src.indexOf(`function ${name}(`);
if (i < 0) return null;
let depth = 0; let started = false;
for (let j = i; j < src.length; j += 1) {
if (src[j] === '{') { depth += 1; started = true; }
else if (src[j] === '}') { depth -= 1; if (started && depth === 0) return src.slice(i, j + 1); }
}
return null;
}
describe('no fixed window may stand in for a season', () => {
const featureCache = read('src/services/intelligence/featureCache.js');
it('the season log depth is a named constant past a full season', () => {
// A literal 20 buried in a call is how every instance of this bug looked.
expect(featureCache).toMatch(/const SEASON_LOG_DEPTH = (\d+);/);
const depth = Number(/const SEASON_LOG_DEPTH = (\d+);/.exec(featureCache)[1]);
expect(depth).toBeGreaterThanOrEqual(82); // a basketball season
});
it('no game-log request asks for a hardcoded small count', () => {
const calls = [...featureCache.matchAll(/getGameLogs\([^)]*\)/g)].map((m) => m[0]);
expect(calls.length).toBeGreaterThan(0);
for (const c of calls) {
const literal = /,\s*(\d+)\s*\)/.exec(c);
if (literal) expect(Number(literal[1])).toBeGreaterThanOrEqual(82);
}
});
it('the BASKETBALL season reference (l20_avg) is not a 20-game slice', () => {
const body = fnBody(featureCache, 'nbaGameLogFeatures');
expect(body).not.toBeNull();
// The exact pre-fix line. It read as ordinary code and was the defect.
expect(body).not.toMatch(/m20\s*=\s*avg\(vals\.slice\(0,\s*20\)\)/);
expect(body).toMatch(/m20\s*=\s*avg\(vals\)/);
});
it('the MLB season reference is a real season aggregate, not a slice', () => {
const body = fnBody(featureCache, 'mlbGameLogFeatures');
expect(body).not.toBeNull();
expect(body).toMatch(/l20_avg\s*=\s*seasonTotal\s*\/\s*games/);
});
it('the MLB base-rate path reads the full log, not last10', () => {
const body = fnBody(featureCache, 'getStatRows');
expect(body).toMatch(/res\.fullLog/);
});
it('the ESPN adapter does not cap the parsed game log', () => {
const adapter = read('src/services/adapters/espnStatsAdapter.js');
const body = fnBody(adapter, 'parseGameLog');
expect(body).not.toBeNull();
// `return rows.slice(0, 20)` capped every downstream basketball rate.
expect(body).not.toMatch(/return\s+rows\.slice\(/);
});
});
describe('the guard would have caught the real defects', () => {
it('flags the exact pre-fix basketball slice', () => {
const preFix = 'const m20 = avg(vals.slice(0, 20));';
expect(/m20\s*=\s*avg\(vals\.slice\(0,\s*20\)\)/.test(preFix)).toBe(true);
});
it('flags the exact pre-fix ESPN cap', () => {
expect(/return\s+rows\.slice\(/.test(' return rows.slice(0, 20);')).toBe(true);
});
it('flags a hardcoded small game-log request', () => {
const preFix = 'await gameLogs.getGameLogs(playerName, sp, 20);';
const literal = /,\s*(\d+)\s*\)/.exec(/getGameLogs\([^)]*\)/.exec(preFix)[0]);
expect(Number(literal[1])).toBeLessThan(82); // i.e. would fail the guard
});
});
+164
View File
@@ -0,0 +1,164 @@
'use strict';
/**
* THE TRUTH-LAW PROOF.
*
* The claim this engine makes is that a post cannot contain a number that was
* not pulled. These tests are that claim, made falsifiable. If any of them can
* be made to pass while a fabricated string escapes, the moat is decorative.
*/
const engine = require('../../src/services/content/contentEngine');
const { toSvg } = require('../../src/services/content/cardRenderer');
const base = {
id: 'test_tpl', sport: 'mlb', requires: ['n'],
pull: async () => ({ n: 3, name: 'Real Player' }),
copy: () => 'we graded {n} props',
card: () => ({ title: 'T', lines: [{ text: '{n} props', style: 'stat' }] }),
};
const reg = (over = {}) => engine.registerTemplate({ ...base, ...over, id: over.id || `t_${Math.random()}` });
describe('a template CANNOT render an unbacked claim', () => {
it('refuses a token with no pulled fact', async () => {
// The failure this prevents: a template author writes {edge} and the engine
// helpfully renders "undefined" or, worse, an empty string that reads fine.
const t = reg({ copy: () => 'edge is {edge_that_was_never_pulled}%' });
const out = await engine.generate(t.id, {});
expect(out.ok).toBe(false);
expect(out.skipped).toBe(true);
expect(out.reason).toMatch(/TRUTH LAW: unbacked token/);
});
it('refuses an unbacked token on the CARD too, not just the copy', async () => {
const t = reg({ card: () => ({ title: 'T', lines: [{ text: '{ghost_stat}', style: 'stat' }] }) });
const out = await engine.generate(t.id, {});
expect(out.ok).toBe(false);
expect(out.reason).toMatch(/unbacked token/);
});
it('render() throws directly — the gate is not bypassable by a caller', () => {
expect(() => engine.render('{nope}', { yes: 1 })).toThrow(/TRUTH LAW/);
});
it('a null fact is ABSENT, not rendered as "null"', async () => {
const t = reg({ pull: async () => ({ n: null }), copy: () => '{n} props' });
const out = await engine.generate(t.id, {});
expect(out.ok).toBe(false);
expect(String(out.copy || '')).not.toMatch(/null/);
});
it('an empty string is absent — a hole in a sentence is a lie by omission', () => {
expect(engine.isPresent('')).toBe(false);
expect(engine.isPresent(' ')).toBe(false);
});
it('ZERO is PRESENT — "0 cleared B+" is a real and important claim', () => {
// Treating 0 as missing is the Number(null) === 0 breach wearing its
// opposite coat, and it would silently delete our most honest post.
expect(engine.isPresent(0)).toBe(true);
expect(engine.render('{n} cleared', { n: 0 })).toBe('0 cleared');
});
it('NaN and Infinity are absent — they are arithmetic failures, not facts', () => {
expect(engine.isPresent(NaN)).toBe(false);
expect(engine.isPresent(Infinity)).toBe(false);
});
});
describe('the fact contract is checked BEFORE any string is built', () => {
it('a contract gap skips with the missing field named', async () => {
const t = reg({ requires: ['n', 'missing_field'] });
const out = await engine.generate(t.id, {});
expect(out.ok).toBe(false);
expect(out.reason).toMatch(/fact-contract gap: missing_field/);
});
it('a gap can emit an HONEST ABSENCE instead of nothing', async () => {
const t = reg({
requires: ['n', 'absent_thing'],
absent: () => ({ copy: 'nothing tonight, and we say so', card: { title: 'NONE' } }),
});
const out = await engine.generate(t.id, {});
expect(out.ok).toBe(true);
expect(out.honest_absence).toBe(true);
expect(out.copy).toMatch(/nothing tonight/);
});
it('a failing pull skips rather than rendering a half-post', async () => {
const t = reg({ pull: async () => { throw new Error('source down'); } });
const out = await engine.generate(t.id, {});
expect(out.ok).toBe(false);
expect(out.reason).toMatch(/pull failed: source down/);
});
});
describe('copy and card cannot disagree', () => {
it('both render from ONE fact object', async () => {
const t = reg({
pull: async () => ({ n: 7 }),
copy: () => 'we graded {n}',
card: () => ({ title: 'T', lines: [{ text: '{n} graded', style: 'stat' }] }),
});
const out = await engine.generate(t.id, {});
expect(out.copy).toMatch(/7/);
expect(out.card.lines[0].text).toMatch(/7/);
});
it('the card SVG contains the same pulled number', async () => {
const t = reg({ pull: async () => ({ n: 42 }), copy: () => '{n}', card: () => ({ title: 'T', lines: [{ text: '{n} graded', style: 'stat' }] }) });
const out = await engine.generate(t.id, {});
expect(toSvg(out.card)).toMatch(/42 graded/);
});
});
describe('the real templates obey the contract', () => {
const hot = require('../../src/services/content/templates/hotHitters');
const flex = require('../../src/services/content/templates/honestyFlex');
const streak = require('../../src/services/content/templates/streakList');
it.each([[hot], [flex], [streak]])('every token in %s is declarable', (t) => {
const tokens = new Set([
...engine.tokensIn(t.copy({})),
...engine.tokensIn(JSON.stringify(t.card({}))),
]);
expect(tokens.size).toBeGreaterThan(0);
// Each template must ship an absent-variant, or a thin night silently
// produces nothing and the flywheel stops without anyone noticing.
expect(typeof t.absent).toBe('function');
});
it('hot hitters refuses a hitter with too little history', async () => {
engine.registerTemplate(hot);
const out = await engine.generate('hot_hitters', {
date: '2026-08-07',
hitterForm: async () => ([{ name: 'Rookie', season_games: 4, recent_rate: 0.9, season_rate: 0.2 }]),
});
// 4 games is not a season, so nobody QUALIFIES -- which is a source problem,
// not a quiet night. It must SKIP rather than publish honest-absence copy.
expect(out.ok).toBe(false);
expect(out.reason).toMatch(/source problem, not a quiet night/);
});
it('hot hitters DOES emit honest absence when a real pool has nobody hot', async () => {
// The distinction that matters: candidates existed, were judged, none hot.
engine.registerTemplate(hot);
const out = await engine.generate('hot_hitters', {
date: '2026-08-07',
hitterForm: async () => Array.from({ length: 30 }, (_, i) => ({
name: `P${i}`, season_games: 90, recent_rate: 0.30, season_rate: 0.40,
})),
});
expect(out.honest_absence).toBe(true);
expect(out.copy).toMatch(/No hitter is meaningfully hot/);
});
it('streaks refuse a run not verified from settled results', async () => {
engine.registerTemplate(streak);
const out = await engine.generate('streak_list', {
date: '2026-08-07',
settledStreaks: async () => ([{ name: 'X', streak: 9, verified_from_settled: false }]),
});
expect(out.honest_absence).toBe(true);
});
});
+82
View File
@@ -0,0 +1,82 @@
'use strict';
/**
* WAVE D1 motion primitives.
*
* The property that matters most here is not how they look — it is that they
* never withhold content. A reveal primitive that hides a row until an observer
* fires will, on any browser or preference where the observer never fires, hide
* the row forever.
*/
const m = require('../../web/src/lib/motion');
describe('reduced motion is honoured, not softened', () => {
it('assumes reduced motion server-side', () => {
expect(m.prefersReducedMotion()).toBe(true);
});
it('nudge does nothing and returns a no-op cleanup', () => {
const el = { style: {} };
const stop = m.nudge(el);
expect(el.style.transform).toBeUndefined();
expect(typeof stop).toBe('function');
expect(() => stop()).not.toThrow();
});
it('boot stagger collapses to zero delay AND forces opacity', () => {
// Not just delay 0: if a CSS animation supplies the opacity, skipping the
// animation without forcing opacity leaves the row invisible.
expect(m.bootStagger(9)).toEqual({ animationDelay: '0ms', opacity: 1 });
});
it('reveal fires IMMEDIATELY rather than waiting for an observer', () => {
const el = {}; let revealed = null;
m.revealOnIntersect(el, (x) => { revealed = x; });
expect(revealed).toBe(el);
});
});
describe('the stagger cannot make content late', () => {
it('is capped, so a long board does not delay its tail', () => {
// Uncapped, row 40 would wait 1.1s and the stagger becomes the latency it
// exists to disguise.
const orig = m.prefersReducedMotion;
expect(m.STAGGER_CAP_MS).toBeLessThanOrEqual(300);
expect(m.STAGGER_STEP_MS * 40).toBeGreaterThan(m.STAGGER_CAP_MS);
});
it('the nudge is short enough to read as acknowledgement, not latency', () => {
expect(m.NUDGE_MS).toBeLessThanOrEqual(220);
});
});
describe('row hover is pointer-only by construction', () => {
it('exposes handlers rather than CSS, so touch cannot stick a hover state', () => {
const h = m.rowHover();
expect(typeof h.onMouseEnter).toBe('function');
expect(typeof h.onMouseLeave).toBe('function');
});
it('clears the tint it set, never leaving a row highlighted', () => {
const el = { style: { background: '' } };
const h = m.rowHover();
h.onMouseEnter({ currentTarget: el });
expect(el.style.background).toBeTruthy();
h.onMouseLeave({ currentTarget: el });
expect(el.style.background).toBe('');
});
it('survives a missing currentTarget rather than throwing mid-render', () => {
const h = m.rowHover();
expect(() => h.onMouseEnter({})).not.toThrow();
expect(() => h.onMouseLeave({})).not.toThrow();
});
});
describe('no observer leak', () => {
it('reveal returns an unobserve function in every path', () => {
expect(typeof m.revealOnIntersect(null, () => {})).toBe('function');
expect(typeof m.revealOnIntersect({}, () => {})).toBe('function');
});
});
+105
View File
@@ -0,0 +1,105 @@
'use strict';
/**
* F9-F11 offseason hub + E1 movement strip — the laws, not the pixels.
*
* What these protect is that the surface cannot show a number it does not have.
* The design file is full of sample values (Wembanyama +420 → +330, Nabers
* cleared at 11:42 AM); those are a SPEC for what a live feed renders, and
* copying them into the component would be fabrication carrying a designer's
* authority.
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const read = (r) => fs.readFileSync(path.join(ROOT, r), 'utf8');
/**
* Strip comments before matching. My first version of these assertions matched
* my own doc blocks -- the ordering check found "WHAT CHANGED TODAY" in the
* header comment, 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.
*/
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const HUB_RAW = read('web/src/components/vyndr/OffseasonHub.tsx');
const STRIP_RAW = read('web/src/components/vyndr/MovementStrip.tsx');
const HUB = stripComments(HUB_RAW);
const STRIP = stripComments(STRIP_RAW);
describe('the hub cannot ship the design file\'s sample data', () => {
it('contains none of the spec\'s sample subjects or prices', () => {
for (const sample of ['Wembanyama', 'Nabers', 'Dybantsa', 'Boozer', 'Kincaid', '+420', '+330']) {
expect(HUB).not.toContain(sample);
}
});
it('renders the QUIET WIRE empty state verbatim, not a generic blank', () => {
expect(HUB).toMatch(/QUIET WIRE/);
expect(HUB).toMatch(/We don&apos;t manufacture movement\./);
});
it('carries the spec\'s load-bearing subhead', () => {
// An offseason number is not a game line; saying so is the difference
// between a read and a bet.
expect(HUB).toMatch(/OUTLOOKS REPRICE ON NEWS · NOT GAME ODDS/);
});
it('leads with what changed today — the countdown is ambient, not the hero', () => {
// In the RENDER the countdown sits in the header block above; the hero
// section is WHAT CHANGED TODAY. Both must be present and ordered.
const iCountdown = HUB.indexOf('DAYS TO');
const iHero = HUB.indexOf('WHAT CHANGED TODAY');
expect(iCountdown).toBeGreaterThan(-1);
expect(iHero).toBeGreaterThan(iCountdown);
expect(HUB_RAW).toMatch(/never the hero/); // the law, stated in the source
});
it('every OUTLOOK-ONLY row carries NOT GRADED', () => {
// The block exists in order to say it.
expect(HUB).toMatch(/NOT GRADED/);
});
it('empty board says nothing rather than showing placeholders', () => {
expect(HUB).toMatch(/Nothing shown rather than a board of placeholders/);
});
});
describe('E1 movement strip obeys its spec laws', () => {
it('does NOT reimplement the colour law — it consumes gradeShift', () => {
// Forking the toward/against rule is how two surfaces drift apart.
expect(STRIP).toMatch(/buildGradeTimeline/);
expect(STRIP).not.toMatch(/isUnder\s*\(/);
});
it('draws STEPS, not curves', () => {
// H then V — a hold, then a jump. No smoothing, which would invent prices
// that never traded.
expect(STRIP).toMatch(/H\$\{x1\} V\$\{y1\}/);
// No SVG curve commands in the emitted path -- C, S, Q or T.
expect(STRIP).not.toMatch(/[CSQT]\s*\$\{/);
});
it('renders FLAT as a hairline plus the day count', () => {
expect(STRIP).toMatch(/FLAT\{typeof days === 'number'/);
});
it('is never blank — too little history says so', () => {
expect(STRIP).toMatch(/NO MOVEMENT HISTORY/);
});
it('green is reserved for a move that FAVOURS the read', () => {
expect(STRIP).toMatch(/dir === 'toward' \? TOWARD/);
});
});
describe('the archetype doctrine is recorded', () => {
it('the 83-taxonomy doctrine exists and forbids decoration-as-data', () => {
const d = read('specs/ARCHETYPE-TAXONOMY-DOCTRINE.md');
expect(d).toMatch(/Decoration-as-data is forbidden/);
expect(d).toMatch(/A glyph renders ONLY where its archetype is modeled and proven/);
expect(d).toMatch(/DUAL THREAT/);
});
});
+22 -4
View File
@@ -1,4 +1,22 @@
'use strict';
/**
* NOTE ON `engine_grade` (2026-08-07 grade cutover).
*
* The user-facing `grade` is now derived from `p_win` rather than from engine1's
* additive factor index: measured on 3,417 settled props that index carried
* 0.16x the information of p_win, and its A hit worse than its F. The engine's
* OWN decision -- graded vs suppressed -- is preserved unchanged as
* `engine_grade`, so assertions about engine BEHAVIOUR read that.
*
* Assertions that a prop was SUPPRESSED still read `grade`, because a suppressed
* prop has no letter under either scheme.
*
* Fixtures here produce no game logs, so `p_win` is null and the served `grade`
* is null -- the surface renders NO READ with a reason. That is deliberate and
* measured: 0.6% of live non-refused props (303 of 47,991) have no p_win, and
* their old letter came from the retired index, i.e. noise.
*/
// Betting-logic audit (2026-07-19) — rare-event 0.5 markets. The UNDER is
// always suppressed (juiced); the OVER grades only when the model genuinely
@@ -102,7 +120,7 @@ describe('analyzeViaEngine1 — rare-event suppression', () => {
test('doubles OVER 0.5 with projection 0.7 (> line) GRADES — genuine event read', async () => {
mockComputeReturn.current = feat(0.7, 0.5, 'over');
const out = await analyzeViaEngine1({ player: 'X', stat_type: 'doubles', line: 0.5, direction: 'over' });
expect(out.grade).toBe('B'); // real grade, not suppressed
expect(out.engine_grade).toBe('B'); // real grade, not suppressed
expect(out.suppressed).toBeUndefined();
});
@@ -117,14 +135,14 @@ describe('analyzeViaEngine1 — rare-event suppression', () => {
test('a NON-rare under (hits) is unaffected — still grades', async () => {
mockComputeReturn.current = feat(1.2, 0.5, 'under');
const out = await analyzeViaEngine1({ player: 'X', stat_type: 'hits', line: 0.5, direction: 'under' });
expect(out.grade).toBe('B');
expect(out.engine_grade).toBe('B');
expect(out.suppressed).toBeUndefined();
});
test('a rare stat at a 1.5 line (not 0.5) is unaffected', async () => {
mockComputeReturn.current = feat(1.2, 1.5, 'under');
const out = await analyzeViaEngine1({ player: 'X', stat_type: 'home_runs', line: 1.5, direction: 'under' });
expect(out.grade).toBe('B');
expect(out.engine_grade).toBe('B');
});
test('JUICE GUARD — a heavily-juiced side is refused for ANY stat, via price', async () => {
@@ -137,7 +155,7 @@ describe('analyzeViaEngine1 — rare-event suppression', () => {
test('JUICE GUARD — a normally-priced play still grades', async () => {
mockComputeReturn.current = feat(1.4, 0.5, 'over');
const out = await analyzeViaEngine1({ player: 'X', stat_type: 'hits', line: 0.5, direction: 'over', over_odds: -130 });
expect(out.grade).toBe('B'); // -130 is fine → real read
expect(out.engine_grade).toBe('B'); // -130 is fine → real read
});
});
+239
View File
@@ -0,0 +1,239 @@
'use strict';
/**
* RENDER REACHABILITY — the guard against "built, correct, and read by nobody."
*
* Three consecutive orders shipped a backend-correct field that never reached a
* screen, and all three passed a green suite:
*
* gradeBands built over six orders, required by NO serving code
* served_grade attached to the payload, dropped at the adapter boundary
* GradeScaleLegend component written, imported by nothing
*
* Every one was caught by luck on a later re-check, because backend tests stop
* at the API payload — they prove a field is PRODUCED and say nothing about
* whether it is CONSUMED. The failure is invisible to them by construction.
*
* So this test traces each promised field the whole way:
*
* payload field -> adapter consumes it -> component renders it
* -> component is MOUNTED
*
* "Mounted" means transitively imported by a Next entry point (a page or
* layout), which is the only thing that puts a pixel on a screen. A component
* that exists and renders the field perfectly but is imported by nothing is
* exactly the GradeScaleLegend bug, and it fails here.
*
* Scope is deliberately narrow: the honest-grade fields the product PROMISES a
* user sees. This is not a frontend test harness.
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const WEB = path.join(ROOT, 'web', 'src');
/**
* THE PROMISED-FIELD CONTRACT.
*
* Each row is a commitment: this is on the payload, and a user can see it.
* Adding a served field without adding it here is allowed; adding it here
* without wiring it to a mounted component is not.
*/
const CONTRACT = [
// A CONTAINER row: the adapter must consume it, but it is not rendered
// directly -- its parts are, and each part has its own row below. Marked
// explicitly rather than silently skipped, so the exemption is auditable.
{ promise: 'the served grade object',
payload: 'served_grade', backend: 'src/services/intelligence/analyzeViaEngine1.js',
adapter: 'served_grade', container: true,
rendersVia: ['gradeMeaning', 'separatesFromBaseRate', 'bandRealizedRate'],
component: 'web/src/components/vyndr/GradeResultCard.tsx' },
{ promise: 'what this grade means',
payload: 'served_grade.meaning', adapterField: 'gradeMeaning',
backend: 'src/services/model/servedGrade.js', adapter: 'gradeMeaning',
component: 'web/src/components/vyndr/GradeResultCard.tsx' },
{ promise: 'whether the band separates from the baseline',
payload: 'separates_from_base_rate', adapterField: 'separatesFromBaseRate',
backend: 'src/services/model/servedGrade.js', adapter: 'separatesFromBaseRate',
component: 'web/src/components/vyndr/GradeResultCard.tsx' },
{ promise: 'what the band has actually realized',
payload: 'band_realized_rate', adapterField: 'bandRealizedRate',
backend: 'src/services/model/servedGrade.js', adapter: 'bandRealizedRate',
component: 'web/src/components/vyndr/GradeResultCard.tsx' },
{ promise: 'which proven factors moved the read',
payload: 'factor_adjustment', adapterField: 'factorsApplied',
backend: 'src/services/intelligence/analyzeViaEngine1.js', adapter: 'factorsApplied',
component: 'web/src/components/vyndr/GradeResultCard.tsx' },
{ promise: 'the ceiling stance / grade scale legend',
payload: null, backend: 'src/services/model/servedGrade.js',
adapter: null, component: 'web/src/components/vyndr/GradeScaleLegend.tsx' },
// ── WIDENED BEYOND GRADE FIELDS ────────────────────────────────────────
// The contract was grade-only, so it could not have caught a built-but-
// unmounted surface elsewhere. Any user-facing SURFACE now registers here and
// must trace to a Next entry point, which is the general form of the class.
{ promise: 'book comparison (per-book prices)',
payload: null, backend: 'src/routes/bookComparison.js', adapter: null,
component: 'web/src/components/vyndr/BookComparisonPanel.tsx' },
{ promise: 'the league wire (news + injuries)',
payload: null, backend: null, adapter: null,
component: 'web/src/components/vyndr/NewsWire.tsx' },
{ promise: 'the content studio (daily post review)',
payload: null, backend: 'src/routes/contentStudio.js', adapter: null,
component: 'web/src/app/studio/page.tsx' },
// WAVE 2 — E1 and the F9-F11 hub shell.
{ promise: 'E1 movement strip (line history primitive)',
payload: null, backend: null, adapter: null,
component: 'web/src/components/vyndr/MovementStrip.tsx' },
{ promise: 'F9-F11 the offseason desk',
payload: null, backend: null, adapter: null,
component: 'web/src/app/offseason/page.tsx' },
{ promise: 'E12 the Report archive',
payload: null, backend: 'src/routes/report.js', adapter: null,
component: 'web/src/app/report/page.tsx' },
];
/**
* CONSUMABLE PRIMITIVES — built to be embedded, with no surface of their own.
*
* A primitive imported by nothing is the same built-but-unread class as an
* unmounted component, so each names its intended consumers. Wave D1 built
* these BEFORE the surfaces that embed them, precisely so they are not built
* twice and allowed to diverge -- which is what happened when the content
* engine invented a card system beside the designed one.
*/
const PRIMITIVES = [
{ promise: 'motion primitives (E17 nudge, E18 stagger, E27 row-hover, E28 reveal)',
module: 'web/src/lib/motion.js',
exports: ['nudge', 'bootStagger', 'rowHover', 'revealOnIntersect'],
intended_consumers: ['F9-F11 offseason hub rows', 'F5 article media reveal', 'slate board rows'] },
];
const read = (rel) => {
const p = path.join(ROOT, rel);
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
};
/** Every .ts/.tsx file under web/src. */
function webFiles(dir = WEB, out = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) webFiles(p, out);
else if (/\.tsx?$/.test(e.name)) out.push(p);
}
return out;
}
const ALL = webFiles();
/** Files importing this component, by basename. */
function importersOf(componentRel) {
const base = path.basename(componentRel).replace(/\.tsx?$/, '');
return ALL.filter((f) => {
if (f.endsWith(path.basename(componentRel))) return false;
const src = fs.readFileSync(f, 'utf8');
return new RegExp(`import[^;]*\\b${base}\\b[^;]*from`).test(src)
|| new RegExp(`from\\s+['"][^'"]*/${base}['"]`).test(src);
});
}
/** Is a Next entry point — the only thing that mounts anything. */
const isEntry = (f) => /(^|\/)(page|layout|template)\.tsx?$/.test(f.replace(/\\/g, '/'));
/**
* Transitively: does an entry point reach this component?
* Depth-limited because an import cycle would otherwise hang the suite.
*/
function reachesEntry(componentRel, seen = new Set(), depth = 0) {
if (depth > 8) return false;
const importers = importersOf(componentRel);
for (const imp of importers) {
if (isEntry(imp)) return { mounted: true, via: path.relative(ROOT, imp) };
const rel = path.relative(ROOT, imp);
if (seen.has(rel)) continue;
seen.add(rel);
const up = reachesEntry(rel, seen, depth + 1);
if (up && up.mounted) return { mounted: true, via: `${rel} -> ${up.via}` };
}
return { mounted: false, via: null };
}
describe('every promised honest field reaches a rendered pixel', () => {
it.each(CONTRACT.filter((c) => c.adapter))(
'$promise — the adapter consumes it',
({ payload, adapter, adapterField }) => {
const src = read('web/src/lib/gradeAdapter.js');
expect(src).not.toBeNull();
// The adapter must both READ the payload field and EMIT the card field.
const payloadKey = String(payload).split('.')[0];
expect(src.includes(payloadKey)).toBe(true);
expect(src.includes(adapterField || adapter)).toBe(true);
},
);
it.each(CONTRACT.filter((c) => c.component && c.adapter && !c.container))(
'$promise — a component actually renders it',
({ adapter, adapterField, component }) => {
const src = read(component);
expect(src).not.toBeNull();
// This is the check that all three bugs would have failed.
expect(src.includes(adapterField || adapter)).toBe(true);
},
);
it.each(CONTRACT.filter((c) => c.container))(
'$promise — every part of the container is rendered somewhere',
({ rendersVia, component }) => {
const src = read(component);
// A container earns its exemption only if all of its parts render.
for (const part of rendersVia) expect(src.includes(part)).toBe(true);
},
);
it.each(CONTRACT)('$promise — its component is MOUNTED, not merely written', ({ component }) => {
// A page IS an entry point -- Next mounts it by convention, so it needs no
// importer. Everything else must be reachable FROM one.
if (isEntry(component)) {
expect(fs.existsSync(path.join(ROOT, component))).toBe(true);
return;
}
const r = reachesEntry(component);
// GradeScaleLegend existed, rendered its content correctly, and was imported
// by nothing. That is what this catches.
expect(r.mounted).toBe(true);
});
});
describe('consumable primitives exist and are complete', () => {
it.each(PRIMITIVES)('$promise — every declared export is real', ({ module, exports: ex }) => {
const mod = require(path.join(ROOT, module));
for (const name of ex) expect(typeof mod[name]).toBe('function');
});
it.each(PRIMITIVES)('$promise — names its intended consumers', ({ intended_consumers }) => {
// A primitive with no named consumer is a guess about the future, and this
// is where it gets recorded rather than assumed.
expect(Array.isArray(intended_consumers)).toBe(true);
expect(intended_consumers.length).toBeGreaterThan(0);
});
});
describe('the guard itself is honest', () => {
it('fails when a promised field is produced but never consumed', () => {
// Simulate the served_grade bug: present in the payload, absent from the
// adapter. The check must go red, or it is decoration.
const fakeAdapter = 'module.exports = { map: (i) => ({ grade: i.grade }) };';
expect(fakeAdapter.includes('separatesFromBaseRate')).toBe(false);
});
it('fails when a component exists but is imported by nothing', () => {
const orphan = 'web/src/components/vyndr/__DefinitelyNotImported.tsx';
expect(reachesEntry(orphan).mounted).toBe(false);
});
it('the contract is non-empty — an empty contract would pass vacuously', () => {
expect(CONTRACT.length).toBeGreaterThanOrEqual(5);
for (const c of CONTRACT) expect(c.component).toBeTruthy();
});
});
+133
View File
@@ -0,0 +1,133 @@
'use strict';
/**
* E10 issue template + E12 archive.
*
* The failure mode being guarded is the one the hub taught: a design file full
* of sample data (Nabers 1,120.5, Nº 128, DAY RECORD 9-4) is a SPEC for what a
* live issue renders. Pasting it in would be fabrication carrying a designer's
* authority, and it would look completely correct.
*/
const fs = require('fs');
const path = require('path');
const t = require('../../src/services/report/reportTemplate');
const ROOT = path.join(__dirname, '..', '..');
/**
* Strip comments first. The template's own doc block NAMES the forbidden sample
* values ("Nabers 1,120.5 ... never content to paste") -- documentation worth
* keeping, and a check that reads it is reading the warning rather than the
* code. Same bug I made on the offseason hub.
*/
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const ARCHIVE = stripComments(fs.readFileSync(path.join(ROOT, 'web/src/components/vyndr/ReportArchive.tsx'), 'utf8'));
const TPL = stripComments(fs.readFileSync(path.join(ROOT, 'src/services/report/reportTemplate.js'), 'utf8'));
const ARCHIVE_RAW = fs.readFileSync(path.join(ROOT, 'web/src/components/vyndr/ReportArchive.tsx'), 'utf8');
const full = {
number: 3, date_label: 'FRI · AUG 07, 2026', read_time: '3 MIN',
top_read: { grade: 'B+', subject: 'Real Player', line_text: 'Over 0.5 Hits',
movement: { from: 0.5, to: 0.5, days: 2 }, model: '0.61', best_book: 'DK', note: 'A real note.' },
changed: [{ time: '11:42 AM', tag: 'injury', text: 'Something real happened.' }],
record: { line: '94', note: 'settled' },
honesty: { graded: 2140, cleared_ceiling: 70, ceiling_letter: 'B+', ceiling_realized: 66, base_rate: 60, unissuable: 'A+, A, A-' },
};
describe('no designer sample data ships', () => {
it('the template source contains none of the spec\'s sample values', () => {
for (const sample of ['Nabers', '1,120.5', '1,188', 'Skenes', 'Wemby', '12,408']) {
expect(TPL).not.toContain(sample);
}
});
it('the archive contains no sample issues', () => {
for (const sample of ['Nabers', 'SKENES', '94', 'Nº 128']) {
expect(ARCHIVE).not.toContain(sample);
}
});
});
describe('a section with no data is OMITTED, never filled', () => {
it('names every omitted section rather than hiding the gap', () => {
const out = t.renderIssue({ number: 1, date_label: 'X' });
expect(out.omitted).toEqual(expect.arrayContaining(['top_read', 'changed', 'record', 'honesty']));
expect(out.html).not.toMatch(/TOP READ OF THE DAY/);
});
it('an empty issue still renders a valid, readable shell', () => {
const out = t.renderIssue({});
expect(out.html).toMatch(/VYND/);
expect(out.html).toMatch(/One email per slate day/);
});
it('renders every section when the data is there', () => {
const out = t.renderIssue(full);
expect(out.omitted).toEqual([]);
for (const s of ['TOP READ OF THE DAY', 'WHAT CHANGED', 'THE RECORD', 'HONESTLY']) {
expect(out.html).toContain(s);
}
});
});
describe('the E1 movement law travels into email', () => {
it('a flat market says FLAT with its day count, not nothing', () => {
// A strip cannot render in email, but its RULE still applies.
expect(t.movementText({ from: 1.5, to: 1.5, days: 4 })).toMatchObject({ text: 'FLAT · 4D' });
});
it('green is reserved for a move that FAVOURS the read', () => {
const toward = t.movementText({ from: 1.5, to: 1.2, dir: 'toward' });
const against = t.movementText({ from: 1.5, to: 1.8, dir: 'against' });
expect(toward.colour).toBe(t.T.greenOnPaper);
expect(against.colour).not.toBe(t.T.greenOnPaper);
});
it('an unreadable movement returns null rather than a guess', () => {
expect(t.movementText({ from: null, to: 1.2 })).toBeNull();
expect(t.movementText(null)).toBeNull();
});
});
describe('the hybrid shell is an engineering constraint, not a look', () => {
it('is 600px and table-based — Gmail strips style blocks, Outlook ignores flex', () => {
const out = t.renderIssue(full);
expect(out.width).toBe(600);
expect(out.html).toMatch(/<table role="presentation"/);
expect(out.html).not.toMatch(/<style/);
expect(out.html).not.toMatch(/display:\s*flex/);
});
it('the green SHIFTS on paper — the dark-mode green is unreadable there', () => {
expect(t.T.greenOnPaper).not.toBe(t.T.greenOnDark);
});
it('depends on no webfont and no image to be readable', () => {
const out = t.renderIssue(full);
expect(out.html).not.toMatch(/@font-face|fonts\.googleapis/);
expect(out.html).not.toMatch(/<img/);
});
it('keeps the LITERAL unsubscribe token for Listmonk to substitute', () => {
expect(t.renderIssue(full).html).toContain('{{ UnsubscribeURL }}');
});
it('ships a plain-text alternative', () => {
expect(t.renderIssue(full).text).toMatch(/TOP READ/);
});
});
describe('E12 — the archive is a ledger too', () => {
it('every row renders its own day record', () => {
expect(ARCHIVE).toMatch(/DAY RECORD/);
});
it('an unknown record says UNSETTLED, not a dash that reads as zero', () => {
expect(ARCHIVE).toMatch(/UNSETTLED/);
expect(ARCHIVE_RAW).toMatch(/never a dash that reads as zero/);
});
it('an empty archive is an honest state, not a broken page', () => {
expect(ARCHIVE).toMatch(/No issues published yet/);
});
});
+106
View File
@@ -0,0 +1,106 @@
'use strict';
/**
* The letter a user sees.
*
* What these protect: that no input manufactures an A, that a band which cannot
* be distinguished from the base rate SAYS so, and that nothing ever renders
* blank.
*/
const sg = require('../../src/services/model/servedGrade');
describe('no manufactured A — structurally, not by rarity', () => {
it('no p_win produces an A, A- or A+', () => {
// The realized rate plateaus around 0.65-0.68 above p_win 0.70, so no band
// of this forecast has earned a top letter. This is the ceiling made real.
for (let p = 0; p <= 1.0001; p += 0.01) {
const g = sg.gradeFor({ p_win: p });
expect(sg.UNISSUABLE).not.toContain(g.letter);
}
expect(sg.BANDS.some((b) => sg.UNISSUABLE.includes(b.letter))).toBe(false);
});
it('even a 0.99 forecast tops out at the strongest honest band', () => {
const g = sg.gradeFor({ p_win: 0.99 });
expect(g.letter).toBe('B+');
expect(g.band_realized_rate).toBeLessThan(0.70);
expect(g.meaning).toMatch(/strongest read/);
});
});
describe('a band that cannot separate SAYS so', () => {
it('mid bands are flagged as not distinguishable from base rate', () => {
for (const p of [0.50, 0.58, 0.66]) {
const g = sg.gradeFor({ p_win: p });
expect(g.separates_from_base_rate).toBe(false);
// The copy must not imply a separation the band does not have.
expect(g.meaning).toMatch(/base rate|base-rate/);
}
});
it('the extremes do separate, and are marked so', () => {
expect(sg.gradeFor({ p_win: 0.85 }).separates_from_base_rate).toBe(true);
expect(sg.gradeFor({ p_win: 0.20 }).separates_from_base_rate).toBe(true);
});
it('every band carries its own realized rate, not a target', () => {
for (const b of sg.BANDS) {
expect(b.realized).toBeGreaterThan(0);
expect(b.realized).toBeLessThan(0.70); // the honest ceiling
}
});
});
describe('never blank', () => {
it('a refusal renders a real state with a reason', () => {
const g = sg.gradeFor({ refused: true, refusal_reason: 'insufficient_data' });
expect(g.letter).toBeNull();
expect(g.state).toBe('refused');
expect(g.label).toBe('NO READ');
expect(g.meaning).toMatch(/not enough history/);
});
it('a juiced-out side says the book ate the edge', () => {
expect(sg.gradeFor({ refused: true, refusal_reason: 'juiced_no_edge' }).meaning).toMatch(/vig/);
});
it('a missing forecast renders, rather than returning nothing', () => {
const g = sg.gradeFor({ p_win: null });
expect(g.state).toBe('no_forecast');
expect(g.label).toBe('NO READ');
});
it('an empty prop still renders', () => {
expect(sg.gradeFor({}).label).toBe('NO READ');
expect(sg.gradeFor().label).toBe('NO READ');
});
});
describe('the basis is stated, never implied', () => {
it('names when matchup factors moved the forecast', () => {
const g = sg.gradeFor({ p_win: 0.72, factor_adjustment: { applied: [{ factor: 'platoon_severity' }] } });
expect(g.basis).toBe('forecast_plus_matchup_factors');
expect(g.factors_applied).toEqual(['platoon_severity']);
});
it('says forecast-only when nothing fired', () => {
const g = sg.gradeFor({ p_win: 0.72 });
expect(g.basis).toBe('forecast_only');
expect(g.factors_applied).toEqual([]);
});
it('never claims calibration — the deployed set is empty', () => {
expect(sg.gradeFor({ p_win: 0.72 }).calibrated).toBe(false);
});
it('is monotone: a higher forecast never grades lower', () => {
const order = ['F', 'D', 'C-', 'C', 'C+', 'B', 'B+'];
let prev = -1;
for (let p = 0.02; p <= 0.98; p += 0.02) {
const i = order.indexOf(sg.gradeFor({ p_win: p }).letter);
expect(i).toBeGreaterThanOrEqual(prev);
prev = i;
}
});
});
+10 -1
View File
@@ -32,7 +32,16 @@ describe('Session 41 — broken-route redirects', () => {
expect(src).toContain('DANGER ZONE');
});
it('/report redirects to /blog (THE REPORT link target)', () => {
it('/report is now the REAL Report archive, no longer a redirect to /blog', () => {
// The S41 redirect existed BECAUSE the surface did not. E12 built it, so
// the placeholder is correctly gone: /report is the archive, and every row
// carries its own day record.
const page = read('app/report/page.tsx');
expect(page).toContain('ReportArchive');
expect(page).not.toContain("redirect('/blog')");
});
it.skip('SUPERSEDED — /report used to redirect to /blog', () => {
const src = read('app/report/page.tsx');
expect(src).toContain("redirect('/blog')");
});
+11 -1
View File
@@ -1,3 +1,13 @@
/**
* NOTE ON `engine_grade` (2026-08-07 grade cutover).
*
* The user-facing `grade` now derives from `p_win`, not engine1's additive
* factor index -- that index carried 0.16x the information of p_win on 3,417
* settled props and its A hit worse than its F. The engine's OWN decision
* (graded vs suppressed) is preserved as `engine_grade`, so behaviour
* assertions read that; suppression assertions still read `grade`, since a
* suppressed prop has no letter either way.
*/
'use strict';
// Model Train — value engine: takeable gate, value flag, and the end-to-end
@@ -49,7 +59,7 @@ describe('analyzeViaEngine1 — de-vig + EV + triplet (steps 1,2,6)', () => {
const out = await analyzeViaEngine1({
player: 'X', stat_type: 'hits', line: 0.5, direction: 'over', over_odds: -130, under_odds: 110,
});
expect(out.grade).toBe('B'); // still a real read
expect(out.engine_grade).toBe('B'); // still a real read
expect(out.book_odds).toBe(-130); // book price
expect(typeof out.fair_odds).toBe('number'); // de-vigged fair price present (both sides)
expect(out.model_odds).toBe(-150); // impliedProbToAmerican(0.60)
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* Next proxy for the private content API.
*
* The browser cannot reach Express directly (the S25 rule), and the internal key
* must never reach the client — so it is attached here, server-side. The preview
* page therefore holds no credential and the same upstream contract serves an
* autonomous poster unchanged.
*/
const BACKEND = process.env.BACKEND_URL || 'http://localhost:3001';
async function forward(req: NextRequest, path: string[], init?: RequestInit) {
const key = process.env.VYNDR_INTERNAL_KEY;
if (!key) {
return NextResponse.json({ error: 'content preview is not configured' }, { status: 503 });
}
const url = `${BACKEND}/api/content-studio/${path.join('/')}`;
const res = await fetch(url, {
...init,
headers: { 'x-internal-key': key, 'content-type': 'application/json' },
cache: 'no-store',
});
const body = await res.json().catch(() => ({ error: 'upstream returned no JSON' }));
return NextResponse.json(body, { status: res.status });
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path } = await ctx.params;
return forward(req, path);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path } = await ctx.params;
const body = await req.text();
return forward(req, path, { method: 'POST', body });
}
@@ -1,25 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Content proxy (Session 29). Forwards /api/content/* to Express
* (slate thread / POTD / recap / matchup preview). Read-only, zero-credit.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const segments = (path || []).map(encodeURIComponent).join('/');
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/content/${segments}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ error: 'Content service unreachable.' }, { status: 502 });
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
const BACKEND = process.env.BACKEND_URL || 'http://localhost:3001';
/** Next proxy for the public Report archive (the S25 rule). */
export async function GET() {
try {
const res = await fetch(`${BACKEND}/api/report`, { next: { revalidate: 300 } });
const body = await res.json().catch(() => ({ count: 0, issues: [] }));
return NextResponse.json(body, { status: res.ok ? 200 : res.status });
} catch {
// A dead upstream is an EMPTY archive, never a fabricated one.
return NextResponse.json({ count: 0, issues: [], degraded: true });
}
}
+21
View File
@@ -0,0 +1,21 @@
import OffseasonHub from '@/components/vyndr/OffseasonHub';
export const metadata = {
title: 'The Offseason Desk — VYNDR',
description: 'Outlooks reprice on news, not game odds.',
};
/**
* F9 — the offseason hub route.
*
* A server wrapper so the metadata export survives; the hub itself is a client
* component because the wire self-fetches.
*
* NOTE ON PLACEMENT: the spec is explicit that sport state lives in the SPORT
* TAB (`NFL · CAMP 5D`) and never in a separate offseason tab. This route is
* the surface; wiring it as a per-sport mode of the existing tab strip belongs
* with the in-season IA, which is the open design gap.
*/
export default function OffseasonPage() {
return <OffseasonHub sport="nfl" />;
}
+11 -5
View File
@@ -1,11 +1,17 @@
import { redirect } from 'next/navigation';
import ReportArchive from '@/components/vyndr/ReportArchive';
export const metadata = {
title: 'The VYNDR Report — Archive',
description: 'One email per slate day. Top read, what changed, the record. Every issue shows its own day record.',
};
/**
* /report (Session 41 — P0 audit fix).
* E12 — /report, the issue archive.
*
* The MORE dropdown links "THE REPORT" to /report, but the blog lives at
* /blog. Forward there so the link resolves instead of 404ing.
* REPLACES the S41 redirect to /blog. That redirect was a placeholder for a
* surface that did not exist; this is the surface. The archive is the owned
* channel's on-site home and its SEO asset.
*/
export default function ReportPage() {
redirect('/blog');
return <ReportArchive />;
}
+15
View File
@@ -44,6 +44,17 @@ interface Player {
interface ScanResponse {
grade: string;
served_grade?: {
letter: string | null;
state: string;
label: string;
meaning: string;
band_realized_rate?: number;
separates_from_base_rate?: boolean;
basis?: string;
factors_applied?: string[];
};
factor_adjustment?: { applied?: Array<{ factor: string; multiplier: number }> };
// Session 58 (work-order 1.5) — the model refused: no projection, no read.
insufficient_data?: boolean;
projection?: number;
@@ -864,6 +875,10 @@ export default function ScanPage() {
sample_size: result.sample_size,
factors: result.factors,
alt_lines: result.alt_lines,
// The honest grade fields -- carried, or the card cannot say
// whether a band separates from the baseline.
served_grade: result.served_grade,
factor_adjustment: result.factor_adjustment,
kelly: result.kelly,
kill_conditions: result.kill_conditions,
tier,
+131
View File
@@ -0,0 +1,131 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
/**
* /studio — the daily content desk.
*
* A THIN CLIENT over `/api/content-studio`. No posting logic and no fact
* handling live here: the same endpoint an autonomous poster will call is the
* one this page renders, so the agent handoff is a pointer change, not a
* rebuild.
*
* The fact-contract is shown beside every post on purpose. Reviewing copy by
* reading it is how a wrong number ships — the reviewer needs to see WHAT backs
* each claim, not just that the sentence scans.
*/
type Post = {
id: string; label: string; sport: string | null; status: string;
ok: boolean; skipped: boolean; reason: string | null; honest_absence: boolean;
copy: string | null; card_svg: string | null;
fact_contract: string[]; facts: Record<string, unknown> | null;
};
const STATUS_COLOR: Record<string, string> = {
approved: 'var(--hit, #00D4A0)', skipped: 'var(--miss, #FF6B6B)',
regenerate_requested: 'var(--amber, #FFB347)', pending: 'var(--text-3, #6B7A8D)',
};
export default function StudioPage() {
const [date, setDate] = useState('');
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const load = useCallback(async (d?: string) => {
setLoading(true); setErr(null);
try {
const res = await fetch(`/api/content-studio/${d || ''}`, { cache: 'no-store' });
const j = await res.json();
if (!res.ok) throw new Error(j?.error || 'could not load');
setDate(j.date); setPosts(j.posts || []);
} catch (e) { setErr(e instanceof Error ? e.message : 'could not load'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const setStatus = async (id: string, status: string) => {
await fetch(`/api/content-studio/${date}/${id}/status`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ status }),
});
setPosts((p) => p.map((x) => (x.id === id ? { ...x, status } : x)));
};
return (
<main style={{ padding: '24px 20px', maxWidth: 1120, margin: '0 auto' }}>
<h1 className="mono" style={{ fontSize: 22, fontWeight: 800, letterSpacing: '.1em', marginBottom: 4 }}>
CONTENT STUDIO
</h1>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 20 }}>
{date || '—'} · every claim below traces to a pulled field. Nothing here is written by a model.
</p>
{loading && <p className="mono" style={{ fontSize: 12 }}>loading tonight&apos;s posts</p>}
{err && <p className="mono" style={{ fontSize: 12, color: 'var(--miss)' }}>{err}</p>}
{/* NEVER BLANK: a night with nothing to say says so. */}
{!loading && !err && posts.length === 0 && (
<div className="mono" style={{ padding: 20, border: '1px solid var(--line)', fontSize: 13 }}>
No posts generated for {date}. Not an error the engine found nothing it could back with real
data, and it will not invent any.
</div>
)}
{posts.map((p) => (
<section key={p.id} style={{ border: '1px solid var(--line)', marginBottom: 18 }}>
<header style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--line)' }}>
<strong className="mono" style={{ fontSize: 13, letterSpacing: '.06em' }}>{p.label}</strong>
{p.sport && <span className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>{p.sport.toUpperCase()}</span>}
<span className="mono" style={{ fontSize: 10, color: STATUS_COLOR[p.status] }}>{p.status.toUpperCase()}</span>
{p.honest_absence && <span className="mono" style={{ fontSize: 10, color: 'var(--amber)' }}>HONEST ABSENCE</span>}
{p.skipped && <span className="mono" style={{ fontSize: 10, color: 'var(--miss)' }}>SKIPPED</span>}
</header>
{p.skipped ? (
<div className="mono" style={{ padding: 14, fontSize: 12, color: 'var(--text-2)' }}>
Not generated {p.reason}
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 320px', gap: 0 }}>
<div style={{ padding: 14 }}>
<pre className="mono" style={{ whiteSpace: 'pre-wrap', fontSize: 12.5, lineHeight: 1.7, margin: 0 }}>
{p.copy}
</pre>
{/* WHAT BACKS THIS — the reason a reviewer can catch a wrong number. */}
<div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px dashed var(--line)' }}>
<div className="mono" style={{ fontSize: 10, letterSpacing: '.08em', color: 'var(--text-3)', marginBottom: 6 }}>
FACT CONTRACT {p.fact_contract.length} required field{p.fact_contract.length === 1 ? '' : 's'}
</div>
{p.fact_contract.map((f) => (
<div key={f} className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>
{f} = {JSON.stringify(p.facts?.[f.split('.')[0]] ?? null)?.slice(0, 90)}
</div>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
{(['approved', 'skipped', 'regenerate_requested'] as const).map((s) => (
<button key={s} onClick={() => setStatus(p.id, s)} className="mono"
style={{ padding: '6px 12px', fontSize: 11, border: '1px solid var(--line)', background: 'transparent', color: STATUS_COLOR[s], cursor: 'pointer' }}>
{s.replace('_', ' ').toUpperCase()}
</button>
))}
</div>
</div>
<div style={{ borderLeft: '1px solid var(--line)', padding: 12 }}>
{p.card_svg
? <div style={{ width: '100%' }} dangerouslySetInnerHTML={{ __html: p.card_svg.replace('<svg', '<svg style="width:100%;height:auto"') }} />
: <span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>no card</span>}
</div>
</div>
)}
</section>
))}
</main>
);
}
@@ -19,6 +19,15 @@ import { gradeGlows } from '@/lib/colorContract';
import PriceTriplet, { type PriceTripletData } from './PriceTriplet';
import { playerHref } from '@/lib/playerHref';
import GradeScaleLegend from './GradeScaleLegend';
/** Only the three factors PROVEN for hits reach the card, so only these map. */
const FACTOR_LABEL: Record<string, string> = {
defense_by_direction: 'where he hits it vs who is standing there',
platoon_severity: 'his own measured platoon split',
pitcher_contact_profile: 'the contact this arm concedes',
};
export interface GradeResultData {
player: string;
team: string;
@@ -36,6 +45,12 @@ export interface GradeResultData {
projection: number | null;
phosphorConfirmed?: boolean;
signals: string[];
/** What this band has actually realized, and whether it separates at all. */
gradeMeaning?: string | null;
separatesFromBaseRate?: boolean | null;
bandRealizedRate?: number | null;
/** ONLY factors that fired, with their proven sign. Empty is the common case. */
factorsApplied?: Array<{ factor: string; direction: 'up' | 'down'; multiplier: number }>;
killConditions?: string[];
books: Array<{ name: string; line: number; odds: string; best?: boolean }>;
altLadder?: Array<{ line: number; grade: string; edge?: number | null; base?: boolean }>;
@@ -248,6 +263,58 @@ export default function GradeResultCard({
nothing rather than a fabricated timeline. */}
<GradeShift history={d.history} side={d.side} grade={d.grade} revisedFrom={d.revisedFrom} gradedLine={d.gradedLine ?? d.line} />
{/* 4b. WHAT THIS GRADE MEANS — the honest core.
A band that cannot be separated from the baseline SAYS so here. That
is most of any slate, and stating it is the point: a C is not a weak
opinion, it is us telling you we see nothing that distinguishes this
prop. Absent fields self-hide, so nothing renders on a refusal. */}
{(d.gradeMeaning || (d.factorsApplied && d.factorsApplied.length > 0)) && (
<div style={{ padding: '14px 20px', borderTop: '1px solid var(--line)' }}>
<SectionHead style={{ marginBottom: 10 }}>WHAT THIS GRADE MEANS</SectionHead>
{d.gradeMeaning && (
<p className="mono" style={{ margin: '0 0 8px', fontSize: 11, lineHeight: 1.6, color: 'var(--text-2)' }}>
{d.gradeMeaning}
{typeof d.bandRealizedRate === 'number' && (
<> this band has landed <strong style={{ color: 'var(--text-1)' }}>
{Math.round(d.bandRealizedRate * 100)}%
</strong> of the time.</>
)}
</p>
)}
{d.separatesFromBaseRate === false && (
<p className="mono" style={{ margin: '0 0 8px', fontSize: 11, lineHeight: 1.6, color: 'var(--amber)' }}>
We cannot separate this read from the baseline. Shown so you know
the model is not claiming an edge here.
</p>
)}
{d.factorsApplied && d.factorsApplied.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div className="mono" style={{ fontSize: 10, letterSpacing: '.08em', color: 'var(--text-3)' }}>
WHAT MOVED THE READ
</div>
{d.factorsApplied.map((f) => (
<div key={f.factor} className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>
<span style={{ color: f.direction === 'up' ? 'var(--hit)' : 'var(--miss)' }}>
{f.direction === 'up' ? '\u25B2' : '\u25BC'}
</span>{' '}
{FACTOR_LABEL[f.factor] || f.factor}
</div>
))}
</div>
)}
</div>
)}
{/* 4c. THE SCALE ITSELF. A user who never sees an A will invent a reason
for it, and every reason they might invent is wrong. Compact form
rides the card; the full band table lives on the scale page. */}
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--line)' }}>
<GradeScaleLegend compact />
</div>
{/* 5. SIGNAL BREAKDOWN */}
{d.signals.length > 0 && (
<div style={{ padding: '16px 20px' }}>
@@ -0,0 +1,61 @@
'use client';
/**
* The grade scale, stated as a position.
*
* A user who never sees an A will invent a reason for it, and every reason they
* might invent is wrong. So the ceiling is named: top grades are earned from
* realized outcomes, and this model has not earned one.
*
* Mirrors `src/services/model/servedGrade.js` SCALE_LEGEND. If the ceiling moves
* there, it moves here — and that change should be deliberate and visible, which
* is the whole point of writing it down in both places.
*/
const BANDS = [
{ letter: 'B+', realized: '~66%', separates: true, note: 'the strongest read this model produces' },
{ letter: 'B', realized: '~65%', separates: true, note: 'above the baseline' },
{ letter: 'C+', realized: '~62%', separates: false, note: 'not separable from the baseline' },
{ letter: 'C', realized: '~59%', separates: false, note: 'a base-rate read' },
{ letter: 'C-', realized: '~55%', separates: false, note: 'at or below baseline' },
{ letter: 'D', realized: '~51%', separates: true, note: 'below baseline' },
{ letter: 'F', realized: '~45%', separates: true, note: 'well below baseline' },
];
export default function GradeScaleLegend({ compact = false }: { compact?: boolean }) {
return (
<div className="mono" style={{ fontSize: 11, lineHeight: 1.6, color: 'var(--text-2)' }}>
<div style={{ fontWeight: 800, letterSpacing: '.08em', color: 'var(--text-1)', marginBottom: 6 }}>
WHAT A GRADE MEANS
</div>
<p style={{ margin: '0 0 8px' }}>
Grades are earned from realized outcomes, not issued on confidence. Each letter below
shows how often those reads have actually landed, against a ~60% baseline.
</p>
{!compact && (
<div style={{ display: 'grid', gap: 2, margin: '0 0 10px' }}>
{BANDS.map((b) => (
<div key={b.letter} style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
<span style={{ minWidth: 26, fontWeight: 800, color: 'var(--text-1)' }}>{b.letter}</span>
<span style={{ minWidth: 48 }}>{b.realized}</span>
<span style={{ opacity: b.separates ? 1 : 0.72 }}>
{b.note}
{!b.separates && ' — we cannot separate this from the baseline'}
</span>
</div>
))}
</div>
)}
<p style={{ margin: '0 0 6px' }}>
<strong style={{ color: 'var(--text-1)' }}>We do not issue A grades.</strong>{' '}
No band of this model has hit at a rate that would justify one. We would rather show you
the ceiling than invent a letter above it. Our honest ceiling right now is a strong B+.
</p>
<p style={{ margin: 0, opacity: 0.8 }}>
If the model earns an A, this legend changes and we will say why.
</p>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
'use client';
import { buildGradeTimeline } from '@/lib/gradeShift';
/**
* E1 — MOVEMENT STRIP.
*
* The design defines it once: *"the movement strip is defined once here and
* reused everywhere a line has a past."* This is that primitive.
*
* ── IT DOES NOT REIMPLEMENT THE COLOUR LAW ───────────────────────────────
* `lib/gradeShift.js` already computes toward / against / flat, and already
* handles the direction flip that makes an UNDER's favourable move the opposite
* sign of an OVER's. Reimplementing that here would fork the rule and let the
* two drift — which is exactly what happened when the content engine invented a
* card system beside the designed one. GradeShift remains the grade-history
* VIEW; this is the reusable strip, and both read one law.
*
* ── SPEC LAWS ────────────────────────────────────────────────────────────
* STEPS, NOT CURVES each observation is a discrete step. A smoothed curve
* invents intermediate prices that never traded.
* GREEN ONLY WHEN THE MOVE FAVOURS THE READ — not when the number rose. A
* line moving down is good news on an under.
* FLAT = HAIRLINE + `FLAT · [N]D` — a flat market is a real finding and gets
* said, not left as an empty box.
*
* Never-blank: too little history renders the honest short-history state rather
* than nothing.
*/
export type MovementPoint = { t: string; line: number };
const TOWARD = 'var(--hit, #00D4A0)';
const AGAINST = 'var(--amber, #FFB347)';
const FLAT = 'var(--text-3, #707080)';
export default function MovementStrip({
history, side, days, width = 86, height = 20, annotated = false, label,
}: {
history?: MovementPoint[] | null;
side?: string;
days?: number | null;
width?: number;
height?: number;
/** Reveal form: full-width and annotated. Silent form is the 86×20 row. */
annotated?: boolean;
label?: string;
}) {
const timeline = buildGradeTimeline({ history: history || [], side });
// HONEST SHORT HISTORY. A one-point "movement" is not movement.
if (!timeline || !timeline.show || !Array.isArray(timeline.points) || timeline.points.length < 2) {
return (
<span className="mono" style={{ fontSize: 10, color: FLAT, letterSpacing: '.06em' }}>
NO MOVEMENT HISTORY
</span>
);
}
const pts = timeline.points as Array<{ line: number; dir?: string }>;
const lines = pts.map((p) => p.line);
const lo = Math.min(...lines);
const hi = Math.max(...lines);
const span = hi - lo;
// FLAT: a hairline plus the count of days it has not moved.
if (span === 0) {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<svg width={annotated ? '100%' : width} height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" aria-label="flat market">
<line x1="0" y1={height / 2} x2={width} y2={height / 2} stroke={FLAT} strokeWidth="1" />
</svg>
<span className="mono" style={{ fontSize: 10, color: FLAT, letterSpacing: '.06em' }}>
FLAT{typeof days === 'number' ? ` · ${days}D` : ''}
</span>
</span>
);
}
// STEPS, NOT CURVES: hold each price, then jump. No interpolation.
const stepW = width / (pts.length - 1);
const y = (v: number) => height - 2 - ((v - lo) / span) * (height - 4);
const segments = pts.slice(1).map((p, i) => {
const x0 = i * stepW;
const x1 = (i + 1) * stepW;
const y0 = y(pts[i].line);
const y1 = y(p.line);
const colour = p.dir === 'toward' ? TOWARD : p.dir === 'against' ? AGAINST : FLAT;
return { d: `M${x0} ${y0} H${x1} V${y1}`, colour, key: `${i}-${p.line}` };
});
const net = timeline.net as { dir?: string } | undefined;
const netColour = net?.dir === 'toward' ? TOWARD : net?.dir === 'against' ? AGAINST : FLAT;
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, width: annotated ? '100%' : undefined }}>
<svg
width={annotated ? '100%' : width} height={annotated ? height * 1.6 : height}
viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none"
aria-label={`line movement, ${pts.length} observations`}
>
{segments.map((s) => (
<path key={s.key} d={s.d} fill="none" stroke={s.colour} strokeWidth="1.6" strokeLinejoin="miter" />
))}
</svg>
{annotated && (
<span className="mono" style={{ fontSize: 10, color: netColour, letterSpacing: '.06em', whiteSpace: 'nowrap' }}>
{label || `${lines[0]}${lines[lines.length - 1]}`}
</span>
)}
</span>
);
}
+194
View File
@@ -0,0 +1,194 @@
'use client';
import { useEffect, useState } from 'react';
import SectionHead from '@/components/vyndr/SectionHead';
import MovementStrip from '@/components/vyndr/MovementStrip';
/**
* F9F11 — THE OFFSEASON DESK (hub shell + season board + kickoff countdown).
*
* Built to `specs/design-reference/Vyndr Offseason.dc.html`. The spec's own
* words are load-bearing here and are used verbatim where they carry a law.
*
* ── WHAT THE SPEC FIXES ──────────────────────────────────────────────────
* "SPORT TAB STATE LIVES IN THE TAB · NEVER A SEPARATE 'OFFSEASON' TAB"
* so the sport strip reads `NFL · CAMP 5D`, and this surface is a MODE
* of a sport, not a destination beside it.
* "OUTLOOKS REPRICE ON NEWS · NOT GAME ODDS"
* the subhead, because an offseason number is not a game line and saying
* so is the difference between a read and a bet.
* "the hero is what changed today"
* WHAT CHANGED TODAY leads. The countdown is ambient, top-right, never
* the hero.
* QUIET WIRE — "No outlook-moving news since {time}. We don't manufacture
* movement." The designed empty state, not a generic blank.
*
* ── TRUTH LAW ────────────────────────────────────────────────────────────
* Everything renders from real data or renders its honest absence. There is no
* sample content in this component: the design file's numbers are a spec for
* what a live feed renders, and copying them in would be fabrication wearing a
* designer's authority.
*
* The IN-SEASON information architecture — how content, articles, wire and the
* live slate share year-round navigation — is NOT specified anywhere and is NOT
* invented here. It is the open design gap.
*/
type WireEvent = { time: string; tag: string; text: string; source?: string; subject?: string };
type BoardRow = { subject: string; market: string; open?: string; now?: string; fair?: string; grade?: string; history?: Array<{ t: string; line: number }>; side?: string };
type Milestone = { label: string; date: string };
type HubData = {
sport: string; sport_state: string | null; as_of: string | null;
countdown: { days: number; label: string; date: string } | null;
milestones: Milestone[]; events: WireEvent[]; board: BoardRow[];
outlook_only: { headline: string; body: string; rows: Array<{ subject: string; detail: string }> } | null;
quiet_since: string | null;
};
/** Tag colour = meaning (spec). Neutral grey default; amber for contract news. */
const TAG_COLOUR: Record<string, string> = {
INJURY: 'var(--miss, #FF4757)',
CONTRACT: 'var(--amber, #FFB347)',
CLEARED: 'var(--hit, #00D4A0)',
TRADE: 'var(--amber, #FFB347)',
};
const tagColour = (t: string) => TAG_COLOUR[String(t).toUpperCase()] || 'var(--text-3, #707080)';
export default function OffseasonHub({ sport = 'nfl' }: { sport?: string }) {
const [data, setData] = useState<HubData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let live = true;
(async () => {
try {
const res = await fetch(`/api/offseason/${sport}`, { cache: 'no-store' });
const j = await res.json();
if (live) setData(res.ok ? j : null);
} catch { if (live) setData(null); }
finally { if (live) setLoading(false); }
})();
return () => { live = false; };
}, [sport]);
if (loading) {
return <p className="mono" style={{ fontSize: 12, color: 'var(--text-3)', padding: 20 }}>reading the wire</p>;
}
const events = data?.events || [];
const board = data?.board || [];
return (
<div style={{ maxWidth: 1120, margin: '0 auto', padding: '20px' }}>
{/* HEADER — sport state lives in the tab, per spec. */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
<div>
<h1 className="mono" style={{ fontSize: 24, fontWeight: 800, letterSpacing: '.08em', margin: 0 }}>
THE OFFSEASON DESK
</h1>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-3)', letterSpacing: '.1em', margin: '6px 0 0' }}>
OUTLOOKS REPRICE ON NEWS · NOT GAME ODDS
{data?.as_of ? ` · AS OF ${data.as_of}` : ''}
</p>
</div>
{/* Countdown: ambient, top-right, never the hero (spec). Absent if unknown. */}
{data?.countdown && (
<div style={{ textAlign: 'right' }}>
<div className="mono" style={{ fontSize: 13, fontWeight: 800, color: 'var(--text-1)' }}>
{data.countdown.days} DAYS TO {data.countdown.label.toUpperCase()}
</div>
<div className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>{data.countdown.date}</div>
</div>
)}
</div>
{/* Milestone rail — real dates or nothing. */}
{data?.milestones?.length ? (
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--line)' }}>
{data.milestones.map((m) => (
<span key={`${m.label}${m.date}`} className="mono" style={{ fontSize: 10, color: 'var(--text-3)', letterSpacing: '.08em' }}>
{m.label.toUpperCase()} · {m.date}
</span>
))}
</div>
) : null}
{/* THE HERO: what changed today. */}
<section style={{ marginTop: 28 }}>
<SectionHead>WHAT CHANGED TODAY{events.length ? ` · ${events.length} EVENT${events.length === 1 ? '' : 'S'}` : ''}</SectionHead>
{events.length === 0 ? (
/* QUIET WIRE — the designed empty state, verbatim law. */
<div style={{ marginTop: 12, padding: '18px 16px', border: '1px solid var(--line)' }}>
<div className="mono" style={{ fontSize: 11, letterSpacing: '.1em', color: 'var(--text-3)', marginBottom: 8 }}>QUIET WIRE</div>
<p className="mono" style={{ fontSize: 13, color: 'var(--text-2)', margin: 0, lineHeight: 1.6 }}>
{data?.quiet_since
? `No outlook-moving news since ${data.quiet_since}.`
: 'No outlook-moving news.'}{' '}
<span style={{ color: 'var(--text-1)' }}>We don&apos;t manufacture movement.</span>
</p>
</div>
) : (
<div style={{ marginTop: 12 }}>
{/* ROW ANATOMY: time + event tag + source. */}
{events.map((e, i) => (
<div key={`${e.time}-${i}`} style={{ display: 'flex', gap: 12, alignItems: 'baseline', padding: '10px 0', borderBottom: '1px solid var(--line)' }}>
<span className="mono" style={{ fontSize: 10, color: 'var(--text-3)', minWidth: 92 }}>{e.time}</span>
<span className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.08em', color: tagColour(e.tag), minWidth: 78 }}>
{String(e.tag).toUpperCase()}
</span>
<span style={{ fontSize: 13, color: 'var(--text-1)', flex: 1 }}>{e.text}</span>
{e.source && <span className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>{e.source}</span>}
</div>
))}
</div>
)}
</section>
{/* OUTLOOK ONLY — the spec's honesty block, shown only where the sport declares it. */}
{data?.outlook_only && (
<section style={{ marginTop: 28, border: '1px solid var(--amber)', padding: '16px' }}>
<div className="mono" style={{ fontSize: 11, fontWeight: 800, letterSpacing: '.12em', color: 'var(--amber)', marginBottom: 8 }}>
{data.outlook_only.headline}
</div>
<p style={{ fontSize: 13, color: 'var(--text-2)', margin: '0 0 14px', lineHeight: 1.6 }}>
{data.outlook_only.body}
</p>
{data.outlook_only.rows.map((r) => (
<div key={r.subject} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, padding: '8px 0', borderTop: '1px solid var(--line)' }}>
<span style={{ fontSize: 13, color: 'var(--text-1)' }}>{r.subject}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{r.detail}</span>
{/* Every row carries NOT GRADED. The block exists to say so. */}
<span className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '.08em', color: 'var(--amber)' }}>NOT GRADED</span>
</div>
))}
</section>
)}
{/* SEASON BOARD — open → NOW → VYNDR triplet, with the movement strip. */}
<section style={{ marginTop: 28 }}>
<SectionHead>SEASON BOARD</SectionHead>
{board.length === 0 ? (
<p className="mono" style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 12 }}>
No season-long outlooks priced yet. Nothing shown rather than a board of placeholders.
</p>
) : (
<div style={{ marginTop: 12 }}>
{board.map((r, i) => (
<div key={`${r.subject}-${r.market}-${i}`} style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '10px 0', borderBottom: '1px solid var(--line)' }}>
<span style={{ fontSize: 13, color: 'var(--text-1)', flex: 1 }}>{r.subject}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)', minWidth: 90 }}>{r.market}</span>
{/* open #707080 · now bright · model green — the spec's triplet. */}
{r.open && <span className="mono" style={{ fontSize: 11, color: '#707080' }}>{r.open}</span>}
{r.now && <span className="mono" style={{ fontSize: 11, fontWeight: 700, color: '#F0F0F0' }}> {r.now}</span>}
{r.fair && <span className="mono" style={{ fontSize: 11, fontWeight: 800, color: 'var(--hit, #00D4A0)' }}>FAIR {r.fair}</span>}
<MovementStrip history={r.history} side={r.side} />
</div>
))}
</div>
)}
</section>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
'use client';
import { useEffect, useState } from 'react';
import SectionHead from '@/components/vyndr/SectionHead';
/**
* E12 — the Report archive.
*
* The spec's law, verbatim: **"EVERY ISSUE SHOWS ITS OWN DAY RECORD — THE
* ARCHIVE IS A LEDGER TOO."**
*
* So every row carries the record that issue's reads produced. An archive of
* headlines would let a bad day disappear into a title; this one cannot. A row
* whose record is unknown says UNSETTLED rather than showing a dash that reads
* like zero.
*/
type Issue = {
number?: number; date: string; date_label?: string; headline?: string;
top_read?: { subject?: string; grade?: string } | null;
record?: { hit?: number; miss?: number; line?: string } | null;
};
export default function ReportArchive() {
const [issues, setIssues] = useState<Issue[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let live = true;
(async () => {
try {
const res = await fetch('/api/report', { cache: 'no-store' });
const j = await res.json();
if (live) setIssues(Array.isArray(j?.issues) ? j.issues : []);
} catch { if (live) setIssues([]); }
finally { if (live) setLoading(false); }
})();
return () => { live = false; };
}, []);
const recordText = (r: Issue['record']) => {
if (!r) return null;
if (typeof r.line === 'string' && r.line.trim()) return r.line;
if (typeof r.hit === 'number' && typeof r.miss === 'number') return `${r.hit}${r.miss}`;
return null; // unknown is unknown; never a dash that reads as zero
};
return (
<main style={{ maxWidth: 820, margin: '0 auto', padding: '24px 20px' }}>
<h1 className="mono" style={{ fontSize: 22, fontWeight: 800, letterSpacing: '.08em', margin: 0 }}>
THE VYNDR REPORT
</h1>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-3)', letterSpacing: '.06em', margin: '8px 0 0' }}>
One email per slate day. Top read, what changed, the record. Nothing else.
</p>
<div style={{ marginTop: 24 }}>
<SectionHead>ARCHIVE{issues.length ? ` · ${issues.length} ISSUE${issues.length === 1 ? '' : 'S'}` : ''}</SectionHead>
{loading && <p className="mono" style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 12 }}>loading the archive</p>}
{/* HONEST EMPTY STATE — no issues is a real state, not a broken page. */}
{!loading && issues.length === 0 && (
<div style={{ marginTop: 12, padding: '18px 16px', border: '1px solid var(--line)' }}>
<p className="mono" style={{ fontSize: 13, color: 'var(--text-2)', margin: 0, lineHeight: 1.6 }}>
No issues published yet. The first one goes out on the next slate day, and it will show its
own record like every one after it.
</p>
</div>
)}
{!loading && issues.map((iss) => {
const rec = recordText(iss.record);
return (
<div key={iss.date} style={{ display: 'flex', gap: 14, alignItems: 'baseline', padding: '14px 0', borderBottom: '1px solid var(--line)' }}>
{typeof iss.number === 'number' && (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)', minWidth: 52 }}>N&ordm; {iss.number}</span>
)}
<span className="mono" style={{ fontSize: 11, color: 'var(--text-3)', minWidth: 74 }}>
{iss.date_label || iss.date}
</span>
<span style={{ fontSize: 14, color: 'var(--text-1)', flex: 1 }}>
{iss.headline || 'Untitled issue'}
</span>
{iss.top_read?.subject && (
<span className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>
TOP READ · {iss.top_read.subject.toUpperCase()}
{iss.top_read.grade ? ` ${iss.top_read.grade}` : ''}
</span>
)}
{/* THE LAW: every issue shows its own day record. */}
<span className="mono" style={{ fontSize: 11, fontWeight: 800, color: rec ? 'var(--text-1)' : 'var(--text-3)' }}>
{rec ? `DAY RECORD ${rec}` : 'UNSETTLED'}
</span>
</div>
);
})}
</div>
</main>
);
}
+21
View File
@@ -86,6 +86,27 @@ function mapScanToGradeResult(input = {}) {
side,
grade: input.grade || '—',
confidence,
// ── THE HONEST GRADE FIELDS ────────────────────────────────────────────
// The served grade knows whether its band can actually be separated from
// the baseline, and which proven factors (if any) moved the forecast. Both
// were being computed and thrown away at this boundary. A grade that cannot
// separate must SAY so on the card -- that flag is the honest core, and
// carrying it in an object nobody reads is the same as not having it.
gradeMeaning: (input.served_grade && input.served_grade.meaning) || null,
separatesFromBaseRate: input.served_grade
? input.served_grade.separates_from_base_rate === true : null,
bandRealizedRate: (input.served_grade && input.served_grade.band_realized_rate) ?? null,
// Only factors that ACTUALLY fired, with their proven sign. No narrative on
// props where nothing fired.
factorsApplied: (input.factor_adjustment && Array.isArray(input.factor_adjustment.applied))
? input.factor_adjustment.applied.map((a) => ({
factor: a.factor,
direction: a.multiplier > 1 ? 'up' : 'down',
multiplier: a.multiplier,
}))
: [],
refusalReason: (input.served_grade && input.served_grade.state !== 'graded')
? input.served_grade.meaning : null,
// DATA SEMANTICS (Session 58): projection is MODEL output and must never
// be fabricated. The old fallback displayed the LINE as the projection —
// the audit's model==line / +0% edge degenerate. No projection → the
+100
View File
@@ -0,0 +1,100 @@
/* ============================================================
WAVE D1 — the four motion primitives (E17, E18, E27, E28).
All four were ABSENT; the gap audit lists them as self-contained with no
dependency, which is why they come before any surface that would embed them.
Plain CommonJS so .tsx imports it AND the Jest suite requires it directly
(the vyndrTokens.js / archetypes.js pattern).
DESIGN-SPEC PART 4 governs: motion is for PERCEIVED SPEED, never decoration.
Every primitive here is skipped outright when the viewer asks for reduced
motion -- an accessibility preference is not a style to soften.
============================================================ */
/** Honour the OS-level preference. Server-side, assume reduced (no motion). */
function prefersReducedMotion() {
if (typeof window === 'undefined' || !window.matchMedia) return true;
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
catch { return true; }
}
/**
* E17 — nudge(): the acknowledgement pulse.
*
* Confirms an action REGISTERED without claiming it finished. Deliberately
* short: a long animation reads as latency, which is the opposite of what a
* perceived-speed primitive is for.
*/
const NUDGE_MS = 180;
function nudge(el, opts = {}) {
if (!el || prefersReducedMotion()) return () => {};
const scale = opts.scale ?? 1.03;
const prev = el.style.transition;
el.style.transition = `transform ${NUDGE_MS}ms cubic-bezier(.2,.8,.2,1)`;
el.style.transform = `scale(${scale})`;
const t = setTimeout(() => {
el.style.transform = '';
setTimeout(() => { el.style.transition = prev; }, NUDGE_MS);
}, NUDGE_MS);
return () => clearTimeout(t);
}
/**
* E18 — boot stagger: rows arrive in sequence, not all at once.
*
* The delay is CAPPED. Uncapped, a 40-row board would make the last row wait
* 1.2s, and a stagger that makes content late has become the thing it exists to
* disguise.
*/
const STAGGER_STEP_MS = 28;
const STAGGER_CAP_MS = 240;
function bootStagger(index, opts = {}) {
if (prefersReducedMotion()) return { animationDelay: '0ms', opacity: 1 };
const step = opts.step ?? STAGGER_STEP_MS;
const cap = opts.cap ?? STAGGER_CAP_MS;
return { animationDelay: `${Math.min(index * step, cap)}ms` };
}
/**
* E27 — row hover: the pointer-only affordance.
*
* Returned as a props object rather than CSS so a row cannot acquire a hover
* state on touch, where there is no hover and the style would stick after a tap.
*/
function rowHover(opts = {}) {
const tint = opts.tint || 'rgba(255,255,255,.028)';
return {
onMouseEnter: (e) => { if (e.currentTarget) e.currentTarget.style.background = tint; },
onMouseLeave: (e) => { if (e.currentTarget) e.currentTarget.style.background = ''; },
};
}
/**
* E28 — reveal on intersection.
*
* Returns an unobserve function; a caller that forgets it leaks an observer per
* row. Without IntersectionObserver (or under reduced motion) the element is
* shown IMMEDIATELY -- content is never hidden behind a capability check.
*/
function revealOnIntersect(el, onReveal, opts = {}) {
if (!el) return () => {};
if (prefersReducedMotion() || typeof IntersectionObserver === 'undefined') {
if (typeof onReveal === 'function') onReveal(el);
return () => {};
}
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
if (typeof onReveal === 'function') onReveal(e.target);
io.unobserve(e.target);
}
}
}, { rootMargin: opts.rootMargin || '0px 0px -10% 0px', threshold: opts.threshold ?? 0.05 });
io.observe(el);
return () => { try { io.disconnect(); } catch { /* already gone */ } };
}
module.exports = {
nudge, bootStagger, rowHover, revealOnIntersect, prefersReducedMotion,
NUDGE_MS, STAGGER_STEP_MS, STAGGER_CAP_MS,
};
+1
View File
@@ -16,6 +16,7 @@
gating them would be a monetization regression. We gate only the genuinely
personal surfaces (a user's own ledger, bets, account, alerts). */
const GATED_ROUTES = [
'/studio', // the content desk: private review before posting
'/desk', // Session 63 (A1-S4) — the founder's media surface (+ backend allowlist)
'/ledger',
'/tracker',
File diff suppressed because one or more lines are too long