713f90183f
Serving/gating change only. src/services/ untouched: no grade, model or
settlement-logic change. Pricing = Build 2, migration = Build 3.
WHY THE PRIOR GATE WAS WRONG: freeing grades at resolution made the free tier a
ONE-DAY-DELAYED FEED OF THE WHOLE PRODUCT — settlement is nightly, so a bettor
watching one cycle behind got the entire method free. There is now NO
per-grade resolution flip: an itemized grade, tonight's or last week's, is
Analyst+.
FREE now gets, none of it itemizing the nightly slate:
1. the full data aggregator (unchanged — schedule, per-book lines, stats,
streaks, hubs)
2. the AGGREGATE track record, which ALREADY EXISTS and is public:
/api/accuracy (sample 937, byGrade tiers, per-sport mlb+wnba, min_sample 20)
and /api/ledger/accuracy (per-grade buckets). The honest-record laws are
already honored there — A/D/F return pct:null under the n>=20 threshold
rather than a fake percentage.
3. a CAPPED, day-rotated sample of resolved calls for texture: cap 3, stable
within a day, rotates across days, and only RESOLVED rows are eligible so a
live read can never be sampled. The cap is what kills the exploit — three
rotating past calls cannot reconstruct a nightly slate, whereas the full
settled list is the feed one cycle late.
4. the locked shell of tonight's reads: they exist, and their shape.
EVERY itemized grade for an unentitled tier now loses grade, confidence,
confidence_basis, reasoning, kill_conditions_triggered, projection, edge_pct,
matchup_grade, form, alt_lines and kelly, and is stamped locked. Free-side DATA
survives so the board still reads as real: player, market, line, book_odds,
fair_odds (the de-vigged fair number is the free hook and is never the paywall),
season/last10 stats, archetype — and `outcome`, because a RESULT is a fact
rather than a judgment.
The tease stays aggregate-only (live_locked {count, tiers}) computed from the
ungated rows and never joined back to one, and no gated row carries a grade, so
nobody can work out which prop is the A.
Floor: 319 suites / 3970 tests green, web build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
178 lines
7.5 KiB
JavaScript
178 lines
7.5 KiB
JavaScript
'use strict';
|
|
|
|
const { canAccess } = require('../config/tiers');
|
|
|
|
/**
|
|
* Snapshot tier gating (Session 67) — the model price never leaves the server
|
|
* for an unentitled viewer.
|
|
*
|
|
* WHY THIS EXISTS: `GET /api/snapshot/:sport` is PUBLIC and unauthenticated.
|
|
* Session 66 gated `model_odds` on `/api/analyze`, but the snapshot endpoint
|
|
* bypassed that gate entirely and served the model price to anonymous callers
|
|
* on every graded row. This closes it at the same layer as the CLV gate
|
|
* (`routes/ledger.js` columnsFor/stripClv): strip on the way out, never hide
|
|
* in the client.
|
|
*
|
|
* WHAT MUST GO, AND WHY IT IS MORE THAN `model_odds`:
|
|
* model_odds the gated number itself.
|
|
* p_win model_odds IS `impliedProbToAmerican(p_win)` — shipping p_win
|
|
* is shipping the price in a different base.
|
|
* ev_pct INVERTIBLE. ev is a function of (p_win, book_odds) and
|
|
* book_odds is public, so p_win — and therefore the price — can
|
|
* be recovered exactly from it. A gate that leaves ev behind is
|
|
* not a gate.
|
|
* value a boolean over (ev, takeable); with the band public it leaks a
|
|
* takeable bound on ev. Cheap to drop, so drop them.
|
|
*
|
|
* WHAT STAYS ON EVERY TIER — deliberately:
|
|
* book_odds, fair_odds, fair_prob, overround, devig_method.
|
|
* These are MARKET facts and the de-vig of market facts. The de-vigged fair
|
|
* number is the free-tier hook and the hero of the price layer: THE FAIR LEG
|
|
* IS NEVER THE PAYWALL. Only VYNDR's own price gates.
|
|
*
|
|
* `model_price_locked` is stamped on rows that still carry a book+fair pair so
|
|
* the client renders the lock teaser rather than an absent leg — a gated price
|
|
* must never be mistaken for a missing one.
|
|
*/
|
|
|
|
// Model-derived fields. Every one of these can reconstruct the model price.
|
|
const MODEL_FIELDS = Object.freeze(['model_odds', 'p_win', 'ev_pct', 'value', 'takeable']);
|
|
|
|
// Market facts + their de-vig. Never stripped, on any tier.
|
|
const MARKET_FIELDS = Object.freeze(['book_odds', 'fair_odds', 'fair_prob', 'overround', 'devig_method']);
|
|
|
|
/** Does this tier receive VYNDR's own price? */
|
|
function entitledToModelPrice(tierName) {
|
|
return canAccess(tierName, 'model_price');
|
|
}
|
|
|
|
/**
|
|
* stripModelPrice(grades, tierName) — returns a NEW array with the
|
|
* model-derived fields removed for unentitled tiers. Entitled tiers get the
|
|
* rows back untouched (same reference — no needless copying on the hot path).
|
|
*/
|
|
function stripModelPrice(grades, tierName) {
|
|
if (!Array.isArray(grades)) return grades;
|
|
if (entitledToModelPrice(tierName)) return grades;
|
|
return grades.map((g) => {
|
|
if (!g || typeof g !== 'object') return g;
|
|
const out = { ...g };
|
|
for (const f of MODEL_FIELDS) delete out[f];
|
|
// Only flag a LOCK where there is a price story to lock a third leg onto.
|
|
if (out.book_odds != null && out.fair_odds != null) out.model_price_locked = true;
|
|
return out;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
MODEL_FIELDS,
|
|
MARKET_FIELDS,
|
|
entitledToModelPrice,
|
|
stripModelPrice,
|
|
};
|
|
|
|
/* ===========================================================================
|
|
* BUILD 1 (CORRECTED) — ITEMIZED GRADES ARE PAID, LIVE **AND** SETTLED.
|
|
*
|
|
* WHY THE RESOLUTION-FLIP WAS WRONG: settlement is nightly, so freeing a grade
|
|
* at resolution turns the free tier into a ONE-DAY-DELAYED FEED OF THE WHOLE
|
|
* PRODUCT. A bettor watching one cycle behind gets the entire method for free.
|
|
* That exploit is why there is no per-grade flip here: an itemized grade —
|
|
* tonight's or last week's — is Analyst+.
|
|
*
|
|
* WHAT FREE GETS INSTEAD, and it is not a crippled demo:
|
|
* 1. the whole data aggregator (schedule, per-book lines, stats, streaks, hubs)
|
|
* 2. the AGGREGATE track record — tier hit-rates, CLV, calibration, accuracy
|
|
* over time — served by /api/accuracy + /api/ledger/accuracy, computed FROM
|
|
* settled data but never itemizing the nightly slate
|
|
* 3. a CAPPED, DAY-ROTATED sample of resolved calls for texture
|
|
* 4. the locked shell of tonight's reads: they exist, and their shape
|
|
*
|
|
* THE LINE: aggregate proof is free; the itemized judgment is the product.
|
|
* ========================================================================= */
|
|
|
|
/** Model JUDGMENT on ANY itemized grade. Data stays; the verdict and its argument go. */
|
|
const ITEMIZED_JUDGMENT_FIELDS = Object.freeze([
|
|
'grade', 'confidence', 'confidence_basis', // the verdict
|
|
'reasoning', 'kill_conditions_triggered', // its argument
|
|
'projection', 'edge_pct', // our number vs the line
|
|
'matchup_grade', 'form', // derived letter/label judgments
|
|
'alt_lines', 'kelly', // Desk tools
|
|
]);
|
|
|
|
/** Kept for the free SAMPLE + the aggregate: is a real outcome written on this row? */
|
|
function isResolved(g) {
|
|
if (!g || typeof g !== 'object') return false;
|
|
const o = g.outcome;
|
|
if (!o) return false;
|
|
if (typeof o === 'string') return o.trim() !== '';
|
|
return typeof o === 'object' && typeof o.result === 'string' && o.result.trim() !== '';
|
|
}
|
|
|
|
/** Does this tier get itemized grades at all? (free/anon: no) */
|
|
function entitledToItemizedGrades(tierName) {
|
|
return canAccess(tierName, 'reasoning_visible');
|
|
}
|
|
|
|
/** Tonight's tease — AGGREGATE ONLY, never joined back to a row. */
|
|
function liveLockedSummary(grades) {
|
|
const live = (Array.isArray(grades) ? grades : []).filter((g) => g && !isResolved(g));
|
|
const tiers = {};
|
|
for (const g of live) {
|
|
const letter = String((g.grade || '')).trim().toUpperCase().charAt(0);
|
|
if (!letter) continue;
|
|
tiers[letter] = (tiers[letter] || 0) + 1;
|
|
}
|
|
return { count: live.length, tiers };
|
|
}
|
|
|
|
const FREE_SAMPLE_CAP = 3;
|
|
|
|
/**
|
|
* freeSample(grades, dayKey) — a TASTE, not the archive.
|
|
*
|
|
* Up to FREE_SAMPLE_CAP RESOLVED calls, in FULL (reasoning + outcome), chosen by
|
|
* a day-derived offset so the set rotates daily and is stable within a day. The
|
|
* cap is what kills the exploit: 3 rotating past calls cannot reconstruct a
|
|
* nightly slate, whereas the full settled list is the feed one cycle late.
|
|
*/
|
|
function freeSample(grades, dayKey) {
|
|
const resolved = (Array.isArray(grades) ? grades : []).filter(isResolved);
|
|
if (resolved.length === 0) return [];
|
|
const key = String(dayKey || '');
|
|
let h = 0;
|
|
for (let i = 0; i < key.length; i += 1) h = (h * 31 + key.charCodeAt(i)) >>> 0;
|
|
const start = resolved.length ? h % resolved.length : 0;
|
|
const out = [];
|
|
for (let i = 0; i < Math.min(FREE_SAMPLE_CAP, resolved.length); i += 1) {
|
|
out.push(resolved[(start + i) % resolved.length]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* gateItemizedGrades(grades, tierName) — entitled tiers pass through untouched.
|
|
* Unentitled: EVERY grade (live or settled) loses its judgment and is stamped
|
|
* `locked`, keeping only the free-side data so the board still reads as real.
|
|
*/
|
|
function gateItemizedGrades(grades, tierName) {
|
|
if (!Array.isArray(grades)) return grades;
|
|
if (entitledToItemizedGrades(tierName)) return grades;
|
|
return grades.map((g) => {
|
|
if (!g || typeof g !== 'object') return g;
|
|
const shell = { ...g };
|
|
for (const f of ITEMIZED_JUDGMENT_FIELDS) delete shell[f];
|
|
shell.locked = true;
|
|
return shell;
|
|
});
|
|
}
|
|
|
|
module.exports.ITEMIZED_JUDGMENT_FIELDS = ITEMIZED_JUDGMENT_FIELDS;
|
|
module.exports.isResolved = isResolved;
|
|
module.exports.entitledToItemizedGrades = entitledToItemizedGrades;
|
|
module.exports.entitledToLiveGrades = entitledToItemizedGrades; // back-compat alias
|
|
module.exports.liveLockedSummary = liveLockedSummary;
|
|
module.exports.freeSample = freeSample;
|
|
module.exports.FREE_SAMPLE_CAP = FREE_SAMPLE_CAP;
|
|
module.exports.gateItemizedGrades = gateItemizedGrades;
|