diff --git a/PROMISE-AUDIT.md b/PROMISE-AUDIT.md new file mode 100644 index 0000000..ad6e5f3 --- /dev/null +++ b/PROMISE-AUDIT.md @@ -0,0 +1,35 @@ +# PROMISE-AUDIT — every /pricing claim, verified / built / removed +### Session 62 (A1 board, Session 1). No claim survives unverified. Locked by tests/unit/promiseAudit.test.js. + +| Claim (pricing page) | Verdict | Evidence | +|---|---|---| +| Free: 3 reads per day | **VERIFIED** | `tiers.js free.scans_per_day=3`; limiter enforces; Settings meter reads the same store | +| Free: Grade letter + projection | **VERIFIED** | Grade + real MODEL projection (S58 — never the line); refusals when no projection | +| Free: Cross-book line comparison | **VERIFIED** | GAME LINES 8-book table + BestLinesPanel; per-prop best-book marker shipping in S3 | +| Free: Confidence indicator | **VERIFIED** | Confidence % on card, strips, leaders | +| Free: Factor analysis (blurred) | **BUILT/REWORDED** | Copy now "3-signal preview" (the truth); free gets exactly 3 signals | +| Free: Kill conditions (blurred) | **BUILT** | Copy now "locked preview"; free NOW renders code-only teaser chips ("TRAP — upgrade to see details") instead of silence — `gradeAdapter` + tierGating locked entries | +| Free: Alt line ladder (locked) | **VERIFIED** | Desk-gated at the API (new `alt_line_ladder` capability); free card shows the DESK-tagged section only when data present (never for free) | +| Africa: 10 reads per day | **VERIFIED** | `tiers.js africa.scans_per_day=10` | +| Analyst: Full factor analysis (40+ signals) | **REWORDED** | Real named-feature count is 22 (+trap/consistency signals) — "40+" was unverifiable. Now "every signal the model weighs" in ALL 7 places (pricing, help, about, upgrade, both cards). Numbers only when true | +| Analyst: Kill conditions surfaced inline | **VERIFIED** | tierGating `kill_conditions_detail`; card renders reasons | +| Analyst: Grade + reasoning visible | **VERIFIED** | tierGating passthrough | +| Analyst: World Cup soccer intelligence | **VERIFIED** | `/api/odds/soccer/wc`, soccer tab, soccer in ACTIVE_SPORTS (July = WC window) | +| Analyst: Cascade alerts | **VERIFIED/REWORDED** | REAL: `cascadeService.detectScratches` runs on every odds fetch → `cascade_alerts` → NotificationBell. Copy tightened to "Scratch cascade alerts when players are pulled" (what it actually does; lineup-shift regrades are a future board) | +| Analyst: Parlay leg history with grades | **VERIFIED** | Parlay scans persist pick rows w/ grades; `/api/props/joint-history` (analyst+) serves joint outcomes | +| Analyst: Sportsbook deep links | **VERIFIED** | Scan hand-off links (noopener); S3 upgrades to per-book BOOK IT builder | +| Founder: Unlimited reads | **BUILT** | Was FALSE (analyst capped 15/day while card + Nav ∞ badge promised unlimited). `analyst.scans_per_day → Infinity`; Desk differentiates on capability, not rationing | +| Desk: Alt line ladder + edge ranking | **BUILT** | Was VAPOR (frontend existed, no producer). `analyzeViaEngine1` now grades the SAME feature vector at ±0.5/±1 lines (zero extra I/O), each rung carries grade + edge%, sorted by edge = the ranking. API-gated Desk-only | +| Desk: Quarter-Kelly sizing | **BUILT** | Was VAPOR (marketing copy only). `src/utils/kelly.js`: quarter-Kelly from the engine's quantile P(win) (game-log based) × the REAL captured book odds — either missing → no sizing, never a default. Rendered on the Desk card ("QTR-KELLY 1.4% of bankroll · at -115") | +| Desk: Real-time intelligence feed | **VERIFIED** | Ticker (snapshot exhaust) + 20-min intraday refresh (STEAM/VALUE/MOVE) + live slate strip | +| Desk: Parlay correlation analysis (phi) | **VERIFIED** | `parlayService` numeric correlation + S28 phi matrix; joint-history endpoint | +| Desk: Consensus vs model comparison | **VERIFIED (thin)** | MODEL vs LINE vs multi-book board on every read; `lineDiscrepancyDetector` exists server-side. Honest note: a labeled multi-book consensus average is a small future upgrade, the comparison itself is real | + +## Deviations made to keep the page honest +1. All "40+ factors" claims removed product-wide (7 files) — replaced with wording that is true at any factor count. +2. Analyst reads: 15/day → unlimited (the promise wins; capacity cost is nil — reads are cached compute). +3. "Cascade alerts" reworded to the shipped behavior (scratch cascades), not the aspirational one. +4. Free kill-conditions changed from hidden → locked preview to match "(blurred/locked)" copy. + +## What a paying user gets that didn't exist this morning +Alt line ladder with edge ranking · quarter-Kelly sizing on real prices · truly unlimited analyst reads · free-tier kill-condition previews. diff --git a/package.json b/package.json index f7d37fd..79c0471 100644 --- a/package.json +++ b/package.json @@ -45,5 +45,11 @@ "devDependencies": { "jest": "^30.3.0", "supertest": "^7.2.2" + }, + "jest": { + "testPathIgnorePatterns": [ + "/node_modules/", + "/.claude/" + ] } -} +} \ No newline at end of file diff --git a/src/config/tiers.js b/src/config/tiers.js index 36f448b..eb8d2fb 100644 --- a/src/config/tiers.js +++ b/src/config/tiers.js @@ -33,6 +33,9 @@ const TIERS = Object.freeze({ engine2: false, stat_dashboard: true, api_access: false, + // Session 62 (A1-S1) — Desk-only intelligence (pricing-page promises). + alt_line_ladder: false, + kelly_sizing: false, }), // Session 12 — $4.99/mo Africa tier. Slotted between free and analyst. // Same intelligence surface as analyst (reasoning + kill conditions @@ -49,9 +52,16 @@ const TIERS = Object.freeze({ engine2: false, stat_dashboard: true, api_access: false, + // Session 62 (A1-S1) — Desk-only intelligence (pricing-page promises). + alt_line_ladder: false, + kelly_sizing: false, }), analyst: Object.freeze({ - scans_per_day: 15, + // Session 62 (A1-S1) — the Founder Access card promises "Unlimited + // reads" and the Nav shows an ∞ badge for every paid tier; the 15/day + // cap made both claims false. Analyst is unlimited; Desk differentiates + // on capabilities (alt ladder, Kelly, feed), not on rationing reads. + scans_per_day: Infinity, grade_visible: true, reasoning_visible: true, kill_conditions_detail: true, @@ -61,6 +71,9 @@ const TIERS = Object.freeze({ engine2: false, stat_dashboard: true, api_access: false, + // Session 62 (A1-S1) — Desk-only intelligence (pricing-page promises). + alt_line_ladder: false, + kelly_sizing: false, }), desk: Object.freeze({ scans_per_day: Infinity, @@ -73,6 +86,8 @@ const TIERS = Object.freeze({ engine2: true, // LLM deep analysis stat_dashboard: true, api_access: false, + alt_line_ladder: true, + kelly_sizing: true, }), }); diff --git a/src/services/gradeSlateService.js b/src/services/gradeSlateService.js index de1829c..f995752 100644 --- a/src/services/gradeSlateService.js +++ b/src/services/gradeSlateService.js @@ -55,6 +55,10 @@ async function gradeBestSide(grade, prop, sport) { line: prop.line, sport, book: prop.book, + // Session 62 (A1-S1) — real book odds ride into the engine so the + // quarter-Kelly sizing can compute from actual prices (or not at all). + over_odds: prop.over_odds ?? null, + under_odds: prop.under_odds ?? null, }; const sides = await Promise.all([ Promise.resolve() diff --git a/src/services/intelligence/analyzeViaEngine1.js b/src/services/intelligence/analyzeViaEngine1.js index bd53e84..9bdd22e 100644 --- a/src/services/intelligence/analyzeViaEngine1.js +++ b/src/services/intelligence/analyzeViaEngine1.js @@ -406,6 +406,50 @@ async function analyzeViaEngine1(rawProp = {}) { // ledger model_value and rendered under the MODEL label on the card. legacy.projection = projection; + // Session 62 (A1-S1) — ALT LINE LADDER + EDGE RANKING. The pricing page + // sold it; the adapter/card were already built for it; nothing produced + // it. Re-grade the SAME feature vector at shifted lines (zero extra I/O) + // and rank by edge. Attached for every graded result; the API tier gate + // strips it below Desk. + try { + const baseLine = Number(prop.line); + if (Number.isFinite(baseLine)) { + const ladder = [-1, -0.5, 0, 0.5, 1] + .map((shift) => Math.round((baseLine + shift) * 100) / 100) + .filter((ln) => ln > 0) + .map((ln) => { + const shiftedProp = { ...prop, line: ln }; + const g = engine1.gradeProp({ features, trap, consistency, prop: shiftedProp }); + const adapted = toLegacyShape(g, { + player: rawProp.player, stat_type: rawProp.stat_type, + line: ln, direction: prop.direction, book: rawProp.book || 'unknown', sport: meta.sport, + }, { edgePct: edgePctFor(features, { ...shiftedProp, stat_type: rawProp.stat_type, direction: prop.direction }) }); + return { line: ln, grade: adapted.grade, edge_pct: adapted.edge_pct, base: ln === baseLine }; + }) + .sort((a, b) => (Number(b.edge_pct) || 0) - (Number(a.edge_pct) || 0)); + if (ladder.length > 1) legacy.alt_lines = ladder; + } + } catch { /* the ladder is additive — never breaks the read */ } + + // Session 62 (A1-S1) — QUARTER-KELLY. Real probability (quantile estimator + // over the actual game logs) × real book odds, or nothing. Never derived + // from confidence, never a default vig. + try { + const { estimateProbability } = require('./probabilityEstimator'); + const est = estimateProbability({ gameLogs: meta.gameLogs, line: prop.line, statType: rawProp.stat_type, features }); + const pWin = String(prop.direction || 'over').toLowerCase() === 'under' + ? (Number.isFinite(est.p_over) ? 1 - est.p_over : null) + : (Number.isFinite(est.p_over) ? est.p_over : null); + if (pWin != null) legacy.p_win = Math.round(pWin * 1000) / 1000; + const sideOdds = String(prop.direction || 'over').toLowerCase() === 'under' + ? rawProp.under_odds : rawProp.over_odds; + if (pWin != null && sideOdds != null) { + const { quarterKelly } = require('../../utils/kelly'); + const k = quarterKelly(pWin, sideOdds); + if (k) legacy.kelly = { ...k, odds: String(sideOdds) }; + } + } catch { /* sizing is additive — absent beats wrong */ } + return legacy; } diff --git a/src/utils/kelly.js b/src/utils/kelly.js new file mode 100644 index 0000000..68db4fa --- /dev/null +++ b/src/utils/kelly.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Quarter-Kelly sizing (Session 62 / A1-S1 — the pricing page promised it; + * now it exists). Pure math, no I/O. + * + * DATA SEMANTICS: Kelly needs a REAL win probability and REAL book odds. + * p comes from the engine's quantile probability estimator (game-log based); + * odds come from the captured book row. Either missing → null — a sizing + * recommendation is never fabricated from confidence scores or defaults. + */ + +function americanToDecimal(american) { + const a = Number(american); + if (!Number.isFinite(a) || a === 0) return null; + return a > 0 ? 1 + a / 100 : 1 + 100 / Math.abs(a); +} + +/** + * quarterKelly(p, americanOdds) → + * { full, quarter, pct } — fractions of bankroll (pct = quarter × 100, + * rounded to 0.1) — or null when p/odds are unusable or the edge is + * non-positive (Kelly says: no bet). + */ +function quarterKelly(p, americanOdds) { + const prob = Number(p); + if (!Number.isFinite(prob) || prob <= 0 || prob >= 1) return null; + const dec = americanToDecimal(americanOdds); + if (dec == null || dec <= 1) return null; + const b = dec - 1; + const full = (b * prob - (1 - prob)) / b; + if (!Number.isFinite(full) || full <= 0) return null; + const quarter = full / 4; + return { + full: Math.round(full * 1000) / 1000, + quarter: Math.round(quarter * 1000) / 1000, + pct: Math.round(quarter * 1000) / 10, // % of bankroll at quarter-Kelly + }; +} + +module.exports = { quarterKelly, americanToDecimal }; diff --git a/src/utils/tierGating.js b/src/utils/tierGating.js index f99fa81..366dc31 100644 --- a/src/utils/tierGating.js +++ b/src/utils/tierGating.js @@ -46,14 +46,23 @@ function lockKillConditions(list) { */ function applyTierGating(result, tierName) { if (!result || typeof result !== 'object') return result; + + // Session 62 (A1-S1) — Desk-only intelligence is stripped at the API, not + // just the adapter: alt line ladder + quarter-Kelly never leave the server + // for lower tiers. (The pricing page sells them as Desk-only; the gate + // makes that true at the wire.) + const deskGated = { ...result }; + if (!canAccess(tierName, 'alt_line_ladder')) delete deskGated.alt_lines; + if (!canAccess(tierName, 'kelly_sizing')) delete deskGated.kelly; + if (canAccess(tierName, 'reasoning_visible')) { - // Paid tier — pass through unchanged. - return result; + // Paid tier — everything else passes through unchanged. + return deskGated; } // Free tier: keep grade + confidence + edge_pct (the hook), redact // reasoning + kill condition explanations. return { - ...result, + ...deskGated, reasoning: lockReasoning(result.reasoning), kill_conditions_triggered: lockKillConditions(result.kill_conditions_triggered), tier_gated: true, diff --git a/tests/unit/promiseAudit.test.js b/tests/unit/promiseAudit.test.js new file mode 100644 index 0000000..1d7fdf8 --- /dev/null +++ b/tests/unit/promiseAudit.test.js @@ -0,0 +1,85 @@ +// Session 62 (A1-S1) — the feature-promise locks. Every pricing claim that +// was vapor is now real, and these tests keep it that way. + +const { quarterKelly, americanToDecimal } = require('../../src/utils/kelly'); +const { applyTierGating } = require('../../src/utils/tierGating'); +const { getScanLimit } = require('../../src/config/tiers'); +const fs = require('fs'); +const path = require('path'); +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); + +describe('quarter-Kelly (pricing: "Quarter-Kelly sizing recommendations")', () => { + test('computes from real probability × real odds', () => { + // p=0.55 at -110: b=0.909, f=(0.909*0.55-0.45)/0.909 ≈ 0.055 → quarter ≈ 1.4% + const k = quarterKelly(0.55, -110); + expect(k.quarter).toBeCloseTo(0.0138, 3); + expect(k.pct).toBeCloseTo(1.4, 1); + }); + test('no edge → null (Kelly says: no bet, never a fabricated size)', () => { + expect(quarterKelly(0.5, -110)).toBeNull(); + }); + test('missing inputs → null, never defaults', () => { + expect(quarterKelly(null, -110)).toBeNull(); + expect(quarterKelly(0.6, null)).toBeNull(); + expect(quarterKelly(1.2, -110)).toBeNull(); + }); + test('american conversion both signs', () => { + expect(americanToDecimal(-110)).toBeCloseTo(1.909, 3); + expect(americanToDecimal(150)).toBeCloseTo(2.5, 3); + }); +}); + +describe('Desk-only gate at the API (alt ladder + kelly never leave the server below Desk)', () => { + const RESULT = { grade: 'A', confidence: 70, alt_lines: [{ line: 1.5, grade: 'A' }], kelly: { pct: 1.4 }, reasoning: { summary: 'x' }, kill_conditions_triggered: [] }; + test('desk keeps both', () => { + const g = applyTierGating(RESULT, 'desk'); + expect(g.alt_lines).toBeTruthy(); + expect(g.kelly).toBeTruthy(); + }); + test('analyst loses both, keeps reasoning', () => { + const g = applyTierGating(RESULT, 'analyst'); + expect(g.alt_lines).toBeUndefined(); + expect(g.kelly).toBeUndefined(); + expect(g.reasoning.summary).toBe('x'); + }); + test('free loses both AND gets locked reasoning', () => { + const g = applyTierGating(RESULT, 'free'); + expect(g.alt_lines).toBeUndefined(); + expect(g.kelly).toBeUndefined(); + expect(g.reasoning.locked).toBe(true); + }); +}); + +describe('read limits match the pricing page', () => { + test('free 3/day · africa 10/day · analyst unlimited (Founder promise) · desk unlimited', () => { + expect(getScanLimit('free')).toBe(3); + expect(getScanLimit('africa')).toBe(10); + expect(getScanLimit('analyst')).toBe(Infinity); + expect(getScanLimit('desk')).toBe(Infinity); + }); +}); + +describe('no unverifiable factor-count claims anywhere', () => { + it.each([ + 'components/Pricing.tsx', 'app/help/page.tsx', 'app/about/page.tsx', + 'app/upgrade/desk/page.tsx', 'components/GradeCard.tsx', + 'components/vyndr/GradeResultCard.tsx', 'components/vyndr/ProcessingGrade.tsx', + ])('%s carries no "40+" claim', (rel) => { + expect(read(rel)).not.toContain('40+'); + }); +}); + +describe('free tier sees the promised LOCKED PREVIEW of kill conditions', () => { + const { mapScanToGradeResult } = require('../../web/src/lib/gradeAdapter'); + test('free gets code-only teaser chips; analyst gets reasons', () => { + const input = { + player: 'X', stat: 'hits', line: 1.5, direction: 'over', grade: 'B', + kill_conditions: [{ code: 'TRAP', reason: 'Multiple trap signals firing.' }], + }; + const free = mapScanToGradeResult({ ...input, tier: 'free' }); + expect(free.killConditions).toEqual(['TRAP — upgrade to see details']); + const paid = mapScanToGradeResult({ ...input, tier: 'analyst' }); + expect(paid.killConditions).toEqual(['Multiple trap signals firing.']); + }); +}); diff --git a/tests/unit/scanLimit.test.js b/tests/unit/scanLimit.test.js index 2a5130b..d467d8c 100644 --- a/tests/unit/scanLimit.test.js +++ b/tests/unit/scanLimit.test.js @@ -61,16 +61,18 @@ describe('scanLimit middleware', () => { expect(lastHeaders['X-Scans-Limit']).toBe('3'); }); - test('analyst tier gets 15 scans/day', () => { + // Session 62 (A1-S1) — analyst is UNLIMITED now (the Founder card promises + // "Unlimited reads"; the 15/day cap made the pricing page false). + test('analyst tier is unlimited (Founder promise)', () => { const next = jest.fn(); let blockedAt = null; - for (let i = 0; i < 16; i += 1) { + for (let i = 0; i < 30; i += 1) { const { req, res } = mockReqRes({ tier: 'analyst', userId: 'u-analyst' }); mw(req, res, next); if (res.statusCode === 429 && blockedAt == null) blockedAt = i; } - expect(blockedAt).toBe(15); - expect(next).toHaveBeenCalledTimes(15); + expect(blockedAt).toBeNull(); + expect(next).toHaveBeenCalledTimes(30); }); test('desk tier is unlimited — never blocks', () => { diff --git a/tests/unit/tiers.test.js b/tests/unit/tiers.test.js index 11ac5b8..c703ddb 100644 --- a/tests/unit/tiers.test.js +++ b/tests/unit/tiers.test.js @@ -58,7 +58,7 @@ describe('tiers config', () => { test('analyst opens reasoning + kill conditions + alerts', () => { const a = TIERS.analyst; - expect(a.scans_per_day).toBe(15); + expect(a.scans_per_day).toBe(Infinity); // Session 62 — Founder 'Unlimited reads' promise expect(a.reasoning_visible).toBe(true); expect(a.kill_conditions_detail).toBe(true); expect(a.alerts).toBe(true); @@ -87,7 +87,7 @@ describe('tiers config', () => { test('getScanLimit returns the per-tier daily cap', () => { expect(getScanLimit('free')).toBe(3); - expect(getScanLimit('analyst')).toBe(15); + expect(getScanLimit('analyst')).toBe(Infinity); expect(getScanLimit('desk')).toBe(Infinity); }); diff --git a/tests/unit/vyndrCoreScreens.test.js b/tests/unit/vyndrCoreScreens.test.js index 99c3ff7..f91de12 100644 --- a/tests/unit/vyndrCoreScreens.test.js +++ b/tests/unit/vyndrCoreScreens.test.js @@ -41,7 +41,9 @@ describe('Phase D — grade adapter (engine → §7 contract)', () => { it('FREE tier teases 3 signals, hides kill conditions and the alt ladder', () => { const out = adapter.mapScanToGradeResult({ ...base, tier: 'free' }); expect(out.signals.length).toBe(3); - expect(out.killConditions).toEqual([]); + // Session 62 (A1-S1) — free gets the pricing-promised LOCKED PREVIEW + // (code-only teaser), not silence. + expect(out.killConditions.every((k) => k.includes('upgrade to see details'))).toBe(true); expect(out.altLadder).toEqual([]); }); diff --git a/web/src/app/about/page.tsx b/web/src/app/about/page.tsx index e2dd69d..7fd9e7c 100644 --- a/web/src/app/about/page.tsx +++ b/web/src/app/about/page.tsx @@ -19,7 +19,7 @@ export default function AboutPage() {

