Files
vyndr/src/services/parlayScanService.js
T
builtbykev d296e40cb6 Session 58: Phase 1 — Truth Infrastructure (2327 tests)
ledger_entries is live (migration 019 applied to prod, RLS + NULLS NOT
DISTINCT dedupe verified against the real database). Every grade now
persists, settles against the real result, and carries closing-line value.

- ledgerService: pipeline pre-grade upserts (public model record, user_id
  null, idempotent), closing capture on every snapshot (last write before
  game start = the close), settlement with SIGNED CLV (over = locked -
  closing; beat/faded/flat), 30d model aggregate with the hard n>=20 rule.
- Write paths: snapshotService -> ledger (priority path); Next /api/scan ->
  ledger for authenticated users only (anon never touches the public
  record). Refused reads write nothing and don't burn a scan.
- Honest refusal (work-order 1.5): no projection => insufficient_data,
  grade null, "INSUFFICIENT DATA - no read" UI. The web gradeAdapter no
  longer displays the line as the model projection (the audit's
  model==line / +0% edge degenerate); the card renders absent states.
  projectionFor is sport-aware (l5 -> l20 -> {stat}_per_90 -> xG).
- /ledger: MY READS | MODEL tabs; model header shows hit% + beat-close%
  only at n>=20, else RECORD BUILDING + live pending count. ModelRecord
  deferred-render strip on landing + player hero. CLV + outcome chips,
  revised_from_grade strikethrough (Phase 2.5 ready).
- SYNC (Task 5): thresholds vs SNAPSHOT_EXPECTED_INTERVAL (normal <1.5x,
  amber >=1.5x, STALE red >=3x) via /api/snapshot/summary.
- Phase 2.5 logged in specs/vyndr-roadmap.md (build after Phase 3).
- Data-semantics hardening: strict null-safe numeric parsing everywhere a
  market value is handled (Number(null)===0 would have fabricated lines).

Backend 2309 -> 2327 tests (201 suites), web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 21:34:26 -04:00

203 lines
6.7 KiB
JavaScript

