diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 7dd0707..3cbe0c8 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -1,7 +1,39 @@ # VYNDR — Build State ## Last Updated -2026-07-10 +2026-07-11 + +## Session S2 (a1 board, 2026-07-11) — BUILT ✅ COMPLIANCE + APPROVAL PACK + +Branch worktree off `day1/a1-board`. 2429 tests (206 suites), web build +exit 0 (`next build --webpack`). No backend routes touched. +- **/responsible-gambling REBUILT sincerely** — 21+, 1-800-GAMBLER + (1-800-426-2537) as the primary helpline, 17-state resource list, + warning signs, state self-exclusion guidance, links to the EXISTING + /settings Responsible Play + Danger Zone surfaces. Zero marketing + adjacency (no pricing/scan/upgrade links — a test enforces it). +- **/terms + /privacy redrafted** as honest approval-pack drafts (marked + DRAFT: JULY 2026). Entity details are placeholder tokens Kev must fill: + [ENTITY NAME], [STATE OF FORMATION], [ARBITRATION VENUE], + [CONTACT EMAIL]. Privacy sub-processors now match reality (Stripe, + Supabase, Resend, Sentry; PostHog described honestly — autocapture off, + banner is disclosure-only, NOT consent-gated). +- **/methodology NEW** (server component + metadata, footer COMPANY link, + OPEN_ROUTES): pipeline → engine → 11-step letter grades, refusals + (insufficient_data), VYNDR Originals (41), Ledger settle (locked at + grade time, box-score settle, CLV vs captured close, n≥20, public + revisions), why misses are public. +- **/about audited** to North Star framing: data intelligence platform, + THE PROOF card (Ledger + n≥20 + refusals + methodology link). Kept the + "give it back" h1 (Phase-E test) + Wordmark (QA.5). +- **Footer**: helpline updated 1-800-522-4700 → 1-800-GAMBLER; Methodology + link added. Compliance audit: footer is global in the root layout, no + nested layout or CSS suppresses it on any page — zero pages missing it. +- **content/articles/** — 5 Ghost-ready seed drafts (front matter, VOICE + v1.1, zero exclamation points, test-enforced) + docs/GHOST-PUBLISHING.md + manual runbook (no Ghost credentials used; nothing auto-posts). +- Tests: tests/unit/compliancePages.test.js (31 assertions, source-text + style). ## Current Phase SHIP BUILD v59.0 — Overnight session: ledger team/opponent addendum, diff --git a/content/articles/how-streaks-lie.md b/content/articles/how-streaks-lie.md new file mode 100644 index 0000000..4539276 --- /dev/null +++ b/content/articles/how-streaks-lie.md @@ -0,0 +1,35 @@ +--- +title: How Streaks Lie +slug: how-streaks-lie +excerpt: Most hot streaks are soft schedules wearing a costume. How to read a streak against what it was built on, the way the VYNDR lens does. +tags: [streaks, the-read-room] +status: draft +--- + +Most hot streaks are soft schedules wearing a costume. + +A 7-game hit streak against 5.2 ERA pitching is not form. It is a calendar. The player did his job — but the streak's real author was the schedule maker, and tonight the schedule hands him a different assignment. Here is exactly how the trick works, and how to see through it. + +## The raw streak is the industry's favorite lie + +A streak, stated alone — "8 straight games over 19.5 points," "hits in 7 straight" — carries an implication: this will continue. Books and touts both love the raw number, for opposite reasons. Touts sell it as momentum. Books price it, correctly, as bait, and shade the line into the streak because they know the public buys continuation. + +The number itself is true. The implication is the lie. Whether a streak continues has almost nothing to do with its length and almost everything to do with what it was built against and what it faces tonight. + +## The three questions that expose a streak + +**1. What was it built against?** Pull the opponents inside the streak window from the game log. A scoring streak assembled against bottom-ten defenses is a description of the schedule. The same streak built through two top-five defenses is information about the player. + +**2. What does tonight look like?** A hit streak fed on soft right-handed pitching meets a 2.80 ERA starter tonight. The streak is 7 games old; the thing that produced it is not in the building. In MLB this is unusually concrete — you know the opposing starter by name, with his numbers, hours before first pitch. + +**3. Has the price already eaten it?** By game 6 of a public streak, the line has moved. You are no longer betting on the player continuing; you are betting on him continuing at a worse number than anyone got last week. Streak buzz is negative closing line value sold as a story. + +## The lens rule + +On VYNDR, no raw streak ever renders alone. That is a hard rule in the code, not an editorial habit. Every streak row carries the opponents it was built against, tonight's matchup — including the opposing starter and his ERA for MLB bats — a difficulty rating of tonight versus the streak's diet, and a one-line read composing the three. When we cannot rate the matchup honestly, the row says less. We do not invent context to fill a box. + +Sometimes the lens confirms the streak: built against real pitching, facing more of the same, line still fair. Those exist, and they are worth something precisely because most streaks fail the checks. + +## The habit + +Next time a streak headline reaches you, ask the three questions before the number impresses you. Who built it. Who is here tonight. What does it cost now. Magicians hate this. So do the books. diff --git a/content/articles/how-vyndr-grades-a-prop.md b/content/articles/how-vyndr-grades-a-prop.md new file mode 100644 index 0000000..beb1010 --- /dev/null +++ b/content/articles/how-vyndr-grades-a-prop.md @@ -0,0 +1,37 @@ +--- +title: How VYNDR Grades a Prop +slug: how-vyndr-grades-a-prop +excerpt: From the line feed to the letter. What actually happens inside the pipeline, including the part where the model refuses. +tags: [methodology, the-read-room] +status: draft +--- + +Most grading products are a vibe wearing a letter. Here is what ours actually does. + +## The pipeline, start to finish + +Grades on VYNDR are produced by a scheduled pipeline, several times a day. Nothing is graded on demand, and nothing is graded twice at two different lines without both showing on the record. + +The run starts with the current prop lines from sportsbook feeds — real numbers, captured with a timestamp. For each prop, the system builds a feature vector: recent form (last-5 and last-20 averages, and the variance between them), opponent strength against that exact stat, pace, home or away, rest days, schedule density, batter-versus-pitcher history for MLB, per-90 rates and expected-goals context for soccer. + +Then the engine does the only thing that matters: it compares the model's projection to the posted line. In both directions. The over and the under are graded separately, and the side with more conviction survives. The output is an 11-step letter, F through A+, with a confidence score behind it. + +## What a letter means + +A letter grade is a probabilistic assessment of one side of one line at one moment. An A does not mean the bet wins. It means the gap between our projection and the book's number was wide, in our favor, at the timestamp on the entry. + +The model is wrong regularly. Our public ledger shows exactly how regularly, misses listed by name in the same format as the wins. That is not a disclaimer we were forced to write. It is the design. + +## The part nobody else advertises: refusals + +If the pipeline cannot build a real projection — not enough recent games, no baseline rate, a data source down — the model refuses to grade. No letter renders. The prop shows an absent state, and a free read is not consumed. + +This matters more than it sounds. A grade with nothing behind it still looks like a grade. It would fill the page, and it would be a lie with good typography. So the rule across the whole platform is simple: market numbers are captured, never computed. Model numbers are labeled as model output. Where the data is missing, the surface says less. Absent beats wrong. + +## After the grade + +Every grade is locked to the ledger at the moment it is made — line, odds, timestamp. After the games end, it settles against the real box score. Hit, miss, or push, recorded once. Alongside the result we track closing line value: whether the market moved toward our side or away from it between our timestamp and the close. That number, not the win-loss column, is the standard evidence of real edge. + +And no percentage renders anywhere on VYNDR until at least 20 grades have settled. Below that the surface says RECORD BUILDING, because a percentage on 6 settles is a marketing number, and we do not publish marketing numbers. + +The full methodology lives at vyndr.app/methodology. The record lives at vyndr.app/ledger. Draw your own conclusion. diff --git a/content/articles/the-vyndr-originals.md b/content/articles/the-vyndr-originals.md new file mode 100644 index 0000000..6eb118c --- /dev/null +++ b/content/articles/the-vyndr-originals.md @@ -0,0 +1,37 @@ +--- +title: "The VYNDR Originals: 41 Archetypes and Why They Exist" +slug: the-vyndr-originals +excerpt: TORCH, BOMBER, GHOST, CONDUCTOR. What the archetype system is, how a player earns one, and why it changes which props you should trust. +tags: [archetypes, the-read-room] +status: draft +--- + +Every player profile on VYNDR carries a word like TORCH or BOMBER. This is not decoration. It is the compressed answer to a question the box score never asks: how does this player produce, and which of his props can you actually trust. + +## What an archetype is + +An archetype is a classification of production pattern, scored from a player's real season statistics. There are 41 VYNDR Originals across four sports — 15 for the NBA, 5 built specifically for the WNBA, 15 for MLB, and 6 for soccer. A player earns one by his numbers, not by reputation, and he re-earns it as the season moves. Nothing is assigned once and left to rot. + +A few examples, so the vocabulary means something: + +- **TORCH** — high-usage, shot-dependent scorer. His points track his touches almost one to one. +- **CONDUCTOR** — the offense runs through him, for other people. Assists are the stable prop; his own scoring swings with shot selection. +- **BOMBER** — the power bat. Home runs and total bases behave differently for him than for a contact hitter, and his strikeout risk rides along with the power. +- **GHOST** — the speed threat. He beats you on the bases, in the gaps, on the margins the slow numbers miss. +- **SURGE** — the usage sponge. His baseline is ordinary until a star sits, and then his points prop is a different prop entirely. + +## Why it changes the read + +Different archetypes have different reliable props and different volatile ones. Points for a TORCH is a stable leg because it is tied to a stable input, his usage. Assists for that same player float with game script. For a CONDUCTOR it is exactly reversed. + +This is the practical content of the system: a 7.5 assist line means one thing hanging on a CONDUCTOR and another thing entirely hanging on a scorer who happened to have two good passing nights. Same number, different prop. The archetype tells you which one you are looking at. + +Most players are not one thing, so a classification carries a primary archetype, an optional secondary, and a weighted blend. A player can be 70 percent TORCH and 30 percent CONDUCTOR, and his prop behavior will sit exactly where that mix predicts. + +## Where it comes from + +The classification runs on the same stats pipeline that feeds the grades — season averages, usage, rate stats, all from league data. When the model grades a prop for a BOMBER, the archetype is context the engine already holds, not a label bolted on afterward. + +The names are ours. The industry has spent decades on labels like "volume scorer" and "floor general," which describe a player politely and tell you nothing about his prop sheet. The VYNDR Originals were named for the thing that matters: what the production pattern does to the numbers you are about to bet on. + +Learn the vocabulary once and the slate starts reading differently. That is the point. diff --git a/content/articles/what-clv-is.md b/content/articles/what-clv-is.md new file mode 100644 index 0000000..99dd284 --- /dev/null +++ b/content/articles/what-clv-is.md @@ -0,0 +1,31 @@ +--- +title: "CLV: The Only Number That Proves an Edge" +slug: what-clv-is +excerpt: Closing line value in plain language. Why beating the close matters more than winning the bet, and how VYNDR captures it on every grade. +tags: [clv, methodology, the-read-room] +status: draft +--- + +You can win a bet with a bad read and lose one with a great read. That is variance, and it is why the win-loss column — the number everyone stares at — is the slowest, noisiest possible way to judge a model. There is a faster, cleaner number. The industry calls it CLV: closing line value. + +## The idea in one paragraph + +The closing line is the book's number at the moment the game starts. It is the sharpest price of the day, because by then the market has absorbed every injury report, every lineup, and every dollar of smart money. If you consistently bet numbers that are better than where the line closes, you were consistently ahead of the market's final opinion — and over time, that is what winning is made of. Beating the close is the standard evidence of real edge. One result proves nothing. A season of positive CLV proves the process. + +## A concrete example + +VYNDR grades a total-bases over at a line of 1.5, odds -120, at 2:14 PM. By first pitch the same over is priced at -145. The market spent the afternoon agreeing with the read — it moved 25 cents toward our side after we posted. That is positive CLV, and it is positive whether the player finishes with 3 total bases or 0. + +Now reverse it. The read settles as a win, but the close drifted to -105 — the market moved away from us and we happened to cash anyway. The result column says we were right. The CLV column says we were lucky. Over 20 bets you cannot tell those apart by results. Over 200, CLV has already told you. + +## How VYNDR captures it + +Every grade is locked to the ledger at grade time: line, odds, timestamp. From then until the game starts, the pipeline keeps re-capturing the book's current number on every pass, and the last capture before first pitch stands as the close. Real numbers, from the feed, at a timestamp — never estimated, never backfilled. + +At settlement the ledger computes CLV signed by side: for an over, a close that moved lower means the market came toward the graded side, which counts as beating the close. For an under, the inverse. Each entry records it, and the aggregate renders only after the same rule every percentage on VYNDR obeys — at least 20 settled entries, or the surface says RECORD BUILDING. + +## Why we lead with it + +A results record can be dressed up. A timestamped CLV record cannot — it is the market itself grading our timing, entry by entry, in public. When the line moves three minutes after the wire posts, someone is watching. Good. + +If you take one habit from this piece: judge any model — ours included — by whether it beats the close over a real sample. Everything else is a story about a hot week. diff --git a/content/articles/why-our-misses-are-public.md b/content/articles/why-our-misses-are-public.md new file mode 100644 index 0000000..0bded94 --- /dev/null +++ b/content/articles/why-our-misses-are-public.md @@ -0,0 +1,33 @@ +--- +title: Why Our Misses Are Public +slug: why-our-misses-are-public +excerpt: Every miss stays on the Ledger, by name, in the same format as the wins, permanently. The two reasons, and why nobody else in the industry can copy this. +tags: [the-ledger, methodology] +status: draft +--- + +Open the VYNDR Ledger on any morning and you will find last night's misses listed by name, in the same format, the same placement, and the same energy as the wins. Wilson over 23.5 settled at 19. It is on the record. It stays on the record. + +This is the strangest thing about the product to people who know the industry, so it deserves a straight explanation. + +## Reason one: a curated record is not a record + +The entire value of a grading model rests on its record being real. Not the highlight reel — the record. The moment you delete one bad night, the whole thing becomes marketing, and every number on the page inherits the doubt. + +So the mechanics make curation impossible rather than merely discouraged. Every grade is written to the ledger at the moment it is made, locked with the line, the odds, and a timestamp. Settled entries are never edited and never deleted. If the market moves hard against a grade during the day and the model revises, the original letter stays on the record, struck through, next to the revision. Nothing is revised silently. + +And no percentage renders anywhere until at least 20 grades have settled. A hot 5-1 start is a coin flip wearing a suit. We do not publish it. + +## Reason two: nobody else can afford to + +The standard industry model is a performance. Post the wins loud, let the losses expire in the feed, and rebuild the persona every time the variance runs cold. It works, for a while, because most audiences never audit. + +That model has one structural weakness: it cannot survive a public, timestamped, append-only record. Ours is built on one. Publishing the misses is not bravery — it is the one competitive position the performance accounts cannot follow us into. Their numbers do not survive daylight. Ours are built to live in it. + +## What a miss actually is + +A miss is not a scandal. The model grades probabilistically, which means it is wrong regularly, on purpose, as a condition of being right more often than the market's price implies. A 60 percent proposition misses 4 times in 10. The question that matters is not whether misses happen — it is whether the process that produced them was sound, and the number that answers that is closing line value, tracked on every entry, not the win-loss column. + +When a high-profile read misses, we post it the same way we post everything: the number, the reason the read existed, and the result. No apology thread. No celebration either. The format is the apology and the flex at once. + +The record talks. We don't have to. diff --git a/docs/GHOST-PUBLISHING.md b/docs/GHOST-PUBLISHING.md new file mode 100644 index 0000000..e64b1ab --- /dev/null +++ b/docs/GHOST-PUBLISHING.md @@ -0,0 +1,66 @@ +# Ghost Publishing Runbook — blog.vyndr.app + +How to move the seed articles in `content/articles/` into Ghost as drafts. +Manual by design: **nothing auto-posts, anywhere, ever** (NORTH STAR — Kev is +the publisher of everything). Claude Code has no Ghost credentials and never +touches the Ghost admin; this runbook is the entire publishing pipeline. + +## What lives where + +- Source of truth: `content/articles/*.md` in this repo. Edit here first, + then re-paste into Ghost — never edit only in Ghost, or the repo copy rots. +- Each file has YAML front matter (`title`, `slug`, `excerpt`, `tags`, + `status: draft`). The front matter is metadata for YOU to key into Ghost's + fields — do not paste the front matter block into the post body. + +## Per-article steps (about 3 minutes each) + +1. Open `https://blog.vyndr.app/ghost/` and sign in (your credentials). +2. Click **New post**. +3. Title: copy the `title` from the front matter into the title field. +4. Body: in the editor type `/` and choose the **Markdown** card (or type + ```/markdown```). Paste everything BELOW the closing `---` of the front + matter into the card. Ghost renders the `##` headings, bold, and lists. + - Do not paste into the plain editor surface — straight pasting can + flatten headings depending on Ghost version. The Markdown card is exact. +5. Open **Post settings** (gear icon, top right): + - **Post URL**: set to the front-matter `slug` (e.g. `what-clv-is`). + - **Excerpt**: paste the front-matter `excerpt`. + - **Tags**: add the front-matter tags (e.g. `methodology`, `the-read-room`). +6. Leave the post as a **draft** — do NOT click Publish during seeding. +7. Preview in Ghost. Checklist before it ever goes live: + - Zero exclamation points (VOICE v1.1 — deadpan, no exceptions). + - No banned vocabulary: "lock", "tail", "fade" as calls to action, + guarantee language, hype emoji. If it sounds like the industry, cut it. + - Every record/percentage claim obeys the product's n≥20 gate — if the + live Ledger cannot back a number in the article, remove the number. + - Links point at `vyndr.app/...` production paths (`/methodology`, + `/ledger`), not localhost or staging. + +## The five seed articles (in publish order) + +| File | Title | +| --- | --- | +| `how-vyndr-grades-a-prop.md` | How VYNDR Grades a Prop | +| `why-our-misses-are-public.md` | Why Our Misses Are Public | +| `what-clv-is.md` | CLV: The Only Number That Proves an Edge | +| `the-vyndr-originals.md` | The VYNDR Originals: 41 Archetypes and Why They Exist | +| `how-streaks-lie.md` | How Streaks Lie | + +Suggested cadence: one per week, methodology first (it is the page every +other article links back to conceptually). Publishing is your call, on your +schedule — this table is an ordering, not a scheduler. + +## Updating a published article + +1. Edit the markdown in `content/articles/` and commit. +2. In Ghost, open the post, delete the old Markdown card, insert a fresh one, + paste the updated body. Keep the same Post URL — never change a slug after + publish (dead links). + +## Explicitly out of scope + +- No Ghost Admin API integration, no automation, no scheduled posts. If that + ever changes it goes through a spec in `specs/` first and Kev still presses + the button. +- No exclamation points. Still. diff --git a/tests/unit/compliancePages.test.js b/tests/unit/compliancePages.test.js new file mode 100644 index 0000000..19285cf --- /dev/null +++ b/tests/unit/compliancePages.test.js @@ -0,0 +1,189 @@ +// Session 2 (A1 board) — compliance + approval pack. The legal/trust surfaces +// are asserted against their source text (plain-JS Jest config, no TS +// transform — same pattern as vyndrAppShell.test.js). + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const WEB = path.join(ROOT, 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); +const readRoot = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); +const exists = (rel) => fs.existsSync(path.join(WEB, rel)); + +describe('S2 — /methodology page', () => { + it('exists as a server component with metadata', () => { + expect(exists('app/methodology/page.tsx')).toBe(true); + const src = read('app/methodology/page.tsx'); + expect(src).not.toContain("'use client'"); + expect(src).toContain('export const metadata'); + }); + + const src = read('app/methodology/page.tsx'); + + it('covers the pipeline → engine → letter grades', () => { + expect(src).toContain('The pipeline'); + expect(src).toContain('feature vector'); + expect(src).toMatch(/11-step letter grade/); + expect(src).toContain('A+'); + }); + + it('covers refusals when the model has no projection', () => { + expect(src).toContain('When the model refuses'); + expect(src).toContain('refuses to grade'); + expect(src).toContain('Absent beats wrong'); + }); + + it('covers the VYNDR Originals archetype system', () => { + expect(src).toContain('VYNDR Originals'); + expect(src).toContain('41'); + ['TORCH', 'BOMBER', 'GHOST', 'CONDUCTOR'].forEach((a) => expect(src).toContain(a)); + }); + + it('covers ledger settlement: lock at grade time, box-score settle, CLV, n≥20', () => { + expect(src).toContain('How the Ledger settles'); + expect(src).toContain('locked with the exact line, the odds, and a timestamp'); + expect(src).toContain('real box score'); + expect(src).toContain('closing line'); + expect(src).toContain('n≥20'); + expect(src).toContain('RECORD BUILDING'); + }); + + it('covers why misses are public', () => { + expect(src).toContain('Why the misses are public'); + expect(src).toContain('same format'); + }); + + it('states the not-a-sportsbook position and links the ledger', () => { + expect(src).toContain('not a sportsbook'); + expect(src).toContain('/ledger'); + }); + + it('is registered as an open (ungated) route and linked from the footer', () => { + const routes = require('../../web/src/lib/routes'); + expect(routes.OPEN_ROUTES).toContain('/methodology'); + expect(routes.isGatedRoute('/methodology')).toBe(false); + expect(read('components/Footer.tsx')).toContain("href: '/methodology'"); + }); +}); + +describe('S2 — /terms and /privacy drafts', () => { + it('terms page exists with the non-negotiable disclosures', () => { + const src = read('app/terms/page.tsx'); + expect(src).toContain('not a sportsbook'); + expect(src).toContain('21 years of age'); + expect(src).toContain('Stripe'); + expect(src).toContain('1-800-GAMBLER'); + }); + + it('privacy page exists with the real sub-processor list', () => { + const src = read('app/privacy/page.tsx'); + expect(src).toContain('never sell'); + ['Stripe', 'Supabase', 'PostHog', 'Resend', 'Sentry'].forEach((p) => expect(src).toContain(p)); + }); + + it('entity details are placeholder tokens, not invented facts', () => { + const terms = read('app/terms/page.tsx'); + const privacy = read('app/privacy/page.tsx'); + ['[ENTITY NAME]', '[STATE OF FORMATION]', '[ARBITRATION VENUE]', '[CONTACT EMAIL]'].forEach((t) => + expect(terms).toContain(t), + ); + ['[ENTITY NAME]', '[CONTACT EMAIL]'].forEach((t) => expect(privacy).toContain(t)); + // The previous draft asserted an unverified LLC + emails — keep them out + // until Kev fills the placeholders. + [terms, privacy].forEach((src) => { + expect(src).not.toContain('VYNDR Intelligence LLC'); + expect(src).not.toContain('legal@vyndr.app'); + expect(src).not.toContain('privacy@vyndr.app'); + }); + }); +}); + +describe('S2 — /responsible-gambling (sincere rebuild)', () => { + const src = read('app/responsible-gambling/page.tsx'); + + it('carries 1-800-GAMBLER as the primary helpline and the 21+ mark', () => { + expect(src).toContain('1-800-GAMBLER'); + expect(src).toContain('tel:18004262537'); + expect(src).toContain('21+'); + }); + + it('lists state resources for the major legal states', () => { + ['Michigan', 'New Jersey', 'New York', 'Pennsylvania', 'Ohio', 'Illinois'].forEach((s) => + expect(src).toContain(s), + ); + }); + + it('links the existing self-exclusion / settings surfaces', () => { + expect(src).toContain('/settings'); + expect(src).toContain('Self-exclusion'); + }); + + it('links national support resources', () => { + ['ncpgambling.org', 'gamblersanonymous.org', 'gamtalk.org'].forEach((r) => expect(src).toContain(r)); + }); + + it('has no marketing adjacency (no pricing/upgrade/scan funnels)', () => { + ['/pricing', '/upgrade', '/scan', '__goPaywall'].forEach((bad) => expect(src).not.toContain(bad)); + }); +}); + +describe('S2 — compliance footer (global)', () => { + it('the root layout mounts the single global Footer', () => { + expect(read('app/layout.tsx')).toContain('