VYNDR is prop intelligence — a proprietary terminal that reads the context the books hide. Beyond the box score: form, matchup, usage, rest, the cascade when a star sits. Then a single - grade, earned across 40+ factors, that tells you whether the number is worth taking. + grade, earned across every factor the model weighs, that tells you whether the number is worth taking.

We are an analytics tool, not a sportsbook. We don't take wagers. We hand you the read and diff --git a/web/src/app/help/page.tsx b/web/src/app/help/page.tsx index ef3dd0a..90deba8 100644 --- a/web/src/app/help/page.tsx +++ b/web/src/app/help/page.tsx @@ -5,7 +5,7 @@ import SectionHead from '@/components/vyndr/SectionHead'; import TerminalInput from '@/components/vyndr/TerminalInput'; const FAQ: { cat: string; q: string; a: string }[] = [ - { cat: 'GRADES', q: 'What does a VYNDR grade mean?', a: 'A grade is our confidence that a prop hits, weighed across 40+ factors. A+ is the rarest and strongest; D means stay away. The letter is earned, not opinion.' }, + { cat: 'GRADES', q: 'What does a VYNDR grade mean?', a: 'A grade is our confidence that a prop hits, weighed across every factor the model computes for that prop. A+ is the rarest and strongest; D means stay away. The letter is earned, not opinion.' }, { cat: 'GRADES', q: 'What is a kill condition?', a: 'A red flag we detect that can sink a prop regardless of the stats — a blowout risk, a minutes cap, a brutal matchup. Analyst and Desk see them in full.' }, { cat: 'READS', q: 'How many free reads do I get?', a: 'Five reads every calendar month on the Free tier. The counter resets at the start of each month.' }, { cat: 'READS', q: 'What counts as a read?', a: 'Grading one prop. Viewing the same prop twice in a session only counts once.' }, diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index c98f596..c0ae89a 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -44,7 +44,8 @@ interface ScanResponse { confidence?: number; sample_size?: number; factors?: Record; - alt_lines?: { line: number; grade: string; hit_rate?: number; edge_pct?: number }[]; + alt_lines?: { line: number; grade: string; hit_rate?: number; edge_pct?: number; base?: boolean }[]; + kelly?: { pct: number; quarter: number; full: number; odds: string }; kill_conditions?: { code: string; reason: string }[]; reasoning?: string; historical_hit_rate?: number; @@ -733,6 +734,7 @@ export default function ScanPage() { sample_size: result.sample_size, factors: result.factors, alt_lines: result.alt_lines, + kelly: result.kelly, kill_conditions: result.kill_conditions, tier, // Session 44 — forward the engine's intel fields so the grade diff --git a/web/src/app/upgrade/desk/page.tsx b/web/src/app/upgrade/desk/page.tsx index fd5c1cf..176fd1d 100644 --- a/web/src/app/upgrade/desk/page.tsx +++ b/web/src/app/upgrade/desk/page.tsx @@ -14,7 +14,7 @@ const PRICES: Record; - altLadder?: Array<{ line: number; grade: string }>; + altLadder?: Array<{ line: number; grade: string; edge?: number | null; base?: boolean }>; + // Session 62 (A1-S1) — quarter-Kelly sizing (Desk only; real p × real odds). + kelly?: { pct: number; quarter: number; odds: string }; // Session 42 — Player Intelligence additions (all optional; self-hide). archetypeBlend?: Array<{ archetype: string; weight: number }>; propDNA?: { reliable: string[]; volatile: string[] }; @@ -183,7 +185,7 @@ export default function GradeResultCard({ {/* 5. SIGNAL BREAKDOWN */} {d.signals.length > 0 && (

