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:
@@ -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.
|
||||||
@@ -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
@@ -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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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.']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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't take wagers. We hand you the read and
|
We are an analytics tool, not a sportsbook. We don't take wagers. We hand you the read and
|
||||||
|
|||||||
@@ -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.' },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user