From 3c466d79cb857f8341ac2207079389c8fb753edd Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 31 Jul 2026 23:37:23 -0400 Subject: [PATCH] Order Zero Phase 1b: redaction detection + reference-policy curve Two corrections to the first pass, both of which would have produced a false positive. 1) A non-empty body is NOT proof of access. PropLine's free tier returns the full STRUCTURE of tier-gated endpoints with values stripped plus an upgrade_url -- and the first pass classified /odds/closing and /movement as "works" on structure alone. detectRedaction() now counts actual prices and downgrades works -> partial when a body advertises an upgrade or carries outcomes with zero prices. Same class as the harness that returned a silent false, inverted. 2) One hard-coded reference set forces a yes/no on a question that is really a curve. reference_policy_curve reports strict eligibility (>=2 books, both sides, same line) under exchange_only / exchange_plus_sharp / exchange_plus_us / takeable_only, so the ruler decision is made on coverage-vs-quality rather than on a guess. DFS is absent from every policy by construction and a test asserts it. Also probes /markets/resolution-summary: /exports/resolved-props being 403 tells us we cannot PULL settlements; resolution-summary tells us whether they EXIST to be bought. Different questions. 19 unit tests, still hermetic. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc --- src/services/proplineVerify.js | 80 ++++++++++++++++++++++++++++++- tests/unit/proplineVerify.test.js | 54 ++++++++++++++++++++- 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/src/services/proplineVerify.js b/src/services/proplineVerify.js index e60e208..cd851fb 100644 --- a/src/services/proplineVerify.js +++ b/src/services/proplineVerify.js @@ -142,6 +142,21 @@ function analyseBreadth(raw, allowedBooks) { if (r >= 3) eligible3 += 1; } + // Same strict rule (two-sided, same line) under each candidate policy. + const policyCurve = {}; + for (const [name, set] of Object.entries(REFERENCE_POLICIES)) { + const best = new Map(); + for (const [lk, books] of byPropLine.entries()) { + let r = 0; + for (const b of set) if (books.has(b)) r += 1; + const pk = lk.slice(0, lk.lastIndexOf('|')); + best.set(pk, Math.max(best.get(pk) || 0, r)); + } + let n2 = 0; let n3 = 0; + for (const r of best.values()) { if (r >= 2) n2 += 1; if (r >= 3) n3 += 1; } + policyCurve[name] = { n2, n3 }; + } + const props = feedCounts.length; const hist = (xs) => { const h = {}; for (const n of xs) h[n] = (h[n] || 0) + 1; return h; }; const mean = (xs) => (xs.length ? round2(xs.reduce((a, b) => a + b, 0) / xs.length) : null); @@ -179,9 +194,62 @@ function analyseBreadth(raw, allowedBooks) { consensus_eligible_n2_pct: pctOf(eligible2), consensus_eligible_n3: eligible3, consensus_eligible_n3_pct: pctOf(eligible3), + reference_policy_curve: Object.fromEntries(Object.entries(policyCurve).map(([k, v]) => [k, { + books: REFERENCE_POLICIES[k], + n2: v.n2, n2_pct: pctOf(v.n2), n3: v.n3, n3_pct: pctOf(v.n3), + }])), }; } + +/** + * REDACTION DETECTION. PropLine's free tier returns the full STRUCTURE of + * tier-gated endpoints with the values stripped, plus an `upgrade_url`. A + * non-empty body is therefore NOT proof of access — the first pass classified + * /odds/closing as "works" on structure alone. This counts actual prices. + * + * Returns { outcomes, priced, redacted } — `redacted: true` when the body + * advertises an upgrade or carries outcomes with no prices at all. + */ +function detectRedaction(body) { + if (!body || typeof body !== 'object') return null; + let outcomes = 0; + let priced = 0; + const walkBooks = (books) => { + for (const bm of books || []) { + for (const mk of (bm && bm.markets) || []) { + for (const oc of (mk && mk.outcomes) || []) { + outcomes += 1; + if (oc && oc.price != null) priced += 1; + } + } + } + }; + walkBooks(body.bookmakers); + const advertised = body.upgrade_url != null || body.redacted === true + || (typeof body.redacted === 'string' && body.redacted.length > 0); + return { + outcomes, + priced, + steam_count: Array.isArray(body.steam) ? body.steam.length : null, + advertises_upgrade: !!advertised, + redacted: advertised || (outcomes > 0 && priced === 0), + }; +} + +/** + * Consensus coverage under several candidate REFERENCE-SET policies, so the + * ruler decision is made on a coverage/quality curve rather than on one + * hard-coded set. DFS is absent from every policy by construction. + */ +const REFERENCE_POLICIES = Object.freeze({ + exchange_only: ['novig', 'smarkets', 'kalshi', 'matchbook', 'polymarket'], + exchange_plus_sharp:['novig', 'smarkets', 'kalshi', 'matchbook', 'polymarket', 'pinnacle', 'bovada'], + exchange_plus_us: ['novig', 'smarkets', 'kalshi', 'matchbook', 'polymarket', 'pinnacle', 'bovada', + 'draftkings', 'betmgm', 'betrivers', 'fanduel'], + takeable_only: ['draftkings', 'fanduel', 'betmgm', 'betrivers'], +}); + /** One read-only probe. Never throws; classifies works / partial / no. */ async function probe(path, params, note, httpGet) { const get = httpGet || axios.get; @@ -216,8 +284,12 @@ async function probe(path, params, note, httpGet) { verdict = 'partial'; // reachable, nothing on file for this target } + const redaction = detectRedaction(body); + if (verdict === 'works' && redaction && redaction.redacted) verdict = 'partial'; + return { path, verdict, status, note, + ...(redaction ? { redaction } : {}), ...(rows != null ? { csv_rows: rows } : {}), ...(Object.keys(headers).length ? { headers } : {}), shape: scrubKeys(summarise(body)).slice(0, 700), @@ -285,8 +357,12 @@ async function verify(opts = {}) { } } - // 4 — sport coverage (rewrites rollout economics if it settles NBA/soccer). + // 4 — sport coverage + graded VOLUME per sport. `/exports/resolved-props` + // being 403 tells us we can't PULL settlements; resolution-summary tells us + // whether they EXIST to be bought. Two different questions. out.endpoints.push(await probe('/sports', {}, 'sport coverage — which sports PropLine carries', httpGet)); + out.endpoints.push(await probe('/markets/resolution-summary', { days: 30 }, + 'does PropLine actually GRADE nba/soccer? (the $19/mo question)', httpGet)); // 3 — the four documented-but-unverified endpoints, against a REAL event. for (const sport of sports) { @@ -311,4 +387,4 @@ async function verify(opts = {}) { return out; } -module.exports = { verify, __internals: { analyseBreadth, scrubKeys, probe, summarise, REFERENCE_CANDIDATES, DFS_PLATFORMS, BASE } }; +module.exports = { verify, __internals: { analyseBreadth, scrubKeys, probe, summarise, detectRedaction, REFERENCE_CANDIDATES, REFERENCE_POLICIES, DFS_PLATFORMS, BASE } }; diff --git a/tests/unit/proplineVerify.test.js b/tests/unit/proplineVerify.test.js index fc5e99f..84dd7c2 100644 --- a/tests/unit/proplineVerify.test.js +++ b/tests/unit/proplineVerify.test.js @@ -10,7 +10,7 @@ */ const { __internals } = require('../../src/services/proplineVerify'); -const { analyseBreadth, scrubKeys, probe, summarise, REFERENCE_CANDIDATES, DFS_PLATFORMS } = __internals; +const { analyseBreadth, scrubKeys, probe, summarise, detectRedaction, REFERENCE_CANDIDATES, REFERENCE_POLICIES, DFS_PLATFORMS } = __internals; const ALLOWED = new Set(['draftkings', 'fanduel', 'betmgm', 'betrivers', 'pinnacle']); @@ -144,3 +144,55 @@ describe('proplineVerify — breadth', () => { expect(summarise([{ a: 1 }])).toContain('array(1)'); }); }); + +describe('proplineVerify — redaction detection', () => { + beforeEach(() => { process.env.PROPLINE_API_KEY_1 = 'k'; }); + + const priced = { bookmakers: [{ key: 'dk', markets: [{ outcomes: [{ price: -110 }, { price: -110 }] }] }] }; + const stripped = { bookmakers: [{ key: 'dk', markets: [{ outcomes: [{ price: null }, {}] }] }], upgrade_url: 'https://x' }; + + it('flags a structure-only body with an upgrade_url as redacted', () => { + const r = detectRedaction(stripped); + expect(r.outcomes).toBe(2); + expect(r.priced).toBe(0); + expect(r.redacted).toBe(true); + }); + + it('does not flag a genuinely priced body', () => { + const r = detectRedaction(priced); + expect(r.priced).toBe(2); + expect(r.redacted).toBe(false); + }); + + it('DOWNGRADES a 200 redacted body from works to partial', async () => { + const httpGet = async () => ({ status: 200, data: stripped, headers: {} }); + const r = await probe('/x', {}, '', httpGet); + expect(r.verdict).toBe('partial'); // a non-empty body is NOT proof of access + expect(r.redaction.redacted).toBe(true); + }); + + it('keeps works when the body carries real prices', async () => { + const httpGet = async () => ({ status: 200, data: priced, headers: {} }); + expect((await probe('/x', {}, '', httpGet)).verdict).toBe('works'); + }); +}); + +describe('proplineVerify — reference policies', () => { + it('no policy admits a DFS platform', () => { + for (const books of Object.values(REFERENCE_POLICIES)) { + for (const dfs of DFS_PLATFORMS) expect(books).not.toContain(dfs); + } + }); + + it('the curve widens monotonically as the policy widens', () => { + const raw = [event('e1', { + novig: [{ player: 'A', point: 1.5, over: -104, under: -104 }], + draftkings: [{ player: 'A', point: 1.5, over: -110, under: -110 }], + betmgm: [{ player: 'A', point: 1.5, over: -112, under: -108 }], + })]; + const c = analyseBreadth(raw, ALLOWED).reference_policy_curve; + expect(c.exchange_only.n2).toBe(0); // one exchange only + expect(c.exchange_plus_us.n2).toBe(1); // exchange + two US books + expect(c.takeable_only.n2).toBe(1); + }); +});