- SIGNAL BREAKDOWN · {d.signals.length} OF 40+ FACTORS + SIGNAL BREAKDOWN · {d.signals.length} FACTORS SHOWN
{d.signals.map((s, i) => (
@@ -266,12 +268,23 @@ export default function GradeResultCard({
{d.altLadder!.map((a, i) => ( -
-
{a.line}
+
+
{a.line}{a.base ? ' •' : ''}
{a.grade}
+ {a.edge != null && ( +
= 0 ? 'var(--g-a)' : 'var(--miss)', marginTop: 3 }}>{Number(a.edge) >= 0 ? '+' : ''}{a.edge}%
+ )}
))}
+ {/* Quarter-Kelly (Desk) — real probability × the real captured odds. */} + {d.kelly && ( +
+ QTR-KELLY + {d.kelly.pct}% of bankroll + · at {d.kelly.odds} +
+ )}
)} diff --git a/web/src/components/vyndr/ProcessingGrade.tsx b/web/src/components/vyndr/ProcessingGrade.tsx index e2fc697..fcadd13 100644 --- a/web/src/components/vyndr/ProcessingGrade.tsx +++ b/web/src/components/vyndr/ProcessingGrade.tsx @@ -106,7 +106,7 @@ export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToP
{pct}%
-
WEIGHING 40+ FACTORS
+
WEIGHING THE FACTORS
diff --git a/web/src/lib/gradeAdapter.js b/web/src/lib/gradeAdapter.js index 7c19cde..f646f60 100644 --- a/web/src/lib/gradeAdapter.js +++ b/web/src/lib/gradeAdapter.js @@ -64,8 +64,14 @@ function mapScanToGradeResult(input = {}) { // richer blurred-paywall treatment returns in Phase G (Session 38). const allSignals = toSignals(input.factors); const signals = tier === 'free' ? allSignals.slice(0, 3) : allSignals; + // Session 62 (A1-S1) — the pricing page promises free users a LOCKED + // PREVIEW of kill conditions (the count + codes exist server-side via + // tierGating), not their absence. Free sees "⚠ TRAP — upgrade to see + // details"; paid sees the reasons. const killConditions = tier === 'free' - ? [] + ? (input.kill_conditions || []) + .map((k) => (k && k.code ? `${k.code} — upgrade to see details` : '')) + .filter(Boolean) : (input.kill_conditions || []).map((k) => (typeof k === 'string' ? k : (k && k.reason) || '')).filter(Boolean); return { @@ -89,8 +95,10 @@ function mapScanToGradeResult(input = {}) { killConditions, books: Array.isArray(input.books) ? input.books : [], altLadder: includeAlt && Array.isArray(input.alt_lines) - ? input.alt_lines.map((a) => ({ line: a.line, grade: a.grade })) + ? input.alt_lines.map((a) => ({ line: a.line, grade: a.grade, edge: a.edge_pct, base: a.base })) : [], + // Session 62 (A1-S1) — quarter-Kelly (Desk; API already strips below). + kelly: includeAlt && input.kelly && input.kelly.pct != null ? input.kelly : undefined, // Session 42 — Player Intelligence additions. All OPTIONAL + self-hiding: // they only render once the engine supplies them (Session 43 data pipeline). ...buildIntelFields(input),