S1 (a1): feature-promise audit — no claim survives unverified

PROMISE-AUDIT.md: every /pricing claim → verified/built/reworded.
BUILT (was vapor): alt line ladder + edge ranking (same-features regrade
at shifted lines, Desk-gated at the API), quarter-Kelly (engine quantile
P(win) x real captured odds — either missing → no sizing), free-tier
kill-condition locked previews. FIXED (was false): analyst 15/day cap vs
the Founder 'Unlimited reads' promise → analyst unlimited; every '40+
factors' claim (real count: 22 named features) reworded truthfully in 7
files. VERIFIED: cascade alerts (real, wired), phi correlation,
leg history, cross-book comparison, WC soccer, real-time feed.
Locked by tests/unit/promiseAudit.test.js. Jest now ignores
.claude/worktrees (parallel agents' suites no longer leak into runs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 14:24:41 -04:00
parent e4d2e79f95
commit d242b11b4b
20 changed files with 296 additions and 30 deletions
+35
View File
@@ -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.
+7 -1
View File
@@ -45,5 +45,11 @@
"devDependencies": { "devDependencies": {
"jest": "^30.3.0", "jest": "^30.3.0",
"supertest": "^7.2.2" "supertest": "^7.2.2"
},
"jest": {
"testPathIgnorePatterns": [
"/node_modules/",
"/.claude/"
]
} }
} }
+16 -1
View File
@@ -33,6 +33,9 @@ const TIERS = Object.freeze({
engine2: false, engine2: false,
stat_dashboard: true, stat_dashboard: true,
api_access: false, 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. // Session 12 — $4.99/mo Africa tier. Slotted between free and analyst.
// Same intelligence surface as analyst (reasoning + kill conditions // Same intelligence surface as analyst (reasoning + kill conditions
@@ -49,9 +52,16 @@ const TIERS = Object.freeze({
engine2: false, engine2: false,
stat_dashboard: true, stat_dashboard: true,
api_access: false, api_access: false,
// Session 62 (A1-S1) — Desk-only intelligence (pricing-page promises).
alt_line_ladder: false,
kelly_sizing: false,
}), }),
analyst: Object.freeze({ 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, grade_visible: true,
reasoning_visible: true, reasoning_visible: true,
kill_conditions_detail: true, kill_conditions_detail: true,
@@ -61,6 +71,9 @@ const TIERS = Object.freeze({
engine2: false, engine2: false,
stat_dashboard: true, stat_dashboard: true,
api_access: false, api_access: false,
// Session 62 (A1-S1) — Desk-only intelligence (pricing-page promises).
alt_line_ladder: false,
kelly_sizing: false,
}), }),
desk: Object.freeze({ desk: Object.freeze({
scans_per_day: Infinity, scans_per_day: Infinity,
@@ -73,6 +86,8 @@ const TIERS = Object.freeze({
engine2: true, // LLM deep analysis engine2: true, // LLM deep analysis
stat_dashboard: true, stat_dashboard: true,
api_access: false, api_access: false,
alt_line_ladder: true,
kelly_sizing: true,
}), }),
}); });
+4
View File
@@ -55,6 +55,10 @@ async function gradeBestSide(grade, prop, sport) {
line: prop.line, line: prop.line,
sport, sport,
book: prop.book, 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([ const sides = await Promise.all([
Promise.resolve() Promise.resolve()
@@ -406,6 +406,50 @@ async function analyzeViaEngine1(rawProp = {}) {
// ledger model_value and rendered under the MODEL label on the card. // ledger model_value and rendered under the MODEL label on the card.
legacy.projection = projection; 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; return legacy;
} }
+41
View File
@@ -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 };
+12 -3
View File
@@ -46,14 +46,23 @@ function lockKillConditions(list) {
*/ */
function applyTierGating(result, tierName) { function applyTierGating(result, tierName) {
if (!result || typeof result !== 'object') return result; 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')) { if (canAccess(tierName, 'reasoning_visible')) {
// Paid tier — pass through unchanged. // Paid tier — everything else passes through unchanged.
return result; return deskGated;
} }
// Free tier: keep grade + confidence + edge_pct (the hook), redact // Free tier: keep grade + confidence + edge_pct (the hook), redact
// reasoning + kill condition explanations. // reasoning + kill condition explanations.
return { return {
...result, ...deskGated,
reasoning: lockReasoning(result.reasoning), reasoning: lockReasoning(result.reasoning),
kill_conditions_triggered: lockKillConditions(result.kill_conditions_triggered), kill_conditions_triggered: lockKillConditions(result.kill_conditions_triggered),
tier_gated: true, tier_gated: true,
+85
View File
@@ -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.']);
});
});
+6 -4
View File
@@ -61,16 +61,18 @@ describe('scanLimit middleware', () => {
expect(lastHeaders['X-Scans-Limit']).toBe('3'); 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(); const next = jest.fn();
let blockedAt = null; 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' }); const { req, res } = mockReqRes({ tier: 'analyst', userId: 'u-analyst' });
mw(req, res, next); mw(req, res, next);
if (res.statusCode === 429 && blockedAt == null) blockedAt = i; if (res.statusCode === 429 && blockedAt == null) blockedAt = i;
} }
expect(blockedAt).toBe(15); expect(blockedAt).toBeNull();
expect(next).toHaveBeenCalledTimes(15); expect(next).toHaveBeenCalledTimes(30);
}); });
test('desk tier is unlimited — never blocks', () => { test('desk tier is unlimited — never blocks', () => {
+2 -2
View File
@@ -58,7 +58,7 @@ describe('tiers config', () => {
test('analyst opens reasoning + kill conditions + alerts', () => { test('analyst opens reasoning + kill conditions + alerts', () => {
const a = TIERS.analyst; 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.reasoning_visible).toBe(true);
expect(a.kill_conditions_detail).toBe(true); expect(a.kill_conditions_detail).toBe(true);
expect(a.alerts).toBe(true); expect(a.alerts).toBe(true);
@@ -87,7 +87,7 @@ describe('tiers config', () => {
test('getScanLimit returns the per-tier daily cap', () => { test('getScanLimit returns the per-tier daily cap', () => {
expect(getScanLimit('free')).toBe(3); expect(getScanLimit('free')).toBe(3);
expect(getScanLimit('analyst')).toBe(15); expect(getScanLimit('analyst')).toBe(Infinity);
expect(getScanLimit('desk')).toBe(Infinity); expect(getScanLimit('desk')).toBe(Infinity);
}); });
+3 -1
View File
@@ -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', () => { it('FREE tier teases 3 signals, hides kill conditions and the alt ladder', () => {
const out = adapter.mapScanToGradeResult({ ...base, tier: 'free' }); const out = adapter.mapScanToGradeResult({ ...base, tier: 'free' });
expect(out.signals.length).toBe(3); 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([]); expect(out.altLadder).toEqual([]);
}); });
+1 -1
View File
@@ -19,7 +19,7 @@ export default function AboutPage() {
<p className="mono" style={{ fontSize: 14.5, color: 'var(--text-1)', lineHeight: 1.7 }}> <p className="mono" style={{ fontSize: 14.5, color: 'var(--text-1)', lineHeight: 1.7 }}>
VYNDR is prop intelligence a proprietary terminal that reads the context the books hide. 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 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.
</p> </p>
<p className="mono" style={{ fontSize: 14.5, color: 'var(--text-1)', lineHeight: 1.7 }}> <p className="mono" style={{ fontSize: 14.5, color: 'var(--text-1)', lineHeight: 1.7 }}>
We are an analytics tool, not a sportsbook. We don&apos;t take wagers. We hand you the read and We are an analytics tool, not a sportsbook. We don&apos;t take wagers. We hand you the read and
+1 -1
View File
@@ -5,7 +5,7 @@ import SectionHead from '@/components/vyndr/SectionHead';
import TerminalInput from '@/components/vyndr/TerminalInput'; import TerminalInput from '@/components/vyndr/TerminalInput';
const FAQ: { cat: string; q: string; a: string }[] = [ 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: '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: '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.' }, { cat: 'READS', q: 'What counts as a read?', a: 'Grading one prop. Viewing the same prop twice in a session only counts once.' },
+3 -1
View File
@@ -44,7 +44,8 @@ interface ScanResponse {
confidence?: number; confidence?: number;
sample_size?: number; sample_size?: number;
factors?: Record<string, string>; factors?: Record<string, string>;
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 }[]; kill_conditions?: { code: string; reason: string }[];
reasoning?: string; reasoning?: string;
historical_hit_rate?: number; historical_hit_rate?: number;
@@ -733,6 +734,7 @@ export default function ScanPage() {
sample_size: result.sample_size, sample_size: result.sample_size,
factors: result.factors, factors: result.factors,
alt_lines: result.alt_lines, alt_lines: result.alt_lines,
kelly: result.kelly,
kill_conditions: result.kill_conditions, kill_conditions: result.kill_conditions,
tier, tier,
// Session 44 — forward the engine's intel fields so the grade // Session 44 — forward the engine's intel fields so the grade
+1 -1
View File
@@ -14,7 +14,7 @@ const PRICES: Record<Cadence, { display: string; sub: string; checkoutParam: str
const EARLY_FEATURES = [ const EARLY_FEATURES = [
'Unlimited reads', 'Unlimited reads',
'Full factor analysis (40+ signals)', 'Full factor analysis — every signal the model weighs',
'Kill conditions surfaced inline', 'Kill conditions surfaced inline',
'Cascade alerts when lineups shift', 'Cascade alerts when lineups shift',
'Parlay leg history with grades', 'Parlay leg history with grades',
+1 -1
View File
@@ -8,7 +8,7 @@ import { markReadComplete } from '@/lib/reads';
// Short, plain-English explanations rendered when Explain Like I'm New is on. // Short, plain-English explanations rendered when Explain Like I'm New is on.
// Each key maps to one piece of data we surface on this card. // Each key maps to one piece of data we surface on this card.
const EXPLANATIONS = { const EXPLANATIONS = {
grade: "Our overall confidence. A-minus means we estimate about a 76% chance this prop hits, based on 40+ factors.", grade: "Our overall confidence. A-minus means we estimate about a 76% chance this prop hits, based on every factor the model weighs for the prop.",
projection: "What our model predicts the player will actually do tonight for this stat.", projection: "What our model predicts the player will actually do tonight for this stat.",
line: "The number the sportsbook set. The player needs to go over or under it.", line: "The number the sportsbook set. The player needs to go over or under it.",
overUnder: 'Over = the player needs MORE than the line. Under = LESS.', overUnder: 'Over = the player needs MORE than the line. Under = LESS.',
+6 -6
View File
@@ -36,8 +36,8 @@ const TIERS: TierConfig[] = [
'Confidence indicator', 'Confidence indicator',
], ],
locked: [ locked: [
'Factor analysis (blurred)', 'Factor analysis (3-signal preview)',
'Kill conditions (blurred)', 'Kill conditions (locked preview)',
'Alt line ladder (locked)', 'Alt line ladder (locked)',
], ],
highlight: false, highlight: false,
@@ -54,13 +54,13 @@ const TIERS: TierConfig[] = [
cta: 'Unlock Africa Pricing', cta: 'Unlock Africa Pricing',
features: [ features: [
'10 reads per day', '10 reads per day',
'Full factor analysis (40+ signals)', 'Full factor analysis — every signal the model weighs',
'Kill conditions surfaced inline', 'Kill conditions surfaced inline',
'Grade + reasoning visible', 'Grade + reasoning visible',
'World Cup soccer intelligence', 'World Cup soccer intelligence',
], ],
locked: [ locked: [
'Cascade alerts (Analyst+)', 'Scratch cascade alerts (Analyst+)',
'Alt line ladder (Desk only)', 'Alt line ladder (Desk only)',
], ],
highlight: false, highlight: false,
@@ -76,9 +76,9 @@ const TIERS: TierConfig[] = [
cta: 'Lock Founder Price', cta: 'Lock Founder Price',
features: [ features: [
'Unlimited reads', 'Unlimited reads',
'Full factor analysis (40+ signals)', 'Full factor analysis — every signal the model weighs',
'Kill conditions surfaced inline', 'Kill conditions surfaced inline',
'Cascade alerts when lineups shift', 'Scratch cascade alerts when players are pulled',
'Parlay leg history with grades', 'Parlay leg history with grades',
'Sportsbook deep links', 'Sportsbook deep links',
], ],
+17 -4
View File
@@ -24,7 +24,9 @@ export interface GradeResultData {
signals: string[]; signals: string[];
killConditions?: string[]; killConditions?: string[];
books: Array<{ name: string; line: number; odds: string; best?: boolean }>; books: Array<{ name: string; line: number; odds: string; best?: boolean }>;
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). // Session 42 — Player Intelligence additions (all optional; self-hide).
archetypeBlend?: Array<{ archetype: string; weight: number }>; archetypeBlend?: Array<{ archetype: string; weight: number }>;
propDNA?: { reliable: string[]; volatile: string[] }; propDNA?: { reliable: string[]; volatile: string[] };
@@ -183,7 +185,7 @@ export default function GradeResultCard({
{/* 5. SIGNAL BREAKDOWN */} {/* 5. SIGNAL BREAKDOWN */}
{d.signals.length > 0 && ( {d.signals.length > 0 && (
<div style={{ padding: '16px 20px' }}> <div style={{ padding: '16px 20px' }}>
<SectionHead style={{ marginBottom: 12 }}>SIGNAL BREAKDOWN · {d.signals.length} OF 40+ FACTORS</SectionHead> <SectionHead style={{ marginBottom: 12 }}>SIGNAL BREAKDOWN · {d.signals.length} FACTORS SHOWN</SectionHead>
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
{d.signals.map((s, i) => ( {d.signals.map((s, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 11 }}> <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
@@ -266,12 +268,23 @@ export default function GradeResultCard({
</SectionHead> </SectionHead>
<div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}>
{d.altLadder!.map((a, i) => ( {d.altLadder!.map((a, i) => (
<div key={i} style={{ flex: '1 1 0', minWidth: 64, textAlign: 'center', padding: '9px 6px', background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 6 }}> <div key={i} style={{ flex: '1 1 0', minWidth: 64, textAlign: 'center', padding: '9px 6px', background: 'var(--bg-2)', border: a.base ? '1px solid var(--border-hi)' : '1px solid var(--border)', borderRadius: 6 }}>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 5 }}>{a.line}</div> <div className="mono" style={{ fontSize: 12, color: a.base ? 'var(--text-0)' : 'var(--text-1)', marginBottom: 5 }}>{a.line}{a.base ? ' •' : ''}</div>
<div className="mono" style={{ fontSize: 17, fontWeight: 800, color: gradeColor(a.grade) }}>{a.grade}</div> <div className="mono" style={{ fontSize: 17, fontWeight: 800, color: gradeColor(a.grade) }}>{a.grade}</div>
{a.edge != null && (
<div className="mono" style={{ fontSize: 10, color: Number(a.edge) >= 0 ? 'var(--g-a)' : 'var(--miss)', marginTop: 3 }}>{Number(a.edge) >= 0 ? '+' : ''}{a.edge}%</div>
)}
</div> </div>
))} ))}
</div> </div>
{/* Quarter-Kelly (Desk) — real probability × the real captured odds. */}
{d.kelly && (
<div className="mono" style={{ marginTop: 10, fontSize: 12, color: 'var(--text-1)' }}>
<span className="label" style={{ fontSize: 9.5, marginRight: 8 }}>QTR-KELLY</span>
<span style={{ color: 'var(--g-a)', fontWeight: 700 }}>{d.kelly.pct}% of bankroll</span>
<span style={{ color: 'var(--text-2)' }}> · at {d.kelly.odds}</span>
</div>
)}
</div> </div>
)} )}
+1 -1
View File
@@ -106,7 +106,7 @@ export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToP
</div> </div>
<div style={{ marginLeft: 'auto', textAlign: 'right' }}> <div style={{ marginLeft: 'auto', textAlign: 'right' }}>
<div className="mono" style={{ fontSize: 30, fontWeight: 800, color: 'var(--g-ap)', textShadow: '0 0 16px rgba(0,255,184,.5)' }}>{pct}%</div> <div className="mono" style={{ fontSize: 30, fontWeight: 800, color: 'var(--g-ap)', textShadow: '0 0 16px rgba(0,255,184,.5)' }}>{pct}%</div>
<div className="mono" style={{ fontSize: 9.5, letterSpacing: '0.12em', color: 'rgba(232,255,244,.5)' }}>WEIGHING 40+ FACTORS</div> <div className="mono" style={{ fontSize: 9.5, letterSpacing: '0.12em', color: 'rgba(232,255,244,.5)' }}>WEIGHING THE FACTORS</div>
</div> </div>
</div> </div>
+10 -2
View File
@@ -64,8 +64,14 @@ function mapScanToGradeResult(input = {}) {
// richer blurred-paywall treatment returns in Phase G (Session 38). // richer blurred-paywall treatment returns in Phase G (Session 38).
const allSignals = toSignals(input.factors); const allSignals = toSignals(input.factors);
const signals = tier === 'free' ? allSignals.slice(0, 3) : allSignals; 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' 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); : (input.kill_conditions || []).map((k) => (typeof k === 'string' ? k : (k && k.reason) || '')).filter(Boolean);
return { return {
@@ -89,8 +95,10 @@ function mapScanToGradeResult(input = {}) {
killConditions, killConditions,
books: Array.isArray(input.books) ? input.books : [], books: Array.isArray(input.books) ? input.books : [],
altLadder: includeAlt && Array.isArray(input.alt_lines) 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: // Session 42 — Player Intelligence additions. All OPTIONAL + self-hiding:
// they only render once the engine supplies them (Session 43 data pipeline). // they only render once the engine supplies them (Session 43 data pipeline).
...buildIntelFields(input), ...buildIntelFields(input),