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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
@@ -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 } };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user