test-cli-instructions — edge-uplift round (data access required)

2026-07-25 — CURRENT ROUND: promote line_move_v1 to LIVE paper trading (see review-2026-07-25.md)

Verdict: line-move-v1 PASSED the decisive test — market_form_only 59.0%
(below the 63% floor), lineup channel adds 7.74pp with paired z=4.18,
mean-reversion-only rejected (open_line_only 53.5%). CLV leg positive:
+1.47 line points, 95% CI [1.08,1.87]. This is the program's strongest
result — Law 3 confirmed for the line market. It has earned LIVE evidence.
Backtested CLV is the entry ticket, NOT promotion — no real stakes.

Step 1 — make line_move_v1 a weekly RECOMMENDATION (produce → surface → seed)

The model must become a visible, per-match recommendation each round, not
just a backtest. Three parts, all required:

1a. Produce — a weekly predictor (mirror ml/predict_weekly_margin.py):
load the current round's OPEN line + the Tuesday-clock features
(LINE_MOVE_FEATURES), run the walk-forward-trained line_move_v1, and for
each match emit: predicted move direction, model confidence (calibrated
prob), the recommended side + the open handicap to take. Write to
gold_nrl_predictions with model_id='line_move_v1', market_type='line',
recommended_side, taken_line = the open handicap. Refresh AFTER the
Tuesday team lists land (that's the feature clock) and after weather regen.

1b. Surface — the recommendation shows on the value board as a LINE
pick with the model chip (line_move_v1), the recommended side, the open
handicap, and the model's directional confidence. It must read as
"back line at the open" — the actionable instruction.
Keep the PAPER-trade framing (no real-stake language); guardrail tests
(canonical app + model attribution) stay green.

1c. Seed — register alongside open_fav_v1 / rapm_timing_v1 in the
paper-trade setup. At the real opening line, place the recommended
direction; snapshot placement handicap + model prediction at seed time
(immutable, like paper_trades_v2). Settle line CLV at close ≤24h
post-round. Log to reports/live_evidence.md.

Recommendation rule (pre-registered): recommend a line side only when
the model's directional confidence ≥ 0.60 (the backtest's operating
point). Below that, WATCH not GO — no seed. This keeps the live sample to
the picks the model is actually confident in, matching the 66.8% regime.

Step 2 — weekly live CLV logging + the checkpoint rule

Per docs/edge-review-2026-07-20.md live checkpoints: 20 settled = CLV
direction read (info only); 40 settled = PARK if mean AND median CLV < 0;
100 settled = promotion review vs the canonical gates. Do NOT skip to real
money on the backtest — the two fake edges we caught were both backtests.

Step 3 — keep unblocking the line-tick archive (execution timing)

The archive is still h2h-heavy; local forward staging recovered only 34
line events. Grow line + total tick capture (forward archive + R11+
re-fetch when API budget allows) so the repricing curve can tell us WHICH
point in the Tuesday window to place at. This decides execution, not
whether to bet — so it runs parallel to Steps 1–2, not before them.

Report back

live_evidence.md rows per round (seeded, settled, running mean/median
CLV per strategy); no new model phases until line_move_v1 live CLV reads
at 20/40 settled. Deviations in ## Deviations.

2026-07-24 — superseded 2026-07-25 (decisive ablation executed; thesis survived; see review above)

Verdict on the overnight run: line-move-v1 passed every frozen
directional gate (66.8%, z=9) — but the ablations show the two lineup-news
features add only ~1pp. open_home_line is in every variant, so the ~65%
floor is most likely opening-line MEAN-REVERSION + range-bounding, not
lineup news. The lineup thesis for the line market is UNCONFIRMED. Do not
seed or build a strategy off this yet.

Step 1 — the decisive ablation (one cheap local run, no API/data)

Add two variants to LINE_MOVE_VARIANTS in data/line_move.py and re-run
report_strategy_shift.py --phase line-move-v1b:
1. market_form_only — drop ALL 12 lineup features, keep only
open_home_line, elo_rating_diff, rolling form, rest, playoff,
neutral. This is the baseline the current ablations never established.
2. open_line_onlyopen_home_line alone (the range-bounding /
mean-reversion control).
Same frozen LM1–LM4 gates. Report all five variants side by side.
Interpretation, pre-registered: if market_form_only ≥ ~63%, the
lineup channel adds nothing to line-move — the signal is mean-reversion,
and line-move-v1 is re-classified as a market-structure effect (likely
Law 1 restated, not a new edge). Only if the full model beats
market_form_only by a margin with z ≥ 2 does the lineup thesis survive.

Step 2 — CLV leg (ONLY if Step 1 keeps the lineup lift)

Directional accuracy is not bankability. Bet the predicted move direction
at the OPEN line, settle at the CLOSE, report mean CLV + season-bootstrap
CI. No seeding until mean CLV > 0 out of sample.

Step 3 — unblock Track B: stage line + total ticks

The repricing curve is unmeasurable because the historical backfill and
the forward archive stage h2h only (28 events, 0 line/total ticks). Fix
the staging so stg_odds_api_history / stg_match_price_ticks carry line
and total, and re-run the backfill for R11+ to capture them. This is the
binding constraint on the entire timing thesis.

Report back

results-line-move-v1b.{json,md} with all five variants; if Step 1 fails
the lineup channel, say so plainly and STOP (do not proceed to Step 2).
Deviations in ## Deviations.

2026-07-21 (evening) — superseded 2026-07-24 (line-move-v1 executed; verdict above)

The goal, one sentence: predict WHERE THE LINE IS GOING from lineup
news, not where the margin lands — the first model target aligned with
all three laws (open soft in direction; magnitude unbeatable; lineup
info priced late). Pre-registered as Goal 2 in docs/line-v2-roadmap.md;
owner activated it tonight for an overnight autonomous run.

Overnight autonomy rules (hard): ZERO API calls — every input is
already staged/local (historical file, team lists, RAPM, R11+ tick
history). Do not touch collectors, secrets, CronJobs, or the UI. Gates
below are frozen — a failed gate is a committed result, not a reason to
adjust. Reporter artifacts only; deviations in ## Deviations; STOP
when both tracks are committed.

Track A — line_move_v1 backtest (statistical power)

Target: sign(home_line_close − home_line_open) from the historical
file (zero-moves excluded; pushes reported). Walk-forward by season.