// ARCH-1 Step 4 (Session 7f): /api/scan/parlay legs now grade via
// engine1 (computeFeatures → engine1 → gradeAdapter) instead of the
// legacy `analyzeProp`. Response shape is byte-compatible. Parallel
// resolution from PERF-2 is preserved (still inside Promise.allSettled).
const { analyzeViaEngine1 } = require('./intelligence/analyzeViaEngine1');
const { getOdds } = require('./oddsService');
const { detectCorrelations } = require('./correlationEngine');
const { gradeParlayFromLegs } = require('./parlayGrader');
const { generateUpgradePitch } = require('./upgradePitch');
const { getSupabaseServiceClient } = require('../utils/supabase');
async function scanParlay(user, legs) {
const supabase = getSupabaseServiceClient();
const isFree = user.tier === 'free';
// Scan count check (atomic for free tier)
if (isFree) {
if (user.scan_count >= 5) {
// Already exhausted — return 403 with pitch
const pitch = await generateUpgradePitch(supabase, user.id, null);
return {
blocked: true,
scan_count: user.scan_count,
scans_remaining: 0,
upgrade_pitch: pitch,
};
}
}
// PERF-2 (Session 7d): analyze legs in parallel. Each call is an
// independent upstream lookup, so a 6-leg parlay is ~6x faster here
// than the old sequential loop. allSettled preserves leg order and
// lets a single failed leg surface as an error stub instead of
// crashing the whole parlay.
const settled = await Promise.allSettled(legs.map((leg) => analyzeViaEngine1(leg)));
const legResults = settled.map((s, i) => {
if (s.status === 'fulfilled') {
// Session 58 (work-order 1.5) — a refused read carries grade null;
// the parlay flow needs a letter per leg, so it takes the existing
// failed-leg convention (F / 0 confidence) with an honest summary.
if (s.value && s.value.insufficient_data) {
return {
...s.value,
grade: 'F',
insufficient_data: true,
reasoning: { summary: 'INSUFFICIENT DATA — no read for this leg.' },
};
}
return s.value;
}
return {
...legs[i],
error: s.reason?.message || 'analysis_failed',
grade: 'F',
confidence: 0,
reasoning: { summary: 'Analysis failed for this leg.' },
};
});
// Fetch odds data for correlation detection (spreads, game context)
let spreads = [];
try {
const oddsData = await getOdds('nba');
spreads = oddsData.spreads || [];
// Attach game context to leg results for correlation detection
for (const leg of legResults) {
const matchingProps = (oddsData.props || []).filter(
(p) => p.player.toLowerCase().includes(leg.player.toLowerCase())
);
if (matchingProps.length > 0) {
const prop = matchingProps[0];
leg._gameTime = prop.game_time;
// Resolve team from season avg
const seasonStep = leg.reasoning?.steps?.season_avg;
const team = leg._resolvedTeam || null;
// Use the team from the analysis context
if (leg.reasoning?.steps?.situational?.home_away?.context === 'home') {
leg._team = prop.home_team;
} else if (leg.reasoning?.steps?.situational?.home_away?.context === 'away') {
leg._team = prop.away_team;
}
}
}
} catch (_) {
// Correlation detection is best-effort
}
// Detect correlations
const correlationFlags = detectCorrelations(legResults, spreads);
// Grade the parlay
// Attach composite scores from individual analyses for parlay grading
for (const leg of legResults) {
// Reconstruct composite from the reasoning steps
const steps = leg.reasoning?.steps;
if (steps) {
const seasonDelta = steps.season_avg?.vs_line || 0;
const recentDelta = steps.recent_form?.vs_line || 0;
leg._composite = (Math.abs(seasonDelta) + Math.abs(recentDelta)) / 2;
} else {
leg._composite = 0;
}
}
const { grade: parlayGrade, confidence: parlayConfidence } = gradeParlayFromLegs(
legResults,
correlationFlags
);
// PERF-2 (Session 7d): one batched insert for every leg's pick row
// instead of N sequential inserts. Supabase preserves insert order in
// the returned data array so pickIds line up with legResults.
const pickRows = legResults.map((leg) => ({
user_id: user.id,
player: leg.player,
stat_type: leg.stat_type,
line: leg.line,
book: leg.book || 'unknown',
direction: leg.direction,
grade: leg.grade,
edge_pct: leg.edge_pct,
reasoning: leg.reasoning?.summary || '',
kill_conditions: (leg.kill_conditions_triggered || []).map((k) => k.code),
confidence: leg.confidence,
}));
let pickIds = [];
if (pickRows.length > 0) {
const { data: picksData, error: picksErr } = await supabase
.from('picks')
.insert(pickRows)
.select('id');
if (picksErr) console.warn('[VYNDR] picks batch insert failed:', picksErr.message);
pickIds = (picksData || []).map((p) => p.id);
}
// Write scan session
const { data: session } = await supabase
.from('scan_sessions')
.insert({
user_id: user.id,
legs: pickIds,
final_grade: parlayGrade,
kill_conditions: correlationFlags
.filter((f) => f.impact !== 'positive')
.map((f) => f.type),
correlation_notes: JSON.stringify(correlationFlags),
})
.select('id')
.single();
// Atomic scan count increment for free tier
let newScanCount = user.scan_count;
if (isFree) {
const { data: updated } = await supabase
.from('users')
.update({ scan_count: user.scan_count + 1 })
.eq('id', user.id)
.eq('scan_count', user.scan_count)
.select('scan_count')
.single();
newScanCount = updated?.scan_count ?? user.scan_count + 1;
}
// Build response legs (stripped of internal fields)
const responseLegs = legResults.map((leg, i) => ({
index: i,
player: leg.player,
stat_type: leg.stat_type,
line: leg.line,
direction: leg.direction,
grade: leg.grade,
confidence: leg.confidence,
edge_pct: leg.edge_pct,
kill_conditions: leg.kill_conditions_triggered || [],
reasoning_summary: leg.reasoning?.summary || '',
}));
// Generate upgrade pitch at scan 5
let upgradePitch = null;
if (isFree && newScanCount >= 5) {
upgradePitch = await generateUpgradePitch(supabase, user.id, {
grade: parlayGrade,
legs: responseLegs,
});
}
return {
blocked: false,
scan_id: session?.id || null,
parlay_grade: parlayGrade,
parlay_confidence: parlayConfidence,
correlation_flags: correlationFlags,
legs: responseLegs,
scan_count: newScanCount,
scans_remaining: isFree ? Math.max(0, 5 - newScanCount) : null,
upgrade_pitch: upgradePitch,
};
}
module.exports = { scanParlay };