From 8cf9cbfc26229cda83dff531e06c1a77b04cfdb7 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 20 Jul 2026 18:22:00 -0400 Subject: [PATCH] Price-layer token foundation + the price triplet, built natively on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHASE 0 finding, reported before building: the token layer this order asked me to establish ALREADY EXISTS and already matches HANDOFF.md exactly. web/src/app/globals.css :root carries the design's surfaces, borders, text ramp, fonts and grade colours byte-for-byte (aligned 2026-07-16), and lib/colorContract.js already encodes the green-is-edge-only and glow-is-A-tier-only laws with a test enforcing them. The stack is Tailwind v4 CSS-first (no config file) with components styled by inline style={{}} reading var(--x) — 1,916 such reads — so CSS custom properties are the only vehicle the stack natively consumes. Creating a second parallel layer would have meant two competing sources of truth, so this EXTENDS the existing one. PHASE 1 — additive only. globals.css gains one colour the system did not have, the priced-out blue (#8fb2de + tints), plus a tokenized A-tier glow and the fair-leg tints. The block writes the LAWS into the token layer itself — green = takeable edge only, glow = A-tier only, amber = caution + the fair leg, red = miss/negative only, blue = edge priced out, JetBrains Mono = all data — and a test asserts every newly-declared name is new (zero collisions, zero overrides). No existing hardcoded style was touched and no live surface was migrated: the diff over existing files is 149 insertions, 0 deletions. PHASE 2 — lib/valueState.js is the single verdict function; the component renders what it returns and never re-derives one. VALUE fires only on ev >= 2 AND a takeable price, mirroring src/config/valueEngine.js with a test that cross-checks both files and fails on drift. Five states: VALUE (green), EDGE-NOT-TAKEABLE (blue), NO EDGE (grey, stated at full voice), QUARANTINE (model leg withheld, book+fair stand), REFUSAL (nothing rendered). Free tier is gated at the wire — tierGating strips model_odds and sets model_price_locked, so the lock is real rather than a blur over data already sent; book and fair pass through on every tier because the fair leg is never the paywall. Wired into the landing hero (data was already on /api/hero-prop) and the read card, where the projection block reads first and the triplet sits beside it, not in place of it. Ledger and public profile are out of scope — no fair-odds columns exist there. PHASE 3 — induced all six states in a real browser and read back computed styles, not just markup. Green resolves on VALUE alone: rgb(0,212,160) on the model leg and verdict; the +11.7%-EV-at-210 row renders rgb(143,178,222) blue and a white model leg; quarantine shows MODEL "—" with book and fair intact; refusal renders no legs at all; free tier renders a lock bar with book -120 and fair -104 still honest. Landing hero on live data: book -153, fair -129, model -343, VALUE +21.1% vs fair at +28.1% EV. At a real 390px column the three legs hold at 117px each with no horizontal overflow and fair no smaller than its neighbours. Induction caught a real bug that markup review would not have: the "VS FAIR" figure compared BOOK to fair, printing "VALUE · -6.5% VS FAIR" — a contradiction on screen. The design's own two worked examples pin the formula as MODEL minus FAIR in implied-probability percentage points; modelVsFair now reproduces both exactly (+2.9 and -1.8) and a test locks them. The figure is shown only when its sign agrees with the verdict, so a row that clears the EV bar on the book price while our price sits level with fair leads with the EV instead of a number that reads as a contradiction. Tests 3539 passed / 290 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj --- src/config/tiers.js | 8 + src/utils/tierGating.js | 14 + tests/unit/priceTriplet.test.js | 301 ++++++++++++++++ web/src/app/globals.css | 49 +++ web/src/app/scan/page.tsx | 17 + web/src/components/LiveHeroProp.tsx | 38 ++ web/src/components/vyndr/GradeResultCard.tsx | 29 ++ web/src/components/vyndr/PriceTriplet.tsx | 345 +++++++++++++++++++ web/src/components/vyndr/index.ts | 2 + web/src/lib/gradeAdapter.js | 41 ++- web/src/lib/valueState.js | 184 ++++++++++ 11 files changed, 1027 insertions(+), 1 deletion(-) create mode 100644 tests/unit/priceTriplet.test.js create mode 100644 web/src/components/vyndr/PriceTriplet.tsx create mode 100644 web/src/lib/valueState.js diff --git a/src/config/tiers.js b/src/config/tiers.js index b24e310..926bc3e 100644 --- a/src/config/tiers.js +++ b/src/config/tiers.js @@ -77,6 +77,10 @@ const TIERS = Object.freeze({ // Session 64 — per-read directional-CLV badge. Analyst+Desk only; // Free never RECEIVES the data (gated server-side, not hidden client-side). clv_badge: true, + // Session 66 — VYNDR's OWN price (model_odds) on the price triplet. + // Analyst+Desk. Free keeps BOOK and FAIR: the de-vigged honest number is + // the hook and is NEVER the paywall — only our own price gates. + model_price: true, }), desk: Object.freeze({ scans_per_day: Infinity, @@ -94,6 +98,10 @@ const TIERS = Object.freeze({ // Session 64 — per-read directional-CLV badge. Analyst+Desk only; // Free never RECEIVES the data (gated server-side, not hidden client-side). clv_badge: true, + // Session 66 — VYNDR's OWN price (model_odds) on the price triplet. + // Analyst+Desk. Free keeps BOOK and FAIR: the de-vigged honest number is + // the hook and is NEVER the paywall — only our own price gates. + model_price: true, }), }); diff --git a/src/utils/tierGating.js b/src/utils/tierGating.js index 366dc31..51044ed 100644 --- a/src/utils/tierGating.js +++ b/src/utils/tierGating.js @@ -55,6 +55,20 @@ function applyTierGating(result, tierName) { if (!canAccess(tierName, 'alt_line_ladder')) delete deskGated.alt_lines; if (!canAccess(tierName, 'kelly_sizing')) delete deskGated.kelly; + // Session 66 — the PRICE TRIPLET's model leg. Free never RECEIVES + // `model_odds`; it is stripped here, at the wire, not blurred in the client. + // `book_odds` / `fair_odds` / `ev_pct` deliberately pass through on EVERY + // tier: the de-vigged fair number is the free-tier hook, and the fair leg is + // never the paywall. `model_price_locked` tells the card to render the lock + // teaser rather than an absent leg, so a gated price is never mistaken for a + // missing one. + if (!canAccess(tierName, 'model_price')) { + delete deskGated.model_odds; + if (deskGated.book_odds != null && deskGated.fair_odds != null) { + deskGated.model_price_locked = true; + } + } + if (canAccess(tierName, 'reasoning_visible')) { // Paid tier — everything else passes through unchanged. return deskGated; diff --git a/tests/unit/priceTriplet.test.js b/tests/unit/priceTriplet.test.js new file mode 100644 index 0000000..309f659 --- /dev/null +++ b/tests/unit/priceTriplet.test.js @@ -0,0 +1,301 @@ +/* ============================================================ + Session 66 — THE PRICE LAYER: token layer + the five honesty states. + + Two things are locked here: + 1. The TOKEN LAYER is additive and complete — the design bundle's values + exist in globals.css and match HANDOFF.md byte-for-byte. + 2. The LAWS are enforceable, not folklore — the verdict function is the + single place "is this value?" is answered, and it can never say VALUE + on a juiced price. + ============================================================ */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); + +const CSS = read('web/src/app/globals.css'); +const HANDOFF = read('specs/design-reference/HANDOFF.md'); + +const vs = require('../../web/src/lib/valueState'); +const backendValue = require('../../src/config/valueEngine'); + +// ── 1. TOKEN LAYER — the design's values, present and correct ──────────── +describe('token layer — HANDOFF values resolve in globals.css', () => { + // Each entry: [css custom property, expected hex, why it exists] + const TOKENS = [ + ['--bg-0', '#06060B'], // void + ['--bg-1', '#0E0E14'], // card + ['--bg-2', '#14141E'], // elevated + ['--hairline', '#101018'], // row hairline + ['--bg-deep', '#0A0A10'], // deep panel + ['--border', '#1E1E2A'], + ['--border-hi', '#2A2A38'], + ['--text-0', '#F0F0F0'], + ['--text-1', '#B8BCC8'], + ['--text-2', '#707080'], + ['--text-3', '#4a4a58'], + ['--g-a', '#00d4a0'], // signal green + ['--amber', '#ffb347'], + ['--miss', '#FF4757'], + ['--priced-out', '#8fb2de'], // NEW this session + ]; + + test.each(TOKENS)('%s is declared as %s', (name, hex) => { + const m = CSS.match(new RegExp(`${name}\\s*:\\s*([^;]+);`)); + expect(m).toBeTruthy(); + expect(m[1].trim().toLowerCase()).toBe(hex.toLowerCase()); + }); + + it('every hex above also appears in HANDOFF.md (design is the source)', () => { + // The priced-out blue comes from the triplet file, not HANDOFF's token list. + for (const [, hex] of TOKENS.filter(([n]) => n !== '--priced-out')) { + expect(HANDOFF.toLowerCase()).toContain(hex.toLowerCase()); + } + }); + + it('grade colors follow the design: A green, B white, C grey, D/F red', () => { + expect(CSS).toMatch(/--g-a:\s*#00d4a0/i); + expect(CSS).toMatch(/--g-b:\s*#F0F0F0/i); + expect(CSS).toMatch(/--g-c:\s*#B8BCC8/i); + expect(CSS).toMatch(/--g-d:\s*#FF4757/i); + }); + + it('fonts: Inter for chrome, JetBrains Mono for ALL data', () => { + expect(CSS).toMatch(/--sans:[^;]*Inter/); + expect(CSS).toMatch(/--mono:[^;]*JetBrains Mono/); + }); + + it('the laws are written INTO the token layer, not left as folklore', () => { + const block = CSS.slice(CSS.indexOf('SESSION 66 — PRICE-LAYER TOKENS')); + expect(block).toMatch(/GREEN = EDGE ONLY/); + expect(block).toMatch(/GLOW = A-TIER ONLY/); + expect(block).toMatch(/AMBER = CAUTION/); + expect(block).toMatch(/RED = MISS \/ NEGATIVE only/i); + expect(block).toMatch(/BLUE = EDGE PRICED OUT/); + expect(block).toMatch(/JETBRAINS MONO = ALL DATA/); + }); + + it('is ADDITIVE — the new block declares only new names', () => { + // Bound the block to the Session-66 section itself: globals.css continues + // past :root with a11y/media layers that legitimately re-declare tokens. + const start = CSS.indexOf('SESSION 66 — PRICE-LAYER TOKENS'); + const block = CSS.slice(start, CSS.indexOf('\n}', start)); + const declared = [...block.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gim)].map((m) => m[1]); + expect(declared.length).toBeGreaterThan(0); + // None of the newly-declared names may collide with a pre-existing token. + const before = CSS.slice(0, CSS.indexOf('SESSION 66 — PRICE-LAYER TOKENS')); + for (const name of declared) { + expect(before).not.toMatch(new RegExp(`^\\s*${name}\\s*:`, 'm')); + } + }); +}); + +// ── 2. THE BAND — frontend law mirrors the backend engine ──────────────── +describe('takeable band + EV threshold stay in sync with the backend', () => { + it('mirrors src/config/valueEngine.js exactly', () => { + expect(vs.TAKEABLE_ODDS_CEILING).toBe(backendValue.TAKEABLE_ODDS_CEILING); + expect(vs.TAKEABLE_ODDS_MAX).toBe(backendValue.TAKEABLE_ODDS_MAX); + expect(vs.VALUE_EV_THRESHOLD).toBe(backendValue.VALUE_EV_THRESHOLD); + }); + + it('agrees with the backend on isTakeable / isValue across the band', () => { + const prices = [-400, -210, -161, -160, -110, 100, 200, 201, 500, null, undefined, '']; + for (const p of prices) { + expect(vs.isTakeable(p)).toBe(backendValue.isTakeable(p)); + for (const ev of [-58.7, 0, 1.9, 2, 11.7, 26.5]) { + expect(vs.isValue(p, ev)).toBe(backendValue.isValue(p, ev)); + } + } + }); + + it('a missing price is NEVER takeable (Number(null) === 0 guard)', () => { + expect(vs.isTakeable(null)).toBe(false); + expect(vs.isTakeable('')).toBe(false); + expect(vs.isTakeable(undefined)).toBe(false); + }); +}); + +// ── 3. THE FIVE HONESTY STATES ─────────────────────────────────────────── +describe('deriveValueState — the single verdict function', () => { + const base = { book_odds: -120, fair_odds: -104, model_odds: -196, ev_pct: 21.4 }; + + it('VALUE — real edge at a takeable price (the live Petey Halpin row)', () => { + expect(vs.deriveValueState(base)).toBe(vs.STATES.VALUE); + }); + + it('PRICED OUT — positive EV at a juiced price (the live Josh Bell row)', () => { + // book -210, +11.7% EV: the engine refuses to call this value, and so do we. + const row = { book_odds: -210, fair_odds: -173, model_odds: -311, ev_pct: 11.7 }; + expect(vs.deriveValueState(row)).toBe(vs.STATES.PRICED_OUT); + }); + + it('NEVER calls raw positive EV "value" — the whole point of state 2', () => { + for (const [book, ev] of [[-210, 11.7], [-190, 26.5], [-400, 60], [250, 30]]) { + const st = vs.deriveValueState({ book_odds: book, fair_odds: -150, model_odds: -300, ev_pct: ev }); + expect(st).not.toBe(vs.STATES.VALUE); + expect(st).toBe(vs.STATES.PRICED_OUT); + } + }); + + it('EV below the threshold is NO EDGE even at a takeable price', () => { + expect(vs.deriveValueState({ ...base, ev_pct: 0.2 })).toBe(vs.STATES.NO_EDGE); + expect(vs.deriveValueState({ ...base, ev_pct: 1.99 })).toBe(vs.STATES.NO_EDGE); + expect(vs.deriveValueState({ ...base, ev_pct: 2 })).toBe(vs.STATES.VALUE); // boundary + }); + + it('QUARANTINE — model leg withheld, book + fair still stand', () => { + const row = { ...base, quarantine_reason: 'wrong_opponent_grade' }; + expect(vs.deriveValueState(row)).toBe(vs.STATES.QUARANTINE); + }); + + it('REFUSAL — no fair price we would defend', () => { + expect(vs.deriveValueState({ ...base, fair_odds: null })).toBe(vs.STATES.REFUSAL); + expect(vs.deriveValueState({ ...base, book_odds: null })).toBe(vs.STATES.REFUSAL); + expect(vs.deriveValueState({ ...base, refused: true })).toBe(vs.STATES.REFUSAL); + expect(vs.deriveValueState({})).toBe(vs.STATES.REFUSAL); + }); + + it('a missing model price or EV withholds the verdict — never asserts NO EDGE', () => { + expect(vs.deriveValueState({ ...base, model_odds: null })).toBe(vs.STATES.QUARANTINE); + expect(vs.deriveValueState({ ...base, ev_pct: null })).toBe(vs.STATES.QUARANTINE); + }); + + it('a LOCKED model leg is neither quarantine nor refusal', () => { + const row = { ...base, model_price_locked: true }; + expect(vs.deriveValueState(row)).toBe(vs.STATES.NO_VERDICT_LOCKED); + }); +}); + +// ── 4. THE COLOR LAW ───────────────────────────────────────────────────── +describe('valueStateColor — green appears on exactly one state', () => { + it('VALUE is the ONLY green', () => { + expect(vs.valueStateColor(vs.STATES.VALUE)).toBe('var(--g-a)'); + const others = [ + vs.STATES.PRICED_OUT, vs.STATES.NO_EDGE, vs.STATES.QUARANTINE, + vs.STATES.REFUSAL, vs.STATES.NO_VERDICT_LOCKED, + ]; + for (const s of others) expect(vs.valueStateColor(s)).not.toBe('var(--g-a)'); + }); + + it('PRICED OUT is the blue carve-out; NO EDGE is never red', () => { + expect(vs.valueStateColor(vs.STATES.PRICED_OUT)).toBe('var(--priced-out)'); + // An honest "no" is the instrument working, not a loss. + expect(vs.valueStateColor(vs.STATES.NO_EDGE)).not.toBe('var(--miss)'); + }); + + it('QUARANTINE is amber (caution), per the token law', () => { + expect(vs.valueStateColor(vs.STATES.QUARANTINE)).toBe('var(--amber)'); + }); +}); + +// ── 5. HONEST ABSENCE ──────────────────────────────────────────────────── +describe('never prints a number it cannot stand behind', () => { + it('fmtOddsAmerican returns null for absence — never 0, never a placeholder', () => { + expect(vs.fmtOddsAmerican(null)).toBeNull(); + expect(vs.fmtOddsAmerican('')).toBeNull(); + expect(vs.fmtOddsAmerican(undefined)).toBeNull(); + expect(vs.fmtOddsAmerican('abc')).toBeNull(); + expect(vs.fmtOddsAmerican(-210)).toBe('-210'); + expect(vs.fmtOddsAmerican(125)).toBe('+125'); + }); + + it('modelVsFair is null unless BOTH legs are real', () => { + expect(vs.modelVsFair(null, -110)).toBeNull(); + expect(vs.modelVsFair(-110, null)).toBeNull(); + expect(typeof vs.modelVsFair(98, 110)).toBe('number'); + }); + + it('reproduces the design file\'s own worked examples EXACTLY', () => { + // The design states these two figures; the formula was derived from them. + // STATE 1: book +125 · fair +110 · model +98 → "+2.9% VS FAIR" + expect(vs.modelVsFair(98, 110)).toBe(2.9); + // STATE 3: book +118 · fair +104 · model +112 → "-1.8% VS FAIR" + expect(vs.modelVsFair(112, 104)).toBe(-1.8); + }); + + it('compares MODEL to FAIR — not book to fair (the induction bug)', () => { + // Petey Halpin, live: book -120 · fair -104 · model -196. Our price is far + // shorter than fair, so the gap is strongly POSITIVE. Comparing the book + // to fair instead produced -6.5% on a VALUE row — a visible contradiction. + expect(vs.modelVsFair(-196, -104)).toBeGreaterThan(10); + }); +}); + +// ── 6. THE COMPONENT carries the rulings ───────────────────────────────── +describe('PriceTriplet component', () => { + const src = read('web/src/components/vyndr/PriceTriplet.tsx'); + + it('uses ONLY tokens — no literal hex anywhere', () => { + const hexes = src.match(/#[0-9a-f]{3,8}\b/gi) || []; + expect(hexes).toEqual([]); + }); + + it('does not re-derive the verdict — it renders deriveValueState', () => { + expect(src).toContain('deriveValueState'); + expect(src).not.toMatch(/ev_pct\s*>=\s*2/); + }); + + it('FAIR is the amber hero and is never locked', () => { + expect(src).toMatch(/label="FAIR"[\s\S]{0,200}var\(--amber\)/); + expect(src).not.toMatch(/label="FAIR"[\s\S]{0,200}locked/); + }); + + it('only the MODEL leg is lockable / withheld', () => { + expect(src).toMatch(/label="MODEL"[\s\S]{0,300}locked=\{modelLocked\}/); + }); + + it('carries no sample numbers from the design file', () => { + // Strip comments first — the docblock names the design's sample values in + // order to say they are a SPEC and must never ship as data. + const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + for (const sample of ['1,188', '1,115.5', '+125', '+110', '+98', '+130', '+116']) { + expect(code).not.toContain(sample); + } + }); + + it('REFUSAL renders no legs and no gauge', () => { + const refusal = src.slice(src.indexOf('STATES.REFUSAL')); + expect(refusal).toContain("CAN’T PRICE THIS ONE"); + }); +}); + +// ── 7. THE SERVER GATE — fair is never the paywall ─────────────────────── +describe('free-tier gate strips the model price at the wire', () => { + const { applyTierGating } = require('../../src/utils/tierGating'); + const row = { + grade: 'B', book_odds: -120, fair_odds: -104, model_odds: -196, ev_pct: 21.4, + }; + + it('free: model_odds is REMOVED, book + fair survive', () => { + const out = applyTierGating(row, 'free'); + expect(out.model_odds).toBeUndefined(); + expect(out.book_odds).toBe(-120); + expect(out.fair_odds).toBe(-104); + expect(out.model_price_locked).toBe(true); + }); + + it('analyst + desk: the full triplet passes through', () => { + for (const tier of ['analyst', 'desk']) { + const out = applyTierGating(row, tier); + expect(out.model_odds).toBe(-196); + expect(out.model_price_locked).toBeUndefined(); + } + }); + + it('the adapter turns a stripped row into a LOCK, not an absent leg', () => { + const { buildPriceTriplet } = require('../../web/src/lib/gradeAdapter'); + const gated = applyTierGating(row, 'free'); + const t = buildPriceTriplet(gated).priceTriplet; + expect(t.model_odds).toBeNull(); + expect(t.model_price_locked).toBe(true); + expect(vs.deriveValueState(t)).toBe(vs.STATES.NO_VERDICT_LOCKED); + }); + + it('no book/fair → no price story at all (section hidden, not empty)', () => { + const { buildPriceTriplet } = require('../../web/src/lib/gradeAdapter'); + expect(buildPriceTriplet({ grade: 'B' })).toEqual({}); + expect(buildPriceTriplet({ book_odds: -120 })).toEqual({}); + }); +}); diff --git a/web/src/app/globals.css b/web/src/app/globals.css index c507a3e..1864a38 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -144,6 +144,55 @@ --forest: var(--acc-0); --forest-dark: #0A2A20; --forest-light: var(--acc-1); + + /* ═════════════════════════════════════════════════════════ + SESSION 66 — PRICE-LAYER TOKENS (ADDITIVE). + The Jul-20 design bundle (`Vyndr Price Triplet.dc.html`) introduces one + colour the system did not have. Everything else it specifies already + resolves above and is byte-identical to HANDOFF.md — nothing here + re-declares or overrides an existing token, and no existing component + changes because these are added. + + THE LAWS THESE TOKENS CARRY (machine-readable mirror in + `web/src/lib/colorContract.js`; enforced by colorContract.test.js and + priceTriplet.test.js — a component must be able to read the law, not + just the hex): + + --g-a (#00D4A0) GREEN = EDGE ONLY. edge / active / A-tier / + primary CTA. Never decorative. On the price layer: + green fires ONLY on takeable edge (ev >= 2 AND the + price inside the takeable band) — never on a juiced + price, never on raw positive EV. + --glow-a GLOW = A-TIER ONLY (A / A+). The scarcest cue in the + system; gate every glow through gradeGlows(). + --amber AMBER = CAUTION / CORRELATION only — plus, from this + bundle, the FAIR leg of the price triplet (the + de-vigged number is the hero of the price layer). + --miss RED = MISS / NEGATIVE only. Never "no edge" (an + honest no is the instrument working, not a loss). + --priced-out BLUE = EDGE PRICED OUT. The carve-out that lets a row + say "there is edge" WITHOUT green saying "take it": + positive EV at a price outside the takeable band. + Used nowhere else in the system. + --mono JETBRAINS MONO = ALL DATA (tabular-nums). Inter is + chrome only. Prices are data. + ═════════════════════════════════════════════════════════ */ + + /* A-tier glow, tokenized. The law (glow = A/A+ only) lived in + colorContract.gradeGlows() with no token to hand back; this is that value. */ + --glow-a: 0 0 12px rgba(0, 212, 160, 0.45); + + /* EDGE PRICED OUT — muted blue. Exact values from the design bundle. */ + --priced-out: #8fb2de; + --priced-out-dim: #7a93b0; + --priced-out-tint: rgba(106, 147, 200, 0.08); + --priced-out-tint-2: rgba(106, 147, 200, 0.12); + --priced-out-border: rgba(106, 147, 200, 0.30); + --priced-out-border-hi: rgba(106, 147, 200, 0.34); + + /* FAIR leg surface tints (amber hero of the price layer). */ + --fair-tint: rgba(255, 179, 71, 0.055); + --fair-border: rgba(255, 179, 71, 0.22); } /* ───────────────────────────────────────────────────────── diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index 61964c1..b3a2137 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -67,6 +67,14 @@ interface ScanResponse { archetype?: string; archetype_blend?: { archetype: string; weight: number }[]; prop_dna?: { reliable: string[]; volatile: string[] }; + // Session 66 — the PRICE LAYER. `model_odds` is derived from p_win by the + // engine and STRIPPED for unentitled tiers, which is what sets + // `model_price_locked` — so a locked leg and a missing leg stay distinct. + book_odds?: number | null; + fair_odds?: number | null; + model_odds?: number | null; + ev_pct?: number | null; + model_price_locked?: boolean; } const NBA_STATS = [ @@ -742,6 +750,15 @@ export default function ScanPage() { archetype: result.archetype, archetype_blend: result.archetype_blend, prop_dna: result.prop_dna, + // Session 66 — the PRICE LAYER. Without these forwarded the + // triplet stays hidden no matter what the engine computed + // (the Session-44 lesson: this call site is a hardcoded + // whitelist, not a spread). + book_odds: result.book_odds, + fair_odds: result.fair_odds, + model_odds: result.model_odds, + ev_pct: result.ev_pct, + model_price_locked: result.model_price_locked, }) as GradeResultData} onAddToParlay={() => { addLeg({ diff --git a/web/src/components/LiveHeroProp.tsx b/web/src/components/LiveHeroProp.tsx index 0fdd838..55df456 100644 --- a/web/src/components/LiveHeroProp.tsx +++ b/web/src/components/LiveHeroProp.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { GradePill } from './GradeCard'; +import PriceTriplet from './vyndr/PriceTriplet'; /** * Daily hero prop card (Truth-Everywhere Part 2, item 5). @@ -28,6 +29,16 @@ type Hero = { book?: string | null; graded_at?: string | null; reasoning?: string | null; + // Session 66 — the PRICE LAYER. /api/hero-prop (heroPropService.toHero) has + // emitted these all along; the card just never declared or read them. All + // nullable: a missing leg renders absent, never a sample number. + book_odds?: number | null; + fair_odds?: number | null; + model_odds?: number | null; + ev_pct?: number | null; + value?: boolean | null; + takeable?: boolean | null; + quarantine_reason?: string | null; }; const SPORT_LABEL: Record = { nba: 'NBA', wnba: 'WNBA', mlb: 'MLB', soccer: 'Soccer' }; @@ -129,6 +140,33 @@ export default function LiveHeroProp() { + {/* ② PRICE · IS IT FAIR (Session 66 — the price triplet). + The block above answers "will it clear" in STAT space; this answers + "is it fair" in PRICE space. Additive — the projection comparison is + untouched and the triplet sits beside it, per the design's two-question + hierarchy. Self-hides when the feed carries no real prices. */} + {(data.book_odds != null || data.fair_odds != null) && ( +
+
+ PRICE · IS IT FAIR +
+ +
+ )} + {/* Real timestamp — proves this was graded, and when. */} {data.graded_at && (

diff --git a/web/src/components/vyndr/GradeResultCard.tsx b/web/src/components/vyndr/GradeResultCard.tsx index 540b129..dc90aae 100644 --- a/web/src/components/vyndr/GradeResultCard.tsx +++ b/web/src/components/vyndr/GradeResultCard.tsx @@ -12,6 +12,7 @@ import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; import { type HeadshotSport } from '@/lib/playerHeadshot'; import { gradeColor, gradeHex } from '@/lib/vyndrTokens'; import { edgeColor, gradeGlows } from '@/lib/colorContract'; +import PriceTriplet, { type PriceTripletData } from './PriceTriplet'; import { playerHref } from '@/lib/playerHref'; export interface GradeResultData { @@ -45,6 +46,10 @@ export interface GradeResultData { // below 3 real captured points). Fed by the snapshot pipeline's already- // emitted line history + public revision; the scan path leaves them absent. history?: Array<{ t: string; line: number }> | null; + // Session 66 — the PRICE LAYER (book · fair · model). Optional + self-hiding: + // absent means the engine supplied no prices, and the card shows the + // projection alone rather than an empty gauge. + priceTriplet?: PriceTripletData | null; revisedFrom?: string | null; gradedLine?: number | null; } @@ -189,6 +194,17 @@ export default function GradeResultCard({ )} + {/* Session 66 — TWO-QUESTION HIERARCHY (design ACT 01): the projection + answers "will it clear" in STAT space and reads FIRST; the price + triplet below answers "is it fair" in PRICE space. Two questions, + two stacked blocks — the triplet sits BESIDE the projection, it does + not replace it. This label is additive; the row below is untouched. */} + {d.priceTriplet && ( +

+ ① PROJECTION · WILL IT CLEAR +
+ )} + {/* 4. PROJECTION ROW — DATA SEMANTICS (Session 58): LINE is a real market number (fact); MODEL/EDGE are model output. When the model has no projection they render an absent state ("—"), never the @@ -206,6 +222,19 @@ export default function GradeResultCard({ ))} + {/* 4a. ② PRICE · IS IT FAIR (Session 66) — BOOK · FAIR · MODEL. + Self-hides entirely when the engine supplied no price layer, so a + read with no market prices shows the projection alone rather than an + empty gauge. Every honesty ruling lives in lib/valueState. */} + {d.priceTriplet && ( +
+
+ ② PRICE · IS IT FAIR +
+ +
+ )} + {/* 4b. LIVE GRADE-SHIFT (Wave 4B) — the line/grade movement timeline over the snapshot pipeline's already-emitted history. Self-hides below 3 real captured points, so the scan path (no captured history) shows diff --git a/web/src/components/vyndr/PriceTriplet.tsx b/web/src/components/vyndr/PriceTriplet.tsx new file mode 100644 index 0000000..bd89b3f --- /dev/null +++ b/web/src/components/vyndr/PriceTriplet.tsx @@ -0,0 +1,345 @@ +'use client'; + +import { + STATES, + deriveValueState, + valueStateColor, + modelVsFair, + fmtOddsAmerican, + TAKEABLE_ODDS_CEILING, + TAKEABLE_ODDS_MAX, +} from '@/lib/valueState'; + +/** + * PriceTriplet (Session 66) — BOOK · FAIR · MODEL. + * + * The first component built natively on the price-layer tokens. Extends the + * parlay's TRUE FAIR VALUE language from a 2-up grid to a 3-leg per-prop grid. + * + * DESIGN: `specs/design-reference/Vyndr Price Triplet.dc.html` (ACT 01) + + * HANDOFF.md laws. Every colour resolves from a token — no literal hex. + * + * BOOK the market's offered price. Honest on every row. + * FAIR the de-vigged number. THE HERO (amber). Never hidden — not even + * when the model leg is poisoned, and never the paywall. + * MODEL VYNDR's own price. Green ONLY for takeable edge, never a juiced + * one. Suppressible on quarantined rows, lockable on free. + * + * LIVE DATA ONLY. The design file's numbers (+125 / +110 / +98) are a SPEC, + * not data — nothing here carries a sample value. A leg with no real number + * renders absent (a dash), never a zero and never a placeholder. + * + * The verdict is NOT computed here: `deriveValueState` owns it so there is one + * place where "is this value?" is answered. This component only renders it. + */ + +export interface PriceTripletData { + book_odds?: number | string | null; + fair_odds?: number | string | null; + model_odds?: number | string | null; + ev_pct?: number | string | null; + quarantine_reason?: string | null; + refused?: boolean; + /** Free tier: the model leg exists server-side but is not sent. */ + model_price_locked?: boolean; +} + +const ABSENT = '—'; + +/** Verdict copy, verbatim from the design file. */ +function verdictFor(state: string, vsFair: number | null, ev: number | null) { + const pct = (n: number | null) => (n == null ? null : `${n >= 0 ? '+' : ''}${n}%`); + switch (state) { + case STATES.VALUE: + // Show the VS FAIR gap only when it agrees in sign with the verdict. + // A row can clear the EV bar on the BOOK price while our own price sits + // level with fair; printing "VALUE · -0.4% VS FAIR" would read as a + // contradiction, so we lead with the EV that actually drove the call. + return { + label: vsFair != null && vsFair > 0 ? `VALUE · ${pct(vsFair)} VS FAIR` : 'VALUE', + sub: ev != null ? `${pct(ev)} EV` : null, + body: 'Model prices it shorter than the honest number — the book is paying longer than the leg is worth. Real edge.', + mark: null, + }; + case STATES.PRICED_OUT: + return { + label: 'EDGE · NOT TAKEABLE', + sub: ev != null ? `${pct(ev)} EV, priced out` : null, + body: `The math shows edge — but this price is too juiced to bet. We won't call a juiced price a value, and we won't pretend the edge wasn't there. Takeable band ${TAKEABLE_ODDS_CEILING} to +${TAKEABLE_ODDS_MAX}.`, + mark: '⊘', + }; + case STATES.NO_EDGE: + return { + // Same sign-coherence rule inverted: a NO-EDGE row whose model happens + // to sit above fair says so with the EV, not a positive-looking gap. + label: vsFair != null && vsFair < 0 ? `NO EDGE HERE · ${pct(vsFair)} VS FAIR` : 'NO EDGE HERE', + sub: ev != null ? `${pct(ev)} EV` : null, + body: "Model lands short of fair — the honest number is already priced. We're not calling this a play. That “no” is the instrument working.", + mark: null, + }; + case STATES.QUARANTINE: + return { + label: 'MODEL READ WITHHELD', + sub: null, + body: 'We suppressed our own price on this row. Book and fair stand — we never hide the honest fair number because a different leg is poisoned.', + mark: null, + }; + case STATES.NO_VERDICT_LOCKED: + return { + label: 'MODEL PRICE ON ANALYST', + sub: null, + body: 'The honest de-vigged number stays free. Only VYNDR’s own price gates.', + mark: null, + }; + default: + return null; + } +} + +function Leg({ + label, + value, + note, + color, + hero, + locked, + compact, +}: { + label: string; + value: string | null; + note?: string | null; + color: string; + hero?: boolean; + locked?: boolean; + compact?: boolean; +}) { + return ( +
+
+ {hero ? '◆ ' : ''} + {label} +
+ {locked ? ( + // The lock is a TEASER, not a number. Nothing here can be mistaken + // for a price the viewer wasn't given. +
+ ) : ( +
+ {value ?? ABSENT} +
+ )} + {note && ( +
+ {note} +
+ )} +
+ ); +} + +export default function PriceTriplet({ + data, + compact = false, + showBody = true, + ledgerHref, +}: { + data: PriceTripletData | null | undefined; + compact?: boolean; + showBody?: boolean; + ledgerHref?: string | null; +}) { + if (!data) return null; + const state = deriveValueState(data); + + const book = fmtOddsAmerican(data.book_odds); + const fair = fmtOddsAmerican(data.fair_odds); + const model = fmtOddsAmerican(data.model_odds); + const ev = data.ev_pct == null || data.ev_pct === '' ? null : Number(data.ev_pct); + const vsFair = modelVsFair(data.model_odds, data.fair_odds); + const accent = valueStateColor(state); + + // REFUSAL — nothing to price. No legs, no gauge, no fabricated number. + if (state === STATES.REFUSAL) { + return ( +
+
+ CAN’T PRICE THIS ONE +
+ {showBody && ( +

+ No fair price we’d defend yet — the inputs are too thin. When they clear, the + triplet returns. We’d rather show nothing than a number we can’t stand + behind. +

+ )} +
+ ); + } + + const modelWithheld = state === STATES.QUARANTINE; + const modelLocked = state === STATES.NO_VERDICT_LOCKED; + // GREEN = TAKEABLE EDGE ONLY. The model leg is green on exactly one state. + const modelColor = state === STATES.VALUE ? 'var(--g-a)' : 'var(--text-0)'; + const verdict = verdictFor(state, vsFair, Number.isFinite(ev as number) ? (ev as number) : null); + + return ( +
+
+ + + +
+ + {verdict && ( +
+
+ + {verdict.mark ? `${verdict.mark} ` : ''} + {verdict.label} + + {verdict.sub && ( + + {verdict.sub} + + )} +
+ {showBody && verdict.body && ( +

+ {verdict.body} +

+ )} + {modelLocked && ( + + UNLOCK ▸ + + )} +
+ )} + + {ledgerHref && ( + + CLOSES TRACKED IN YOUR LEDGER ▸ + + )} +
+ ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index f45cd69..af99373 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -62,3 +62,5 @@ export { gradeBadgeSize, } from '@/lib/vyndrTokens'; export { default as ClvBadge } from './ClvBadge'; +export { default as PriceTriplet } from './PriceTriplet'; +export type { PriceTripletData } from './PriceTriplet'; diff --git a/web/src/lib/gradeAdapter.js b/web/src/lib/gradeAdapter.js index 68957c0..a9febf7 100644 --- a/web/src/lib/gradeAdapter.js +++ b/web/src/lib/gradeAdapter.js @@ -105,6 +105,45 @@ function mapScanToGradeResult(input = {}) { // Session 42 — Player Intelligence additions. All OPTIONAL + self-hiding: // they only render once the engine supplies them (Session 43 data pipeline). ...buildIntelFields(input), + // Session 66 — the PRICE LAYER (book · fair · model). Self-hiding: absent + // unless the engine supplied real prices, so a read with no market prices + // renders the projection alone rather than an empty gauge. NEVER + // fabricated — the design file's numbers are a spec, not a fallback. + ...buildPriceTriplet(input), + }; +} + +/** + * buildPriceTriplet(input) — the optional price-layer block for the card. + * + * Returns `{}` (section stays hidden) unless the engine supplied at least a + * book price AND a fair price: with no honest de-vigged number there is no + * price story to tell, and a lone book price is just the market repeated back. + * + * `model_price_locked` is set by the SERVER (utils/tierGating strips + * `model_odds` for unentitled tiers and flags it) — the free tier never + * receives the model price, so the lock is real, not a client-side blur over + * data that was already sent. Book and fair always pass through: the fair leg + * is never the paywall. + */ +function buildPriceTriplet(input) { + const n = (v) => { + if (v == null || v === '') return null; + const x = typeof v === 'number' ? v : Number(v); + return Number.isFinite(x) ? x : null; + }; + const book = n(input.book_odds); + const fair = n(input.fair_odds); + if (book == null || fair == null) return {}; + return { + priceTriplet: { + book_odds: book, + fair_odds: fair, + model_odds: n(input.model_odds), + ev_pct: n(input.ev_pct), + quarantine_reason: input.quarantine_reason || null, + model_price_locked: input.model_price_locked === true, + }, }; } @@ -142,4 +181,4 @@ function buildIntelFields(input) { return out; } -module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals, buildIntelFields }; +module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals, buildIntelFields, buildPriceTriplet }; diff --git a/web/src/lib/valueState.js b/web/src/lib/valueState.js new file mode 100644 index 0000000..5abfdf2 --- /dev/null +++ b/web/src/lib/valueState.js @@ -0,0 +1,184 @@ +/* ============================================================ + VYNDR — THE PRICE-LAYER LAW (Session 66). + + Plain CommonJS so .tsx components import it AND the Jest suite requires it + directly (same pattern as colorContract.js / vyndrTokens.js / playerName.js). + + ONE function decides what a price row is allowed to SAY. The triplet renders + whatever this returns and never re-derives a verdict of its own, so there is + exactly one place where "is this value?" is answered. + + THE FIVE HONESTY STATES (design: `Vyndr Price Triplet.dc.html`, ACT 01): + + VALUE green — ev >= VALUE_EV_THRESHOLD *AND* the book price is + inside the takeable band. Both. Raw positive EV is + NOT value: +11.7% EV at -210 is a juiced price we + will not call a play. + PRICED_OUT blue — real edge, untakeable price. We won't call a juiced + price a value, and we won't pretend the edge wasn't + there. This state exists so green never has to lie + in either direction. + NO_EDGE grey — the honest number is already priced. Stated at full + voice, never hidden. That "no" is the instrument + working. + QUARANTINE amber — the MODEL leg is withheld (graded against inputs we + no longer trust). Book and fair still render: we + never hide the honest fair number because a + different leg is poisoned. RARE — as of Jul 2026 the + only quarantined rows are one frozen historical + cohort, so this is not a routine state. + REFUSAL dim — no fair price we'd defend. Nothing renders. No + fabricated price, no empty gauge. + + TRUTH LAW: the triplet never prints a number it can't stand behind. A leg + with no value renders absent, never a zero and never a placeholder. + ============================================================ */ + +/* ---- Backend mirror ----------------------------------------------------- + These MUST match src/config/valueEngine.js. The browser can't require the + backend module, so the constants are duplicated here and a test + (priceTriplet.test.js) reads BOTH files and fails if they drift — the same + guard used for playerName.js. If you tune the band, tune it in both. */ +const TAKEABLE_ODDS_CEILING = -160; // most-juiced favourite we'll promote +const TAKEABLE_ODDS_MAX = 200; // longest dog we'll promote +const VALUE_EV_THRESHOLD = 2; // a real edge, not rounding + +const STATES = Object.freeze({ + VALUE: 'VALUE', + PRICED_OUT: 'PRICED_OUT', + NO_EDGE: 'NO_EDGE', + QUARANTINE: 'QUARANTINE', + REFUSAL: 'REFUSAL', + // Not a verdict — a display state. The model leg exists and is honest, the + // viewer just isn't entitled to it (free tier). Book + fair render normally. + NO_VERDICT_LOCKED: 'NO_VERDICT_LOCKED', +}); + +/** Strict numeric parse — `Number(null) === 0` is the fabrication bug this + * whole layer exists to prevent, so nothing coerces. */ +function num(v) { + if (v == null || v === '') return null; + const n = typeof v === 'number' ? v : Number(v); + return Number.isFinite(n) ? n : null; +} + +/** isTakeable(american) — inside the promotable band. Mirrors valueEngine. */ +function isTakeable(american) { + const a = num(american); + if (a == null) return false; + return a >= TAKEABLE_ODDS_CEILING && a <= TAKEABLE_ODDS_MAX; +} + +/** isValue(american, evPct) — takeable AND clears the EV threshold. Both. */ +function isValue(american, evPct) { + const ev = num(evPct); + return isTakeable(american) && ev != null && ev >= VALUE_EV_THRESHOLD; +} + +/** + * deriveValueState(row) — the single verdict function. + * + * row: { book_odds, fair_odds, model_odds, ev_pct, quarantine_reason, + * refused, model_price_locked } + * + * Order matters and encodes the honesty ordering: + * 1. REFUSAL first — with no fair price there is nothing honest to show. + * 2. QUARANTINE next — the model leg is untrustworthy, so no verdict may be + * computed from it, but book+fair still stand. + * 3. Then the three real verdicts. + * A locked (free-tier) model leg is NOT quarantine and NOT refusal: the data + * exists and is honest, the viewer just isn't entitled to it — so the verdict + * is withheld rather than asserted, and book+fair render normally. + */ +function deriveValueState(row = {}) { + const fair = num(row.fair_odds); + const book = num(row.book_odds); + + // 1. Refusal — no fair price we'd defend (or no market to price against). + if (row.refused === true || fair == null || book == null) return STATES.REFUSAL; + + // 2. Quarantine — model leg withheld; book + fair still render. + if (row.quarantine_reason) return STATES.QUARANTINE; + + // Free-tier lock: entitled data withheld, not absent. No verdict is claimed + // (the verdict IS the model leg), but this is not quarantine — the row is + // honest, it's just gated. + if (row.model_price_locked === true) return STATES.NO_VERDICT_LOCKED; + + const model = num(row.model_odds); + const ev = num(row.ev_pct); + // Without a model price or an EV there is no verdict to render — treat as a + // withheld model leg rather than asserting "no edge" we didn't compute. + if (model == null || ev == null) return STATES.QUARANTINE; + + // 3. The three real verdicts. + if (isValue(book, ev)) return STATES.VALUE; + if (ev >= VALUE_EV_THRESHOLD) return STATES.PRICED_OUT; // edge, untakeable price + return STATES.NO_EDGE; +} + +/** valueStateColor(state) — the ONE mapping from verdict to token. + * Green appears here exactly once, on VALUE, and nowhere else. */ +function valueStateColor(state) { + switch (state) { + case STATES.VALUE: + return 'var(--g-a)'; + case STATES.PRICED_OUT: + return 'var(--priced-out)'; + case STATES.QUARANTINE: + return 'var(--amber)'; + case STATES.NO_EDGE: + case STATES.REFUSAL: + case STATES.NO_VERDICT_LOCKED: + default: + return 'var(--text-2)'; + } +} + +function impliedProb(american) { + const a = num(american); + if (a == null) return null; + return a > 0 ? 100 / (a + 100) : -a / (-a + 100); +} + +/** + * modelVsFair(modelOdds, fairOdds) — the "+2.9% VS FAIR" figure on the verdict + * line. It is the gap between OUR price and the honest de-vigged price, in + * implied-probability PERCENTAGE POINTS (not a ratio, and NOT book-vs-fair). + * + * Derived from the design file's own two worked examples, both of which this + * reproduces exactly: + * STATE 1 book +125 · fair +110 · model +98 → "+2.9% VS FAIR" + * STATE 3 book +118 · fair +104 · model +112 → "-1.8% VS FAIR" + * Positive = the model prices it SHORTER than fair (we think it's likelier + * than the honest number says). Null unless both legs are real. + */ +function modelVsFair(modelOdds, fairOdds) { + const pm = impliedProb(modelOdds); + const pf = impliedProb(fairOdds); + if (pm == null || pf == null) return null; + return Math.round((pm - pf) * 1000) / 10; +} + +/** fmtOddsAmerican(v) — display an American price, or null for honest absence. + * Never returns '0', never returns a placeholder number. */ +function fmtOddsAmerican(v) { + const n = num(v); + if (n == null) return null; + const r = Math.round(n); + return r > 0 ? `+${r}` : String(r); +} + +module.exports = { + STATES, + TAKEABLE_ODDS_CEILING, + TAKEABLE_ODDS_MAX, + VALUE_EV_THRESHOLD, + isTakeable, + isValue, + deriveValueState, + valueStateColor, + modelVsFair, + impliedProb, + fmtOddsAmerican, +};