Feature clock (precise leakage rule): allowed = anything knowable
Tuesday ~4:05pm AEST of the match week — the target round's TEAM LISTS
(they ARE the news; using them is the point, not leakage),
rapm_lineup_delta built from rounds < target,
delta_availability_loss (BUILD IT if absent — spec in
line-v2-roadmap: RAPM-weighted sum of players named in r−1 with minutes
but missing from r's list, home − away), spine/starter deltas, r−1 form.
Forbidden = ANY price after the open (no close, no refresh, nothing).
Commit the leakage pre-test BEFORE the first backtest run.

Gates (frozen; one scrutiny upgrade pre-registered now): directional
accuracy ≥ 53% AND ≥ (majority-class base rate + 2pp) — if line moves
skew toward favourites, beating a coin is not beating the base rate;
z ≥ 2 against the base rate; n ≥ 500 non-zero moves; 2023+ slice broken
out and reported. Report per-feature ablations (full vs minus-availability
vs minus-RAPM) so the review can see WHICH news moves lines.
Run via report_strategy_shift.py --phase line-move-v1.

Track B — the REAL target measured (report-only, small n)

From stg_odds_api_history (R11+ price-change events, exact move
timestamps): (1) rebuild artifact A1 properly — the Tuesday repricing
curve with REAL event timing (share of each game's open→close line move
completed by T+30m/+1h/+2h/+4h/+12h after the Tuesday drop), per
market; (2) for the ~80 games available, open→Tuesday-evening line move
vs the Track-A features — REPORT ONLY (n far below gates), as the
calibration prior for where line_move_tuesday_v1 gates get set when
the archive matures. If a round's ticks aren't staged yet, stage from
Spaces first (local work, no API).

Morning deliverables (what the review reads at breakfast)

results-line-move-v1.{json,md} with gates + ablations;
reports/repricing_curve_2026.md (A1 v2) with the curve table; ledger/
staging commits; Deviations. The weekend strategic review runs on these.

2026-07-21 — CURRENT ROUND (supersedes below): business-plan data scrape, window-day close-out first

Context: owner purchased a Business API — and it is theoddsapi.com,
a DIFFERENT vendor from the-odds-api.com our live collectors use
(header
auth, no /v4, premium /edges/ /intelligence/ /props/ endpoints, 6,667/day,
50+ books incl Pinnacle). Client shipped: data/theoddsapi_business.py.
Two coverage facts: NRL historical starts ~May 2026 (one-time 2026 grab,
not multi-season); the durable prize is the FORWARD proprietary archive
(docs/match-observation-archive.md). Owner also wants multi-sport
capture: NRL + AFL + UFC + rugby union + WNRL. New env key:
ODDS_API_BUSINESS (distinct from ODDS_API_KEY; no fallback). Every
paid step is probe-first and budget-capped. Owner sequencing rule: data
→ evidence → THEN the strategic review. Do not reorder.
Standing verdicts unchanged
(open_fav_v1 KEEP/live; underdog REJECTED; magnitude models rejected
by Law 2 — see docs/edge-review-2026-07-20.md; Goal 2 deferred to the
weekend review, which now runs on this new data).

Step 0 — window-day close-out (IF NOT ALREADY DONE, do this first)

  1. Secret re-apply for scrapers-runtime-secrets (stale
    NRL_BUCKET_SECRET_KEY + ODDS_API_KEY; fix verified ready in
    go-cicd-ops docs/cli-brief.md round result). Add
    ODDS_API_BUSINESS to the local secrets env file BEFORE running the
    re-apply — one apply carries both fixes.
  2. After 18:00 UTC, verify the first Tuesday window:

bash kubectl -n doks-ci-cd get jobs -l app.kubernetes.io/component=teamlist-window # expect 6 python scripts/reconcile_k8s_raw_runs.py --stage-pending python scripts/audit_snapshot_coverage.py --season 2026 --round 21

Commit the audit output to reports/live_evidence.md. Gate: /ops
coverage panel agrees with the CLI output.

Step 1 — key placement + PROBE the new API (resolves everything below)

ODDS_API_BUSINESS into the VM .env (rides the Step-0 secret re-apply
for the cluster; no CronJob uses it). Then run the probe — it is the
measure-don't-assume gate for the whole round:

bash export ODDS_API_BUSINESS=<key> python scripts/probe_theoddsapi_business.py \ --historical-date 2026-06-10T06:00:00Z

Inspect reports/theoddsapi_probe/*.json and COMMIT them, then confirm
and record in a deviation note:
1. /me works (header auth x-api-key, not the old ?apiKey=).
2. sports.json → the REAL keys for NRL/AFL/UFC/rugby-union/WNRL
(update SPORT_KEYS in theoddsapi_business.py with confirmed keys;
WNRL may not exist — record if so).
3. odds.json → does the payload actually carry Pinnacle + Betfair +
the book count we expect.
4. rate headers → per-call cost and daily remaining (6,667/day budget).
5. edges/intelligence shapes → note for collect-and-validate wiring.
6. historical with the 2026 date → confirms coverage + response shape.

Decision to surface back to chat after the probe: migrate the live
NRL collectors to theoddsapi.com, or keep the-odds-api.com for live and
use the new vendor for premium + historical only. Do NOT migrate the
live pipeline unprompted — flag the recommendation with the probe
evidence.

Step 2 — point the backfill at the new vendor, then dry-run + probe

scripts/backfill_odds_api_history.py + data/odds_api_history.py were
written against the OLD API (the-odds-api.com/v4). Before any paid
historical run, repoint them at data/theoddsapi_business.historical_odds
(header auth, /historical/odds/, sport_key=) — the probe's
historical.json confirms the exact params and response shape. Keep the
budget walker (measured cost, ledger, hard stops) intact; only the fetch
function changes. Then:

bash python scripts/backfill_odds_api_history.py --season 2026 --round 12 --dry-run python scripts/backfill_odds_api_history.py --season 2026 --round 12 \ --credit-budget <cap> --min-remaining <floor>

Record measured cost/call and the earliest date with data; commit an
affordability table (deviation note). Gate: no bulk grab until the
table is committed and projected spend fits the daily budget.

Step 3 — ONE-TIME grab: 2026 season-to-date only (~R10 → R21)

There is no multi-season phase — only 2026 from the coverage start:

bash for R in $(seq 21 -1 10); do python scripts/backfill_odds_api_history.py --season 2026 --round $R \ --credit-budget 2000 --min-remaining <floor> || break done

Stop at the first round that returns empty (coverage boundary). Commit
reports/odds_api_history_ledger.json after the run — NEVER delete it.

Step 3b — stand up the FORWARD archive (the real prize)

Per docs/match-observation-archive.md — this is where the durable edge
is, since history doesn't go back. In priority order:
1. Add Pinnacle to the decision snapshots: regions=au,eu, bookmaker
key pinnacle. Confirm it returns for NRL before relying on it; keep
two-region calls to open + Tuesday window + final-hour only.
2. Final-hour capture every 5–10 min per kickoff (cron off the staged
draw, or a k8s CronJob) — the close curve, where CLV is won.
3. Wire market_reference.py (shipped this round: de-vig, consensus,
sharp anchor, soft-vs-sharp divergence) into staging so every snapshot
carries its derived reference columns.
4. stg_match_price_ticks + gold_match_observation dimension joining
team lists, weather, availability, final score, derived columns.
Store BOTH vendor /intelligence/ fair-odds/consensus AND our
market_reference.py values — disagreement is a data-quality signal.

Step 3c — multi-sport capture (NRL priority; others archive-only)

Probe CONFIRMED sports on the plan: NRL, AFL, UFC (rugby union + WNRL
are NOT on the 26-sport plan — do not chase them). Extend archive
collection to AFL + UFC. CAPTURE ONLY — no modelling splits off NRL (Law
2 applies to every sport; capture is cheap, models are the scarce
resource). NRL keeps the full open/Tuesday-window/final-hour cadence; AFL
and UFC get open + pre-kickoff snapshots so the proprietary history
starts accruing now.

Step 3d — convert LIVE collectors to theoddsapi (primary); keep legacy

Historical is already on the new vendor. Now migrate the LIVE path:
1. data/odds_provider.py (shipped): ODDS_PROVIDER=theoddsapi is
primary; the-odds-api.com stays the fallback. Route
collect_nrl_odds_snapshot.py through the active provider's client
instead of hard-importing odds_api.py.
2. Decimal everywhere: every theoddsapi request already forces
oddsFormat=decimal; wire odds_format.coerce_decimal (shipped) into
the staging normalizer so /edges//intelligence//props prices — which
may ignore the param — are still stored decimal. Add a staging test
with an American-valued fixture row proving it lands decimal.
3. Do NOT decommission the-odds-api.com CronJobs — leave the Basic key
live as the documented switch-back (season-dependent).

Step 3e — collect the INTELLIGENCE (the "any intelligence we can get")

Per snapshot on decision points (open, Tuesday window, final hour),
also pull /intelligence/fair-odds/, /intelligence/consensus/, and
/edges/ for NRL; land raw to Spaces and stage to
stg_odds_intelligence. Store the vendor's fair-odds/consensus ALONGSIDE
our market_reference.py values — disagreement is a data-quality signal.
Vendor edge scores are validation targets, never signal — run them
through CLA/CLV like any candidate before they touch a recommendation.

Step 3f — dashboard rebuild (scoped in docs/dashboard-rebuild-v2.md)

Build order there: finish the /ops new-feed monitor rows (shipped this
round — add per-sport coverage + rate-limit remaining), then the
Bet-Intelligence panel (our fair prob vs Pinnacle vs consensus vs best
line + divergence, vendor edge marked unvalidated), then the
repricing-curve sparkline, then the multi-sport switcher. Each slice is
its own round behind tests; the test_agent_guardrails.py suite
(canonical app + model attribution) must stay green.

Step 4 — staging: stg_odds_api_history

New script mirroring stage_odds_ticks.py: flatten payloads
(event → bookmaker → market → outcome, keyed by the API's actual
snapshot_ts) into stg_odds_api_history. One row per
(snapshot_ts, event, bookmaker, market, side); dbt schema tests for
non-null keys and dedup on the actual timestamp.

Step 5 — the three evidence artifacts the plan was bought for

Committed artifacts, in order:

  1. Tuesday repricing curve — avg |line move| and |h2h implied-prob
    move| by time-since-drop (pre-drop, +30m, +1h, +2h, +4h, +12h, +1d,
    close), per market. Sources: the 2026 historical grab (Step 3) PLUS
    live tlwindow captures as they accrue — pooled, since both are 2026.
    THE number the timing thesis lives or dies on.
  2. True intraweek CLA for open_fav_v1 — confirmation measured at
    the Tuesday-evening price, not the close.
  3. line_move_v1 real backtest — target = open → Tuesday-evening
    line move, placement-time lineup features, gates exactly as
    pre-registered in docs/line-v2-roadmap.md Goal 2 (dir. accuracy
    ≥53%, z ≥ 2, n ≥ 500, 2023+ slice broken out). Run via the
    strategy-shift reporter.

Report back

Push after each step, not in one batch: Step 0 audit output +
live_evidence rows; Step 2 affordability table (deviation note);
Step 3 ledger commits per season; Step 4 staging + tests; Step 5
artifacts in reports/. Deviations in ## Deviations sections. When
Step 5 is pushed, STOP — the reviewer session runs the strategic
review (ingest re-pave, model roadmap, Betfair-row closure) before any
further model work.

2026-07-20 — superseded 2026-07-21 (window-day items absorbed into Step 0 above)

The clock: the four scrapers-tlwindow-* CronJobs fire for the
first time Tuesday 06:05–18:00 UTC (tomorrow). They can only
succeed if the post-revert image is live — the bootstrap image lacks
the teamlist_window label and the jobs will crash on the argument
parser. Everything below is ordered by that deadline.

Deferred by owner decision: Goal 2 (line_move_v1) and the
line-v2 review are REVISITED THIS WEEKEND after the round settles. Do
not start new model phases this week. The three goal-session negatives
stand as recorded (see line-v2-roadmap "Goal 2" section for context).

1 — TONIGHT: go-cicd-ops registry revert, end to end

Execute go-cicd-ops:docs/cli-brief.md Steps 1–3 exactly (retag →
flip the two manifests + scrapers trigger-template → prove the
pipeline with a push to scrapers main). Then copy the NEW digest that
update-gitops-image writes into scrapers-cronjob.yaml into the
four image lines of scrapers-teamlist-window-cronjob.yaml and apply.
Gate before bed: kubectl -n doks-ci-cd get cronjobs shows the four
tlwindow jobs with the new digest and no ImagePullBackOff on a manual
kubectl create job --from=cronjob/scrapers-tlwindow-t0 tlwindow-dryrun.
Confirm ODDS_ROUND=21 (or the round-control object) so the jobs
collect the right round.

2 — TONIGHT (parallel-safe): DuckDB snapshot to the hosted board

Publish sync/duckdb/nrl_bucket-<yyyymmdd>.duckdb + sha256 manifest
from the VM, add DUCKDB_PATH + NRL_DUCKDB_SNAPSHOT_KEY to
scrapers-ui-service.yaml, re-smoke the six routes. Gate:
vb-match-card renders R21; /ops shows real GIT_COMMIT and the new
"Odds collect — teamlist_window" contract row (merged in e911999).

3 — TUESDAY: verify the first window fires

After 18:00 UTC: kubectl -n doks-ci-cd get jobs -l app.kubernetes.io/component=teamlist-window — four completions; four
_run.json manifests in Spaces; reconcile_k8s_raw_runs.py --stage-pending stages them; then
python scripts/audit_snapshot_coverage.py --season 2026 --round 21
and commit the output to reports/live_evidence.md. The /ops coverage
audit panel should agree with the CLI output — if they disagree,
that's a bug report.

4 — THROUGH THE WEEK: R21 live quota (unchanged)

Thursday weather → regen → seed IN THAT ORDER; per-kickoff closes;
CLV ≤24h; observation-CLV for all picks; SGM dog+under quotes
(~8, manual CSV is fine).

5 — SUNDAY: production-readiness verdict

The seven checks (07-16 round). Record "production-ready: YES/NO" +
blockers in reports/live_evidence.md. This and the R21 audit output
are what the weekend goal review will be based on — make them true.

Report back

Push after each numbered item completes, not in one batch — the
reviewer session will be watching the branch through the week.

2026-07-18 — superseded 2026-07-20 (weekly items absorbed into the urgent round above)

Context: R21 week. Verdicts standing: open_fav_v1 KEEP (live CLV
adjudicates); underdog overlay REJECTED (re-run done 655aee5 — await
review); LightGBM diagnostic-only; ridge KEEP; dog×under multi:
correlation CONFIRMED, edge NOT (results-dog-under-multi.md).
Priority order below is execution order.

1 — R21 operating sequence (Tuesday–Sunday, the live quota)

Tuesday: team lists ≤30min after drop → verify the four
teamlist_window snapshots fired (needs go-cicd-ops rounds done — see
3). Thursday: weather refresh → prediction regen → THEN seed paper
trades (sequencing rule; timestamps into live_evidence.md). Per
kickoff: close snapshots. Sunday+24h: CLV settled, observation-CLV
logged for ALL model picks, not just qualifying ones.

2 — collector coverage audit (new tool, run for R20 then R21)

python scripts/audit_snapshot_coverage.py --season 2026 --round 20
(then --round 21 post-Tuesday). Checks open-first, close in
[−120m, kickoff], gaps ≤8h, h2h+line+total at close, teamlist_window
clusters, vm/cluster mix. Exit 1 = FAIL = live-quota incident. Commit
both outputs to reports/live_evidence.md.

3 — go-cicd-ops rounds (fixes the hosted board; blocks 1's Tuesday leg)

Execute go-cicd-ops:docs/cli-brief.md top to bottom: registry revert
(retag → manifest flips → trigger-template → pipeline proof) → DuckDB
snapshot env wiring (DUCKDB_PATH + NRL_DUCKDB_SNAPSHOT_KEY; VM
publishes the checksummed snapshot post-staging) → tlwindow CronJobs
image digest → six-route re-smoke. The GIT_COMMIT build-arg and
canonical web.main app are already on main here.

4 — line-v2 P0 → P1 (the model work; start once 1–3 are moving)

P0 checklist from docs/line-v2-roadmap.md (coverage query,
position-resolver pytest, leakage pre-test), then the four
pre-registered P1 features in dbt: spine+lock cohesion, lock playmaker
index, games-together, delta_offball_effort, plus
delta_availability_loss (implicit injury). One reporter phase
(line-v2a), gates G-L1/G-L2 vs line_ridge_core 13.46 — no
per-feature cherry-picking.

5 — observations and audits (small, alongside)

  • SGM quotes: one book's dog+under same-game-multi quote per R21 game
    vs the naive leg product (CSV: game, book, legs, quote, timestamp).
  • LightGBM data audit (carried): DuckDB training rows vs Excel extract
    — regenerate-vs-retire decision input. No retrain.
  • origin_week_flag + rain_flag backfill status (was wiring-bugged).

6 — PRODUCTION-READINESS VERDICT (end of R21 — the round's deliverable)

The seven checks from the 07-16 round (hosted cards render, GIT_COMMIT
stamped, four Tuesday snapshots staged, sequencing order proven, CLV
≤24h, observation-CLV live, contracts green) — record
"production-ready: YES/NO" with blockers in
reports/live_evidence.md. A false YES is worse than a NO.

Report back

Audit outputs + R21 evidence rows in live_evidence.md; line-v2a results
via the reporter when P1 lands; SGM CSV committed; deviations in the
results MD ## Deviations section.

2026-07-17 — superseded 2026-07-18 (Sol verdict recorded; steps carried above)

Sol review verdict (commits 9a06467, d665cb8) — mostly KEEP, two
corrections applied by the reviewer on this branch:

  • KEEP: promotion_evaluator.py (fail-closed, canonical gates, stricter
    extras), fetch_duckdb_snapshot.py (checksummed immutable snapshot —
    this SUPERSEDES the plain-download plan; the pod needs
    NRL_DUCKDB_SNAPSHOT_KEY + manifest, see go-cicd-ops brief), round
    control object (NRL_ROUND_CONTROL_KEY — kills the weekly ODDS_ROUND
    configmap bump), reconcile/staging hardening + contract tests.
  • CORRECTED: start_web.sh served web.backend.app (React) — the
    canonical app is web.main until the parity checklist passes; now
    env-switchable via WEB_APP, defaulting to canonical. The React
    frontend build stage stays in the image (unused, harmless).
  • CORRECTED: the "model size" commit styled a model chip
    (.vb-model-chip/.vb-model-id) but deleted the element — cards
    showed no model probability or ID. Chip restored in the card header
    using Sol's CSS.

Step A — publish the versioned DuckDB snapshot (VM, closes the hosted board)

Sol's fetcher expects s3://<bucket>/<key> + <key>.json manifest with
a sha256. Add the post-staging publish step to the weekly flow: upload
duckdb/nrl_bucket.duckdb to sync/duckdb/nrl_bucket-<yyyymmdd>.duckdb
+ manifest, then set NRL_DUCKDB_SNAPSHOT_KEY in the scrapers-ui env
(go-cicd-ops). Gate: hosted /ops DuckDB row green via start_web.sh path.

Step B — line-v2 Feature 4: delta_offball_effort (owner-directed)

Spec pre-registered in docs/line-v2-roadmap.md P1.4: defensive workrate
+ tackle efficiency + kick-return metres, minutes-weighted, trailing
4-round window, z-scored delta. Build in dbt alongside Features 1–3;
same v2a reporter phase, same gates. Do NOT extend the stats spider yet
(candidate feed logged in data-ingest-strategy).

Step C — implicit availability delta (injury answer, owner-directed)

We collect NO injury data (audited 2026-07-17). Build route 1 from the
roadmap: delta_availability_loss = RAPM-weighted sum of players named
in r−1 with minutes but absent from r's team list, home − away. Data
already staged; placement-time safe. Casualty-ward scrape stays a
candidate feed — do not build it this round.

Step D — run the collector coverage audit (owner-directed 2026-07-17)

New tool: python scripts/audit_snapshot_coverage.py --season 2026 --round 20 (and 21 after Tuesday). Per match it checks: open exists
and precedes everything, close lands in [−120m, kickoff], no capture
gap > 8h, h2h+line+total present at close; round-level it counts the
four teamlist_window Tuesday clusters and reports the collector
(vm/cluster) mix. Exit 1 on FAIL. Commit the R20 output to
reports/live_evidence.md — this is the "did the cloud collector get
the right prices at the right points" answer, repeatable every round.
If R20 FAILs on close coverage, that's a live-quota incident to log.

Step E — dog×under SGM quote observation (report-only follow-up)

The correlated-multi diagnostic (results-dog-under-multi.md) confirmed
the correlation (lift 1.084, CI low 1.010) but NOT the edge at naive
multiplied prices (−2.3% pooled). The open question is real SGM
pricing: for each R21 game, record one bookmaker's actual same-game
multi quote for [dog H2H × under] alongside the naive product of the
leg prices. Manual capture into a small CSV is fine (game, book, leg
prices, SGM quote, timestamp). ~8 quotes/week; after 3 rounds we know
whether books discount the combo more or less than the measured lift.
No betting logic, no strategy — observation only.

2026-07-16 — steps continue (production-readiness R21 checklist stands)

Verdicts just issued: open_fav_v1 KEEP (CLA leg strong, ROI leg CIs
span zero — live CLV adjudicates from here); underdog overlay REJECTED
in 4 of 6 segments; ridge retrain KEEP (MAE 12.57); LightGBM NOT
reverted — demoted to diagnostic-only, below market baseline. R20
seeded trades stand (entry snapshot is immutable in paper_trades_v2).

New standing rule (sequencing): Thursday weather refresh +
prediction regeneration run BEFORE paper-trade seeding, never after.
R20 seeded 30 min before the regen and the fresher model would have
disqualified the favourite pick — protocol was followed, but the order
was wrong. Encode this in the weekly-round/run-scrapers skill flow.

Step 1 — populate the two unevaluable underdog segments (one re-run)

origin_week_flag is 0 for all 3,492 prediction rows (never populated —
fix the wiring: rounds 11–17 windows per season) and rain_flag is
absent from the predictions frame (weather coverage is now 99%+ — join
it in). Then re-run the segmentation ONCE (--phase open-fav-v1-roi2
or equivalent) reporting the SAME six segments with the SAME bar
(CLA ≥ 0.55, z ≥ 3, n ≥ 100). Whatever the result, the underdog
question closes after this pass.

Step 2 — LightGBM decision input (no retrain yet)

Report only: how much training data does the DuckDB path yield vs the
stale Excel extract (rows, seasons, weather-null rate)? With that in
hand the review decides regenerate-vs-retire. Commit the numbers to the
results MD. Model binaries only land with a phase-labelled commit from
now on — no more "weather fixed".

Step 3 — R20 close-out (live evidence quota)

Per-kickoff close snapshots through Sunday, CLV settled ≤24h post-round
for the 2 seeded open_fav_v1 trades, rapm_timing_v1 seeding still
pending gates — record status honestly in reports/live_evidence.md.

Step 4 — go-cicd-ops rounds (registry revert + team-list window jobs)

go-cicd-ops:docs/cli-brief.md now has TWO open items: the registry
revert (fixes the hosted board) and the NEW team-list market-window
CronJobs (Tue 06:05/06:35/08:00/18:00 UTC, label teamlist_window) —
the measurement instrument for docs/line-v2-roadmap.md P3. Sequencing
note there: the window jobs need the image built AFTER the
VALID_LABELS change in this repo, so registry revert first.

Step 5 — line-v2 P0 prerequisites (this week, parallel-safe)

docs/line-v2-roadmap.md is now the authority for line model work.
P0 checklist items that can start now: team-list coverage query,
position-resolver pytest (1/6/7/9/13 for ≥95% of squads across the 10
most common team-list formats), leakage pre-test for the delta columns.
No feature building until P0 is green.

Step 6 — PRODUCTION-READINESS CONFIRMATION (next week, R21 — the goal)

Owner decision point: next week we confirm production is ready. The
definition — one full round runs hands-off with every instrument green:

# Check Evidence
1 Hosted board renders R21 cards six-route smoke, vb-match-card present
2 /ops shows real GIT_COMMIT + green DuckDB row registry revert + DB sync done
3 All four teamlist_window snapshots captured Tuesday + staged reconciliation table, zero pending
4 Weather refresh → prediction regen → seeding, IN THAT ORDER timestamps in live_evidence.md
5 R20+R21 CLV settled ≤24h post-round live_evidence.md rows
6 Observation-CLV logged for ALL model picks (not just qualifying) new rows/table referenced in live_evidence.md
7 All /ops data contracts green screenshot or smoke note

Record the verdict in reports/live_evidence.md as
"production-ready: YES/NO" with blockers listed. NO is an acceptable
answer — a false YES is not.

Report back

Segmentation re-run results + LightGBM data audit in
reports/strategy_shift/; R20 CLV rows in live_evidence.md; smoke
results in ui-deployment-gaps.md. Deviations in the MD ## Deviations
section — use the section this time.

2026-07-15 — superseded 2026-07-16 (Steps 1–2 executed; results reviewed; deploy step still open via go-cicd-ops brief)

Context: owner review of the 07-14 round results + deploy diagnosis.
The deploy pipeline lives in Hungee/go-cicd-ops (Tekton builds on push
to scrapers main → update-gitops-image rewrites the digest in the
overlay manifests → ArgoCD syncs). The owner has upgraded DOCR to the
5-repository plan — the shared parrilla image repo workaround is to be
REVERTED. Verdicts recorded: bench prune DONE (owner confirmed);
open_fav_v1 CLA gate PASSED (0.737 / 0.690 on 2023+); ROI leg missing.

Step 1 — fix the open_fav_v1 ROI leg and re-report

reports/strategy_shift/results-open-fav-v1.md has an EMPTY policies
table under the strategy section — the season-bootstrap ROI/CI never
rendered, so the verdict is one-legged. Diagnose (likely the strategy
path never feeds settle_policy/bootstrap, or the table renders before
policies attach), fix, and re-run:

bash python scripts/report_strategy_shift.py --phase open-fav-v1-roi

Gates unchanged from the pre-registration (CLA ≥ 0.60 — already passed;
ROI CI reported with 2023+ slice; ≥5 seasons AND ≥100 bets for any
significance claim). Seed the R20 paper trades (07-14 Step 2) only after
BOTH legs render.

Step 2 — pre-registered UNDERDOG segmentation diagnostic (report-only)

Owner question: is there a business-logic overlay that rescues a dog
subset? Pooled dog picks are anti-signal — burden of proof is high.
Run ONE CLA segmentation pass over model-backs-underdog picks with these
FOUR pre-registered segments (no others, no cherry-picking; report all):

  1. origin_week_flag = 1 (favourite potentially Origin-depleted)
  2. rain_flag = 1 (weather compresses margins)
  3. dog open price ≤ 2.60 vs > 2.60 (short dogs vs longshots —
    favourite–longshot bias predicts longshots are the worst side)
  4. home dog vs away dog

Pre-registered bar (written before results, do not adjust): a segment is
only interesting at CLA ≥ 0.55 with z ≥ 3 and ≥ 100 signals — z ≥ 3
because four segments get a multiplicity discount. Anything below stays
anti-signal. This is a DIAGNOSTIC: no strategy, no seeding, regardless
of outcome. Append the table to the Step-1 results MD.

Step 3 — deploy fix, REVISED for the 5-repo registry (go-cicd-ops)

The authoritative, sequenced version of this step now lives in
go-cicd-ops:docs/cli-brief.md (2026-07-15 round)
— that repo now
operates the same brief pattern as this one, and its work lands
directly on main (GitOps: main is the deployed state). The kaniko
GIT_COMMIT build-arg is already applied there. The outline below is
kept for context; where they differ, the go-cicd-ops brief wins.

  1. Retag the current image into its own DOCR repo (from the VM):
    crane copy registry.digitalocean.com/doks-ci-cd-reg/parrilla@sha256:bff5430... registry.digitalocean.com/doks-ci-cd-reg/scrapers:bootstrap
    (or docker pull/tag/push if crane is absent; DOCR auto-creates the
    repo on first push now the plan allows 5).
  2. Flip the manifests in go-cicd/overlays/prod/:
    scrapers-ui-service.yaml and scrapers-cronjob.yaml (both the
    odds-monitor digest ref and the team-lists parrilla:scrapers-bootstrap
    tag) → registry.digitalocean.com/doks-ci-cd-reg/scrapers:bootstrap.
  3. Flip the build target: in
    go-cicd/base/tekton/configs/pipeline/trigger-template.yaml, the
    scrapers-build-deploy-template image-repository default →
    registry.digitalocean.com/doks-ci-cd-reg/scrapers.
  4. Stamp builds: in
    go-cicd/base/tekton/configs/pipeline/pipeline.yaml, add
    --build-arg=GIT_COMMIT=$(params.git-revision) to the kaniko
    EXTRA_ARGS of scheduled-job-build-deploy-pipeline (the one scrapers
    uses; adding to the static-site pipeline too is harmless). This is
    why /ops shows build=unknown — the ARG is never passed.
  5. DuckDB into the UI pod — scrapers-ui is a Knative Service:
    it cannot mount the RWO PVC (that PVC also only holds odds ticks, not
    the staged DB). Route: VM uploads duckdb/nrl_bucket.duckdb to
    Spaces under sync/duckdb/ after each staging run (add to the weekly
    flow); scrapers-ui downloads it at startup (it already has SPACES_*
    creds mounted) to local disk and NRL_DUCKDB_PATH points there —
    add the env to scrapers-ui-service.yaml. Startup-download script
    lives in the scrapers repo (scripts/), guarded so local runs are
    unaffected.
  6. Apply/merge, let Tekton+ArgoCD roll, then re-run the six-route
    smoke
    — gate: vb-match-card renders with R20 data and /ops shows
    the GIT_COMMIT stamp and a green DuckDB row.

Step 4 — spine+lock cohesion (document-only this round)

Owner direction accepted: the modern lock (Yeo/Murray/Radley class) is a
fifth playmaker. docs/edge-risk-and-monitoring-plan.md now specs
spine+lock cohesion (1/6/7/9/13), a lock playmaker index, and cumulative
time-together as line-v2 candidates (the line model is not frozen).
No build this round — they queue behind Steps 1–3. Pre-register before
building.

Step 5 — report back

Push: fixed reporter + results-open-fav-v1-roi.{json,md} including the
underdog segmentation table (Steps 1–2); go-cicd-ops changes on a branch
there + smoke results appended to docs/ui-deployment-gaps.md (Step 3);
R20 seeding row in reports/live_evidence.md once both legs pass.
Failures and deviations in the results MD ## Deviations section.

2026-07-14 — superseded 2026-07-15 (deploy diagnosis stands; PVC-mount fix REVISED by Step 3 above)

Context: docs-governance round (branch
claude/nrl-model-audit-edge-xysmwm) — see docs/ui-value-board-review.md
for the value-board findings and docs/executive-brief.md for the exec
position. Model verdicts are UNCHANGED from docs/edge-review-2026-07-12.md
(favourite-side confirmed; divergence killed; cpois retired; H2H frozen).

Process change, effective now: cross-session communication is
standardised as skills — briefs are written with handover-brief, results
are audited with results-review (both in .claude/skills/, mirrored to
.codex/skills/). Read them once before starting.

Step 1 — CARRIED FORWARD: report the open-fav-v1 backtest

The strategy code landed (data/open_fav.py, reporter section in
scripts/report_strategy_shift.py) but no results file exists
reports/strategy_shift/ has nothing for this phase. Run:

bash python scripts/report_strategy_shift.py --phase open-fav-v1

Gates are the pre-registered ones from the 07-12 brief, unadjusted:
filtered-subset CLA ≥ 0.60; ROI CI with the 2023+ slice broken out;
kill if filtered CLA < 0.55. Push the results whether they pass or fail.

Step 2 — CARRIED FORWARD: R20 live evidence quota

reports/live_evidence.md still shows pending rows. Complete the R20
cycle (open snapshot → team lists → seed open_fav_v1 + rapm_timing_v1
paper trades → per-kickoff close snapshots → CLV ≤24h post-round), verify
green on /ops, and commit the R20 evidence row.

Step 3 — NEW: model attribution on value-board cards (P1)

Per docs/ui-value-board-review.md: dashboard.py knows which model priced
each market but card.primary and the supporting rows never receive it.
Plumb model_id through the card context and render it as a small tag
next to the model probability (primary + each supporting row). Add a test
in tests/ asserting the partial contains the model tag when a prediction
carries model_id. Gate: /partials/matches cards each name their model.

Step 4 — NEW: badge honesty

Rename the value-board badge pair to PAPER GO / WATCH at the service
layer (keep CSS classes). The title/aria quick wins are already in the
template; this makes the visible label honest too.

Step 5 — NEW: authenticated smoke of the HOSTED app (VM only)

The reviewer session's egress cannot reach
scrapers-ui.doks-ci-cd.hungee.cloud (gateway 403 on CONNECT), so hosted
verification belongs to this session. The operator holds the BasicAuth
credentials — export them locally, never commit them:

bash export UI_SMOKE_AUTH="<user>:<pass>" # from operator, out-of-band for p in / /partials/matches?season=2026\&round=Round%2020 /models /strategy /ops /docs; do curl -sS -u "$UI_SMOKE_AUTH" -o /dev/null -w "%{http_code} $p\n" "https://scrapers-ui.doks-ci-cd.hungee.cloud$p" done

Gate: all six return 200; the matches partial contains vb-match-card
markup with real R20 data (not the empty state); /ops shows current DB
sync age. Record the outcomes in the ui-deployment-gaps smoke checklist.

2026-07-15 update — data-not-loading DIAGNOSED (smoke completed).
Root cause confirmed via /ops and recorded in docs/ui-deployment-gaps.md:
the pod has no DuckDB at all (falls back to repo default path, not
found; every data-contract row UNAVAILABLE) and the image is stale
(build=unknown, predates the GIT_COMMIT arg). Remaining actions are
operator-level: rebuild the image with the GIT_COMMIT/IMAGE_BUILT_AT
build args, mount the PVC at /data, set
NRL_DUCKDB_PATH=/data/nrl_bucket.duckdb in the Deployment, run the
VM→PVC copy, then re-run the six-route smoke until vb-match-card
renders. Step 5 is DONE for this session once those results are recorded.

Step 6 — report back

Push: reports/strategy_shift/results-open-fav-v1.{json,md} (Step 1),
the R20 row in reports/live_evidence.md (Step 2), the model-attribution
change + test (Step 3–4), and hosted smoke results appended to
docs/ui-deployment-gaps.md (Step 5). One branch for the round; failures
and deviations go in the results MD ## Deviations section.

2026-07-12 — superseded 2026-07-14 (Steps 1–2 carried into current round; see docs/edge-review-2026-07-12.md)

Verdicts from the deep review: divergence signal KILLED (tested on full
history — the line stopped correcting toward H2H after 2018); CLA
segmentation found the edge's address: models backing the FAVOURITE at
open are confirmed by the market at 62.8–65% (z 5.5–6.9); backing dogs is
anti-signal
. cpois RETIRED. rapm-v4 demoted. H2H features FROZEN.

Step 1 — open-fav-v1 backtest verdict (the one model task this round)

Implement the filtered strategy exactly as pre-registered in
edge-review-2026-07-12.md N1: bet at OPEN only when model edge ≥ 3pp toward
the favourite (that side's open price ≤ ~1.90), models ss_strength_v1 +
logit_rapm_v1, both must agree when both signal, shrunk quarter-Kelly
stakes. Suggested shape: a new policy or a filtered settle pass in
data/backtest.py + a --phase open-fav-v1 report. Gates (pre-registered,
do not adjust): filtered-subset CLA ≥ 0.60; ROI CI reported with the
era-2023+ slice broken out; kill if filtered CLA < 0.55.
Tools you already have: closing_line_agreement_segments() in
data/calibration.py; line_h2h_divergence in data/edge_signals.py is the
killed signal — do NOT wire it anywhere.

Step 2 — arm the live evidence quota (every round, starting R20)

Per docs/edge-review-2026-07-12.md N2: Monday open snapshot → team lists
≤30min after drop → seed open_fav_v1 AND rapm_timing_v1 paper trades →
per-kickoff close snapshots (schedule_close_snapshots.py) → CLV settled
≤24h post-round. Verify each item shows green on /ops afterwards; commit a
one-line evidence log per round to reports/live_evidence.md (round, trades
seeded per strategy, CLV coverage).

Step 3 — report back

open-fav-v1 gates + the 2023+ slice; rounds of live evidence collected;
anything that blocked the quota. No other model phases this round.

2026-07-08 review update (phase3-rapm-v2 verdict + new work):

  • v2 verdict: G6 FAIL confirmed the prediction — logit_rapm_v1 is
    bit-identical to logit_open_mkt on every metric (0.6261 log-loss, same
    733 CLA signals, same ROI to 4dp). Bit-identical is MORE than a zero
    coefficient — diagnose whether rapm_lineup_delta is actually reaching
    the fitted frame
    : run build_feature_rows(...) and check
    features["rapm_lineup_delta"].abs().sum() > 0 and its variance per
    season. If it's all zeros there is a join/wiring bug (report it); if it
    has variance, the logit is genuinely ignoring it (also a result).
  • CLA headline (the edge): open-priced models' disagreements with the
    open are confirmed by the close at 54–55% (z 2.7–3.4). The opening price
    is soft — this is now the stated edge thesis on the /strategy page.
  • logit_open_mkt shrunk_kelly "+51% significant" on 39 bets was a
    significance-rule gap — fixed: CIs now also require ≥100 bets.
  • Step 3/4 below (stats-prior RAPM) is still the main event — not run yet.
  • New UI pages to smoke (ui-up / run-edgebet-ui skill): /models
    (catalogue + latest report metrics), /strategy (edge thesis, policies,
    gates), /ops (pipeline status incl. per-label odds collection and
    player-stats round lag). Verify /ops statuses match reality on the VM.
  • Weekly player stats: weekly_2026_round.sh now rebuilds
    gold_nrl_player_name_map + gold_nrl_player_round_stats every week
    (independent of SKIP_WEATHER). Verify next round's run and that /ops
    shows "Player match stats" tracking the draw round.

Audience: the data-connected CLI session on the VM. Work top to bottom.
Reviewer contract unchanged: everything lands in reports/strategy_shift/
via scripts/report_strategy_shift.py and gets committed to
claude/nrl-model-audit-edge-xysmwm. Failed checks get pushed, not fixed in post.

What's new in code (this commit) — three tools aimed at finding a large
edge faster than paper-trade ROI can:

  1. Closing-line agreement (CLA)data/calibration.py::closing_line_agreement,
    now in every standard report (schema v3). For each model: when it disagrees
    with the opening price by ≥3pp, does the market move toward it by close?
    This is the necessary condition for edge, measurable on ~3,500 matches
    today instead of 100 paper trades next season.
  2. RAPM time decayRAPMConfig(decay_half_life_seasons=...); old
    seasons' evidence decays exponentially.
  3. Stats-prior RAPM (offset ridge)_fit_rapm(..., player_priors=...) /
    build_walk_forward_from_frames(..., priors_by_season=...): player ratings
    shrink toward a box-score prior instead of toward zero. The mechanism is
    built and tested; YOU construct the priors from
    gold_nrl_player_round_stats (recipe in step 4).

Step 0 — still owed from the last plan

results-phase3-rapm-v2 was never pushed. Run it FIRST, before any of the new
options, so there is a clean leakage-free RAPM baseline to compare decay and
priors against:

git checkout claude/nrl-model-audit-edge-xysmwm && git pull
python -m pytest tests/ -q                    # must be green
python scripts/report_strategy_shift.py --phase phase3-rapm-v2
git add -A && git commit -m "phase3-rapm-v2: leakage-free RAPM vs ablation control" && git push

Step 1 — read the CLA table before anything else

The v2 report now includes Closing-line agreement. This reorders all
remaining work by expected payoff:

  • Any model with agreement ≥ 0.53 and |z| ≥ 2 → its signal family is real;
    prioritise everything touching that family.
  • logit_rapm_v1 CLA is the cheap preview of the team-list-timing hypothesis:
    if lineup-driven disagreements with the open are confirmed by the close,
    the Tuesday-window strategy has a live pulse. Report this number
    prominently either way.
  • All models ~0.50 → the current features hold nothing the market confirms;
    the stats-prior work in step 4 becomes the main event.

Step 2 — RAPM decay A/B (30 min)

python - <<'EOF'
import sys; sys.path.insert(0, "nrl-bet-advisor")
from data.rapm import RAPMConfig, build_rapm_walk_forward_features
import numpy as np

for label, config in [
    ("flat", RAPMConfig()),
    ("hl=1.5", RAPMConfig(decay_half_life_seasons=1.5)),
    ("hl=2.0", RAPMConfig(decay_half_life_seasons=2.0)),
    ("hl=3.0", RAPMConfig(decay_half_life_seasons=3.0)),
]:
    feats = build_rapm_walk_forward_features(config)
    deltas = [v["rapm_lineup_delta"] for v in feats.values()]
    print(f"{label:8s} matches={len(deltas)} mean|delta|={np.mean(np.abs(deltas)):.3f}")
EOF

Pick the half-life by out-of-sample log-loss of logit_rapm_v1, NOT by
delta magnitude: temporarily set the chosen config in
backtest.py::_bulk_rapm_features' call (make it a module constant
RAPM_BACKTEST_CONFIG), re-run the reporter as --phase phase3-rapm-decay,
and keep whichever beats phase3-rapm-v2's logit_rapm_v1 log-loss. If none
do, keep flat and say so.

Step 3 — build the box-score player-value prior

Recipe (all walk-forward — priors for season S use data ≤ S−1 only):

  1. Per player-season, aggregate gold_nrl_player_round_stats into per-80-minute
    rates: run metres, post-contact metres, line breaks, tackle breaks, try
    involvements (tries + assists), tackles made, missed tackles, errors.
    Weight by minutes; require ≥ 400 career minutes else prior = 0.
  2. Fit stat weights by regressing MATCH margin on the differential of
    team-aggregated per-80 rates (home minus away), seasons ≤ S−1, Ridge or OLS.
    This converts stat lines into points-per-game currency honestly — do not
    hand-pick weights, and do not use fantasy_points (arbitrary scoring).
  3. Player prior for season S = dot(stat weights, player per-80 rates through
    S−1), clipped to ±6 points (a prior should anchor, not dominate).
  4. Build priors_by_season = {S: {canonical_player_id: prior}} and pass it
    through build_walk_forward_from_frames(..., priors_by_season=...) — add a
    build_rapm_stats_prior_features() wrapper in data/rapm.py mirroring the
    existing builder, and a RAPM_BACKTEST_CONFIG switch so the backtest can
    select it.
  5. Unit-test the prior constructor (≥3 tests: minutes threshold, walk-forward
    cutoff respected, clipping).

Then:

python -m pytest tests/ -q
python scripts/report_strategy_shift.py --phase phase4-stats-prior
git add -A && git commit -m "phase4: stats-prior RAPM (xRAPM-style)" && git push

Success criteria (same discipline as always — decided before you look):
logit_rapm_v1 log-loss < both phase3-rapm-v2 AND logit_open_mkt (G6),
λ ≥ 0.10 (G7), and CLA ≥ 0.52 on its signals. ROI/CIs report-only.

Step 4 — arm the live channel (if not already running)

Unchanged from docs/phase3-v2-test-plan.md step 5: rapm_timing_v1 paper
trades at team-list drop, close snapshots per kickoff
(scripts/schedule_close_snapshots.py), CLV updated post-round. If phase 4's
prior model wins, use ITS deltas for the timing signal from the next round on.

Step 5 — report back (PR comment or thread)

  • phase3-rapm-v2 gates + CLA table (the owed baseline)
  • Decay A/B: chosen half-life and the log-loss it earned (or "flat kept")
  • phase4 gates, λ, CLA — and the one-line answer: does the stats prior make
    the lineup channel real?
  • Rounds of rapm_timing_v1 trades now collecting CLV
  • Any deviation from the prior recipe, in the MD deviations note

Guardrails (bitten once, now test-enforced — do not fight them)

  • No closing features in open-priced models (tests/test_leakage_guard.py).
  • Priors for season S from data ≤ S−1 only — the walk-forward guarantee is
    YOURS in step 3; the reviewer will check the cutoff logic in your test.
  • No gate-threshold edits in a commit that reports results.
  • CLA uses closing data as a diagnostic label; it must never become a feature.

2026-07-10 review update — CRITICAL: RAPM was never wired; phases void

Bit-identical results across phase3-rapm-v2 / decay / phase4 exposed a join
bug, now root-caused and fixed: the historical backtest frame has NO round
column (round_num = 0 for every match), so the round-keyed RAPM and lineup
caches NEVER matched. rapm_lineup_delta and ALL lineup features were zero in
every backtest ever run — including the original audit's "lineup coefficients
0.000". Decay and stats-prior A/Bs measured zeros; those reports are void.

Fixed in this commit: the RAPM builder emits kickoff-date-keyed aliases and
the backtest looks up date-first (the join pattern weather already used);
the lineup cache gets the same treatment via a draw-derived date map.
Regression tests: tests/test_rapm_wiring.py.

Run order for the VM (supersedes step 0–3 above):

git pull
python -m pytest tests/ -q
# 1. Verify the fix bites on real data (MUST be nonzero now):
python - <<'PY'
import sys; sys.path.insert(0, "nrl-bet-advisor")
from data.backtest import build_feature_rows, load_historical_source
f = build_feature_rows(load_historical_source())
print("rapm |delta| sum:", f["rapm_lineup_delta"].abs().sum())
print(f.groupby("season")["rapm_lineup_delta"].agg(["mean", "std"]).tail(8))
print("lineup nonzero rate:", (f["lineup_overall_delta"].abs() > 1e-9).mean())
PY
# 2. First REAL lineup-channel measurement:
python scripts/report_strategy_shift.py --phase phase3-rapm-v3
# 3. Then re-run the A/Bs that previously measured zeros:
#    decay (set RAPM_BACKTEST_CONFIG) -> --phase phase3-rapm-decay-v2
#    stats-prior                     -> --phase phase4-stats-prior-v2
git add -A && git commit -m "phase3-rapm-v3 + re-runs on fixed join" && git push

Expect logit_core_market / lineup_prior_year_v1 metrics to MOVE (lineup
features populate for the first time). That is the fix working, not a
regression. The roadmap (docs/edge-roadmap.md) sequences everything after.