This is the single source of truth for HOW the Friday automation runs and how to
tell whether a run succeeded. It explains what fires, when, what each of the 18
orchestrator steps does, where outputs and logs land, and the canonical "did Friday
succeed?" signal. It is the EXPLANATION layer only: Phase 23's runbook + state-of-system
summary will CITE this file for operational detail, and this file in turn CITES
AUDIT-REPORT.md (the Phase 20 data/feature-correctness audit handoff) and PIPELINE.md
(the canonical 7-stage run sequence the orchestrator wraps). It documents the
now-verified-and-fixed reality after Phase 21 Waves 1-2 (the durable AUTO-01 guard,
the offseason no-op, the three mechanical fixes, and the scheduling reconciliation),
not intentions.
Status tags are ASCII [PASS] / [FAIL] (no emoji, per CLAUDE.md).
The Friday automation is a single Windows Task Scheduler task, NFL_Predict_Pipeline,
that fires once a week.
- Trigger: Friday
18:00LOCAL =6 PM ET. TheStartBoundaryindeployment/windows_scheduler.xmlcarries NO timezone offset (2026-09-11T18:00:00), soschtasksinterprets it in machine-LOCAL time.18:00local equals6 PM ETonly while the machine stays on Eastern Time (the D-09 caveat). If the machine is moved to another timezone, the 6 PM ET freeze is no longer honored and the trigger must be re-pointed. - Canonical command:
uv run python scripts/friday_pipeline.py --log-level INFO(the<Exec>in the XML:<Command>= the absoluteuv.exepath +<Arguments>run python scripts/friday_pipeline.py --log-level INFO</Arguments>). - Principal: the owner user
jackcwithLogonType=S4UandRunLevel=HighestAvailable(NOT the SYSTEM SIDS-1-5-18, which cannot see the owner'suv/.venv/.env). S4U runs whether or not the owner is interactively logged on, which theWakeToRunFriday-evening run needs.LogonType=S4Uis the CONFIRMED working principal (D-08 register-and-verify, Section 9). Because the S4U logon does NOT load the owner's USER PATH, the<Command>must be the ABSOLUTEuv.exepath (C:\Users\jackc\AppData\Roaming\Python\Python313\Scripts\uv.exe) -- a bareuvfails under S4U with0x80070002"file not found". The remedy was the absolute path, not switching off S4U. - WorkingDirectory:
C:\Users\jackc\Code\nfl-predict. The committed XML is machine-specific (this path + theUserIdprincipal) and must be re-pointed if the repo moves to a different machine or user. - Robustness settings (XML):
MultipleInstancesPolicy=IgnoreNew,StartWhenAvailable=true,RunOnlyIfNetworkAvailable=true,WakeToRun=true,ExecutionTimeLimit=PT2H,AllowHardTerminate=true,AllowStartOnDemand=true,Priority=7. The XML is UTF-16 encoded. - Installer (single source of truth, D-07): the task is registered by
uv run python deployment/setup_scheduling.py --install, which runsschtasks /create /tn NFL_Predict_Pipeline /xml deployment/windows_scheduler.xml /f(idempotent overwrite) and cleans up the old split tasks (NFL_Predict_DataUpdate,NFL_Predict_Predictions). The installer installs the committed XML so the installer and the definition can never drift; there is no second scheduling mechanism (no PowerShellRegister-ScheduledTask, no cron).
scripts/friday_pipeline.py accepts (the phase flags are mutually exclusive):
| Flag | Effect |
|---|---|
| (none) | Full pipeline: all 18 steps (DATA + PREDICTIONS). |
--data-only |
DATA phase only (the 8 DATA-phase steps). |
--predictions-only |
PREDICTIONS phase only (the 10 PREDICTIONS-phase steps); logs a "ensure data artifacts are fresh" warning. |
--dry-run |
List the steps that would execute (filtered by mode); execute nothing; exit 0. Works year-round: --dry-run bypasses the offseason no-op short-circuit so steps can be inspected out of season without --force (WR-04). |
--force |
Bypass the pre-flight staleness/season checks AND the offseason no-op short-circuit; pre-flight health becomes advisory. |
--log-level {DEBUG,INFO,WARNING,ERROR} |
Logging verbosity (default INFO). |
Note: the scheduled task runs the FULL pipeline (no phase flag). The
--predictions-only/--data-only/--dry-run/--forcemodes are operator tools for manual / out-of-season runs.
The CLI resolves the mode from its flags and delegates to
pipeline.orchestrator.FridayPipeline(force, mode).run(). run() is a fixed 5-phase
flow:
- A. Pre-flight staleness gate (
pipeline/staleness.py, bypassed by--force): a season-window check plus four freshness signals (week-valid, data-freshness, partial-run, model-age warn-only). Staleness warnings fire exactly onealert_staleness_warning(WARNING) and continue; a staleness failure setsstatus="failed", firesalert_pipeline_failure(CRITICAL) and raises. - B. Pre-flight health check (
pipeline/health.py, ALWAYS runs): 3 read-only checks (database connectivity, model artifacts, disk space). Without--force,unhealthyaborts (failure alert + raise). With--force,unhealthyis advisory: it appends the warning"Pre-flight health: unhealthy (forced)"and continues. - C. Step execution: the 18-step registry (
pipeline/steps.py), phase-filtered by mode, run in order. The log is written atomically after each step (incremental snapshot). A critical step failure setsstatus="failed", firesalert_pipeline_failure(CRITICAL) and raises immediately. A non-critical step failure appends a warning and continues; the run endsdegraded. - D. Post-run health check (6 checks; ADVISORY ONLY): never changes
statusor the exit code. Anunhealthypost-run result only appends"Post-run health check: unhealthy"to warnings. - E. Completion alerting (exactly ONE alert per outcome):
failed->alert_pipeline_failure(CRITICAL);degraded->alert_degraded_completion(WARNING);success->alert_pipeline_success(INFO).
A live, scheduled, unforced Friday run during the NFL offseason would otherwise hit
the staleness season gate, set status="failed", fire a CRITICAL "Pipeline Failed"
alert, and raise -- a false alarm that erodes trust (crying wolf). To prevent this,
scripts/friday_pipeline.py::main detects the offseason and returns 0 as a clean INFO
no-op BEFORE constructing FridayPipeline, so no orchestrator alert path is reached.
The offseason window mirrors StalenessGate.check_season exactly
([season_start, season_start + 22 weeks], resolved via get_current_nfl_week() in ET),
so the CLI short-circuit and the season gate can never disagree. --force deliberately
bypasses the short-circuit so the operator can still run out of season (e.g. a one-time
forced run against a completed-week stand-in). A live unforced offseason run therefore
exits 0 as a no-op with no CRITICAL alert.
The registry (pipeline.steps.build_step_registry) is exactly 18 StepDefinition
entries: 8 in the DATA phase, 10 in the PREDICTIONS phase. Each step uses deferred
imports (inside the function body) to avoid argparse collisions and module-level side
effects. critical=True means a failure aborts the run; retryable=True means transient
errors trigger retry.
| # | Step | Phase | Critical | Retryable | What it does |
|---|---|---|---|---|---|
| 1 | ingest_games |
DATA | yes | yes (3) | Ingest current-week games via nflreadpy. |
| 2 | ingest_weather |
DATA | no | yes (3) | Ingest weather forecasts via Open-Meteo (async). |
| 3 | data_qa |
DATA | yes | no | Run data-quality validation; fail if checks failed. |
| 4 | build_elo |
DATA | yes | no | Update Elo ratings for the current season. |
| 5 | build_team_form |
DATA | yes | no | Build team-form metrics for the current week. |
| 6 | build_contextual |
DATA | yes | no | Build contextual features (travel, rest, venue). |
| 7 | build_weather_features |
DATA | no | no | Build weather-based features for outdoor games. |
| 8 | verify_data_artifacts |
DATA | yes | no | Verify required silver + gold artifacts exist before predictions. |
| 9 | ingest_odds |
PREDICTIONS | yes | yes (3) | Capture the odds snapshot from The Odds API. |
| 10 | build_market_anchors |
PREDICTIONS | yes | no | Build market-anchor features from the odds snapshot. |
| 11 | build_features |
PREDICTIONS | yes | no | Assemble the unified per-target gold feature matrices. |
| 12 | validate_features |
PREDICTIONS | yes | no | Validate features for data leakage / quality. |
| 13 | validate_models |
PREDICTIONS | yes | no | Validate WP/ATS/OU models are available + loadable (via artifacts/latest.json). |
| 14 | generate_predictions |
PREDICTIONS | yes | no | Generate current-week predictions (loads artifacts, applies market blend). |
| 15 | generate_recommendations |
PREDICTIONS | yes | no | Derive bet recommendations (edges that cleared medium/high confidence). |
| 16 | export_artifacts |
PREDICTIONS | yes | no | Export the predictions CSV to JSON. |
| 17 | validate_predictions |
PREDICTIONS | yes | no | Validate the prediction file (non-empty, required columns, wp_prob in [0,1]). |
| 18 | verify_output_files |
PREDICTIONS | no | no | Verify the expected output files exist (advisory; warns on missing). |
Note: retry is handled by
tenacity(Retryingwithwait_exponentialbackoff) and fires ONLY onTRANSIENT_EXCEPTIONS(ConnectionError,TimeoutError,OSError, andhttpx.HTTPStatusErrorwhen httpx is available). Local / validation errors (ValueError,RuntimeError, etc.) are NOT retried -- they fail fast. Only the three network-touching steps (ingest_games,ingest_weather,ingest_odds) are retryable.
All current-week prediction artifacts are written under outputs/predictions/
(via the single pipeline.steps._predictions_output_dir() helper):
outputs/predictions/predictions_<season>_week<week>.csv-- the prediction matrix.outputs/predictions/predictions_<season>_week<week>.json-- the same, JSON.outputs/predictions/game_context_<season>_week<week>.csv-- per-game context.outputs/predictions/recommendations_<season>_week<week>.json-- bet recommendations.
The orchestrator does NOT train, backtest, or rebuild the web cache -- those are the separate PIPELINE.md stages 3, 4, and 6.
The execution log is written to logs/friday_pipeline.json.
- Single-file overwrite -- NO per-run history. Each run overwrites the same file; there is no per-run archive or rolling history. The log reflects the MOST RECENT run only.
- Written atomically by
pipeline.execution_log.write_execution_log_atomic(temp file +os.replace), so a crash mid-write never leaves a partial JSON file. The log is snapshotted after each step (incremental), so a mid-run inspection shows progress so far. - Shape:
ExecutionLog(status, start_time, end_time, season, week, total_duration_ms, forced, mode, pid,steps[]ofStepLogEntry,warnings[],error).
Note: per-run log history is a deferred FUTURE enhancement (D-11). It is deliberately NOT added in this phase (documentation / verification only); the single-file overwrite is documented as-is.
A Friday run resolves to exactly one of three observable outcomes. The canonical signal
is a TRIAD: the log.status, the matching alert level, and the presence of the
predictions output file. These three map to distinct, observable signals (proven by the
durable AUTO-01 test added in Plan 21-04).
| Outcome | log.status |
Alert method (level) | Exit code | Predictions file |
|---|---|---|---|---|
| success | success |
alert_pipeline_success (INFO) |
0 | present |
| degraded | degraded |
alert_degraded_completion (WARNING) |
0 | present (a non-critical step failed) |
| failed | failed |
alert_pipeline_failure (CRITICAL) |
1 | may be absent |
Stated plainly:
"Friday succeeded" ==
log.statusin{success, degraded}ANDoutputs/predictions/predictions_<S>_week<W>.csvexists (non-empty,wp_probin[0,1]).
The exit code is 0 for BOTH success and degraded (a degraded run still produced
predictions; only a non-critical step like weather or output-file verification failed),
and 1 only for failed. The AUTO-01 keystone test
(test_orchestrator_predictions_phase_e2e in
tests/integration/test_friday_prediction_step.py) drives the REAL
FridayPipeline(mode="predictions-only", force=True).run() through the REAL step
registry (no stubbed registry -- that stubbing was the cb61042 blind spot) and asserts
log.status == "success", the predictions CSV exists, game_id is present, and
wp_prob is in [0,1] -- proving the triad's success branch produces a real,
leakage-safe predictions file. The registry itself is unstubbed, but three of the ten
PREDICTIONS-phase step BODIES are no-op'd (step_ingest_odds, step_build_market_anchors,
step_build_features) so the committed gold supplies their inputs; the remaining
generate/validate/export/verify steps run for real (matching the test's docstring). An owner-confirmed one-time live forced run produced a
real predictions file offline (16 rows, wp_prob 0.176-0.867, all market rows matched
from the on-disk odds snapshot with NO live pull) with NO model artifact re-fit.
Alerts are log-only. Every alert always fires to the console/log channel
(_send_console_alert + the logger.{info,warning,error,critical} line in
AlertManager.create_alert), so a normal run produces its alert with NO configuration.
Email and Slack are currently INERT -- they cannot fire at all via conf/config.yaml
today. This is a code-wiring gap, NOT a missing-secret problem: even with every key set,
no email or Slack message would be sent. The D-05 verification (empirical, read-only) is
recorded in Section 9; the alert-wiring gap is captured (NOT fixed) in Section 10 with the
3-part remedy.
The orchestrator's four alert methods (pipeline/alert.py) each map to exactly ONE event
and level: failure -> CRITICAL, success -> INFO, staleness -> WARNING, degraded ->
WARNING. They wrap utils.alert_manager.AlertManager, whose send_alert is supposed to
route each alert to the channels configured for its severity, gated on the enable flags.
A conf/config.yaml monitoring.notifications block (recipients, webhook URL, per-level
channel lists) exists in spirit, but the running code never reads it. Three independent
breaks, each sufficient on its own, keep email/Slack dark:
AlertManagerreads a non-existent attribute.AlertManager.__init__setsself.monitoring_configfromself.settings.monitoring-- butconf.settings.Settingshas NOmonitoringattribute (the YAML monitoring block lives underself.settings.config.monitoring). Sohasattr(self.settings, "monitoring")isFalseandself.monitoring_config == {}ALWAYS.send_alertthen resolvesnotification_levels.get(<level>, ["console"])to["console"]for EVERY level -- CRITICAL and INFO alike route to["console"]only, regardless of any config.MonitoringConfigdoes not declarenotifications. Even via the correct path (self.settings.config.monitoring),conf.settings.MonitoringConfigdeclares onlyenable_health_checks,health_check_interval,data_quality, andmodel_performance-- there is NOnotificationsfield. Pydantic (extra=ignore) DROPSconfig.yaml'smonitoring.notificationsblock, somodel_dump()would never exposeenable_email/enable_slack/email_recipients/slack_webhook_url/notification_levelseven if break (1) were fixed.Settingslacks the SMTP fields. The email SEND path (AlertManager._send_email_alert) referencesself.settings.email_from,self.settings.smtp_host,self.settings.smtp_port,self.settings.smtp_use_tls,self.settings.smtp_username, andself.settings.smtp_password-- NONE of which are defined onSettings. If the email channel were ever reached it would raiseAttributeError. (The.envflagsENABLE_EMAIL_REPORTS/ENABLE_SLACK_NOTIFICATIONSthat DO exist onSettingsare unrelated --AlertManagernever reads them.)
Net: the console/log alert (always fires) is the real, working behavior. Both email AND
Slack are currently inert -- enabling them is NOT possible by configuration alone; it
requires the code changes listed in Section 10. The rate-limit / cooldown logic in
should_send_alert (cooldown_minutes default 30, max_alerts_per_hour default 20) reads
the same empty monitoring_config, so it falls back to those defaults.
Note (D-05 / honesty): an earlier draft of this section implied "Slack works as documented; email needs SMTP settings." That was inaccurate -- in the current code BOTH email and Slack are inert. The honest statement is: alerts are console/log ONLY, and enabling email/Slack requires the deferred code changes in Section 10. The default is log-only and a normal run requires no configuration.
The D-12 automation-audit findings record. This section catalogs what was FIXED in Phase 21 (correctness / wiring only -- documentation / verification milestone, no model re-fit and no gold rebuild, D-01). Mirrors
AUDIT-REPORT.md's F-NN / D-11-x entry shape. Commit hashes are the atomic per-task commits.
- Where:
scripts/build_team_form.py::build_for_current_week. - Fix: rerouted off the deprecated, non-time-fenced
build_team_form_featuresonto the time-fencedTeamFormCalculator.build_featuresProtocol method, so the live current-week build no longer emits aDeprecationWarning. Current-week rolling rows are persisted to silverteam_form_featuresvia a(target_season, target_week)-keyed upsert that preserves the full on-disk history (the WR-01 invariant -- no season-span shrink, no append-merge growth). The operative leakage guard remains theweek < target_weekfilter incalculate_rolling_averages. - Commit:
a01aa7a.
- Where:
artifacts/models/{wp,ats,ou}_model.pkl. - Fix: deleted the three stale stub files (the wrong convention the Phase 20 CR-01
fix repointed health / staleness / model_validation AWAY from). They were UNTRACKED, so
removed by filesystem delete (Phase 19-02 precedent).
latest.json+ the realartifacts/{target}_{ts}/dirs are untouched; nothing on the Friday path reads the stubs. - Commit:
f90966f.
- Where:
features/market_anchors.py::create_consensus_lines. - Fix: fixed the latent
KeyError 'ml_home'(the column was renamed upstream toopening_ml_home/snapshot_ml_home) by making the median computation prefix-aware so both the opening- and snapshot-line callers work. - Live + critical (the fix prevents a Friday-run crash).
create_consensus_linesis reached LIVE on every Friday run by the critical PREDICTIONS step 10,step_build_market_anchors(pipeline/steps.py:211), which calls the deprecatedMarketAnchorFeaturesCalculator.build_market_anchor_features(features/market_anchors.py:589), which in turn callscreate_consensus_lines(features/market_anchors.py:649,654) for both the opening- and snapshot-line frames. Before this fix, that call raisedKeyError 'ml_home', which would have aborted the critical step and failed the whole Friday run with a CRITICAL "Pipeline Failed" alert. The fix therefore prevents a live critical-step crash -- it is load-bearing, not cosmetic. - Still no gold value change. The "no gold value changes" half holds: the canonical
gold matrix is built on the fly by the SEPARATE FeatureBuilder Protocol method
MarketAnchorFeaturesCalculator.build_features(features/market_anchors.py:937), invoked by step 11build_features(scripts/build_features.py), which does NOT read the silvermarket_anchor_featurestable that step 10 writes. So step 10's silver output is unconsumed by gold and the fix changes no gold value -- but the path is LIVE and CRITICAL, not dead. (That step 10 writes an unconsumed silver table is a separate deferred cleanup; see Section 10.) - Commit:
f90966f.
- Where:
pipeline/health.py::check_prediction_pipeline. - Fix: the post-run check globbed
current_predictions*.parquet-- a pattern the real run never produces -- so it ALWAYS reported "No prediction files found" even on a successful run. Changed the glob topredictions_*_week*.csv(via the shared_predictions_output_dir()helper) and read CSV. The check is advisory-only (it never blocks Friday), but it can now actually reporthealthy, strengthening the D-04 "predictions file present" signal. - Commit:
1ba2405.
- Where:
scripts/friday_pipeline.py::main. - Fix: an unforced offseason run now returns 0 as a clean INFO no-op BEFORE
constructing
FridayPipeline, so it never hits the staleness season gate and never fires a false CRITICAL "Pipeline Failed" alert. The offseason window mirrorsStalenessGate.check_seasonexactly;--forcebypasses. No newlog.statuswas added (Option A, smallest blast radius). - Commit:
cc66264.
- Where:
deployment/windows_scheduler.xml,deployment/setup_scheduling.py. - Fix: corrected the canonical XML -- owner principal
jackc+LogonType=S4U+RunLevel=HighestAvailable(NOT the SYSTEM SID, D-10);StartBoundaryrestored to18:00local = 6 PM ET (undoing the Phase-19 5 PM / 17:00 consolidation, D-09);<Exec><Command>changed from the hardcoded.venv\Scripts\python.exeto run the pipeline viauv(D-10). Madesetup_scheduling.pya single installer that registers THAT XML viaschtasks /create /xml ... /f(D-07), removing the dead Linux/mac cron branch. No second automation mechanism. - Commits:
45e2b84(XML),bfcf108(installer),6b72e41(tests). - D-08 register-and-verify follow-up fixes (discovered at the D-08 owner checkpoint;
these correct the 21-03 artifacts so the task actually registers and launches):
02e8b6a-- the XML header comment contained illegal--double-hyphens (inside the--platform/--installinstall instructions), which is forbidden inside an XML comment, soschtasks /create /xmlrejected the file ("The task XML is malformed. (9,55) incorrect comment syntax"). Rephrased the comment to be---free and added an XML well-formedness + no----in-comment regression test (the prior 21-03 test only string-matched content and never PARSED the XML -- that gap is now closed).2061907-- the bare<Command>uv</Command>failed under S4U with0x80070002("file not found") because the S4U logon does NOT load the owner's USER PATH (uvlives atC:\Users\jackc\AppData\Roaming\Python\Python313\Scripts\uv.exe, on the User Path, not the Machine Path). Owner-approved fix: set<Command>to the ABSOLUTEuv.exepath and KEEPLogonType=S4U(runs whether logged on or off, no stored password). The<Arguments>stayrun python scripts/friday_pipeline.py --log-level INFO.
- Final state (D-08 verified):
LogonType=S4Uis the confirmed working principal once the<Command>is the absoluteuv.exepath -- the remedy was the absolute path, NOT switching toInteractiveTokenOrPassword. The owner re-ran (elevated)uv run python deployment/setup_scheduling.py --install("Task 'NFL_Predict_Pipeline' created successfully"), thenschtasks /run(SUCCESS) andschtasks /query ... /v: Run As User =jackc(NOT SYSTEM), Task To Run resolves the absoluteuv.exerunningscripts/friday_pipeline.py, Schedule = Weekly / FRI / 6:00 PM, Next Run Time = 9/18/2026 6:00 PM (season-anchored, correct for the offseason), State = Enabled,Last Result = 0after/run(the run launcheduv.exeunder S4U and exited 0 via the D-06 offseason no-op, so nologs/friday_pipeline.jsonis written -- the no-op returns before the orchestrator is constructed). Details in Section 9.
- Where:
tests/integration/test_friday_prediction_step.py(test_orchestrator_predictions_phase_e2e). - Fix: added the durable
cb61042guard -- a permanent, non-mocked, offseason-safe test that drives the REALFridayPipeline(mode="predictions-only", force=True).run()through the REAL step registry (deliberately NObuild_step_registrypatch and NOmake_mock_step, since those ARE the aliasing blind spot) and proves a leakage-safe predictions file is produced. Plus an owner-confirmed one-time live forced run, offline, with no model re-fit. - Commit:
a9e8105.
Two verifications complete the automation audit on the owner's machine. They require
admin / a local .env and cannot be CI-automated. Both are now DONE and recorded here.
The owner registered NFL_Predict_Pipeline (elevated) via
uv run python deployment/setup_scheduling.py --install ("Task 'NFL_Predict_Pipeline'
created successfully"; the old split task names were cleaned up), ran it once with
schtasks /run (SUCCESS), and confirmed it via
schtasks /query /tn NFL_Predict_Pipeline /fo LIST /v:
| Field | Verified value |
|---|---|
| Run As User | jackc (NOT SYSTEM) |
| Logon Mode | Interactive/Background (= LogonType=S4U) |
| Task To Run | uv run python scripts/friday_pipeline.py --log-level INFO (via the absolute uv.exe) |
| Start In | C:\Users\jackc\Code\nfl-predict |
| Schedule | Weekly, Days = FRI, Start Time = 6:00 PM, Start Date = 9/11/2026 (Friday) |
| Next Run Time | 9/11/2026 6:00 PM (the boundary date is itself the first Friday of the 2026 season; season-anchored -- correct for the offseason) |
| State | Enabled |
| Stop-after | 02:00:00 (ExecutionTimeLimit=PT2H) |
| Last Result | 0 after schtasks /run (Last Run Time 5/29/2026 1:00 PM) |
The Last Result = 0 confirms the task launched uv.exe under S4U and exited 0 via the
D-06 offseason no-op (no logs/friday_pipeline.json is written because the no-op returns
before the orchestrator is constructed -- correct offseason behavior). Reaching this
required the two follow-up fixes in Section 8 (02e8b6a malformed-comment, 2061907
absolute uv.exe path). Final working LogonType = S4U (the remedy for the
0x80070002 PATH failure was the absolute uv.exe path, NOT switching to
InteractiveTokenOrPassword).
Note (WR-03): the committed
windows_scheduler.xmlStartBoundarywas corrected from the Saturday2026-09-12T18:00:00to the Friday2026-09-11T18:00:00(the first Friday of the 2026 season), so the anchor weekday now matches theDaysOfWeek=Fridaytrigger. The owner's ALREADY-REGISTERED OS task still carries the old9/12boundary; re-installing (uv run python deployment/setup_scheduling.py --install) to pick up the corrected boundary is OPTIONAL / non-urgent -- the registered task already fires on Fridays (the Saturday anchor only ever shifted the first eligible fire to the next Friday), so behavior is unchanged either way.
The owner did NOT enable email/Slack. A read-only diagnostic of utils/alert_manager.py
conf/settings.py(recorded in Section 7) empirically confirmed that email AND Slack are currently inert and cannot fire via config alone:AlertManagerreads the non-existentself.settings.monitoring(somonitoring_config == {}always),MonitoringConfigdeclares nonotificationsfield, andSettingslacks the SMTP fields the email path references. The console/log alert always fires and IS the working behavior; the default stays log-only. Enabling email/Slack requires the deferred code changes in Section 10 -- it is NOT a matter of populating config keys.
Real findings that are OUT of correctness scope for this documentation/verification phase. Per the HARD BOUNDARY (D-01/D-12) these are CAPTURED here, NOT fixed. They are robustness / hygiene items for a future milestone or the backlog. (Pointer: these mirror and extend the
AUDIT-REPORT.mddeferred record.)
- WR-03 -- dead
kickoff_ettime-fence guard.features/team_form.py:665-666: thebuild_featureskickoff_et < as_of_datetimeguard is dead code because thecalculate_team_game_statsoutput schema has nokickoff_etcolumn, so the guard never fires. The operative leakage protection on the team-form path is theweek < target_weekfilter insidecalculate_rolling_averages. Pre-existing and latent (not a live leakage exposure). Deferred: either joinkickoff_etso the guard actually fences, or remove the dead guard + the docstring claim. Documented inline at the F-01 routing site. - WR-04 -- fragile
locals()-membership idiom.features/market_anchors.py:724-730: market-efficiency features gate on"opening_probs" in locals() and "snapshot_probs" in locals(), a fragile cross-iterationlocals()-membership idiom that could read a prior game's stale probs if the guard structure ever changes (in practice both are recomputed when both data dicts are present, so the stale value is currently overwritten before use). Pre-existing. Deferred: initializeopening_probs={}/snapshot_probs={}per iteration and gate on non-empty dicts. - D-11-A -- broad
except Exceptiondrift in the pipeline package. Several deliberate graceful-degradation / log-and-continue boundaries use a broadexcept Exception(annotated# noqa: BLE001). Live sites observed this phase:scripts/friday_pipeline.py::main(top-level CLI catch, ~line 149, "top-level CLI must catch all") andpipeline/orchestrator.py::_execute_step(~line 175, "must catch all to record in log"). These per-step / top-level catches are intentional (they must record any failure in the log / exit cleanly). Others inpipeline/health.py/execution_log.py/model_validation.pyare narrowing candidates. PROJECT.md lists "no broad exception swallowing" as a validated v1.0 requirement, so the remaining catches are drift. Deferred: a future robustness pass narrows the non-intentional ones to operation-specific exception tuples (the patterndata/storage.pyadopted in Phase 15). The intentional CLI / per-step catches stay. - ALERT-WIRING -- email/Slack alert channels are inert (D-05 finding).
utils/alert_manager.py+conf/settings.py: the email and Slack alert channels cannot fire via configuration today (verified empirically in Section 7). Three independent breaks: (1)AlertManager.__init__readsself.settings.monitoring, which does not exist (the YAML monitoring block is atself.settings.config.monitoring), soself.monitoring_config == {}always and every level resolves to["console"]; (2)conf.settings.MonitoringConfigdeclares nonotificationsfield, so Pydantic (extra=ignore) dropsconfig.yaml'smonitoring.notificationsblock even via the correct path; (3)conf.settings.Settingslacks the SMTP fields (email_from,smtp_host,smtp_port,smtp_use_tls,smtp_username,smtp_password) thatAlertManager._send_email_alertreferences (wouldAttributeErrorif reached). Captured, NOT fixed (documentation/verification milestone -- HARD BOUNDARY D-01/D-12; no new feature). 3-part remedy for a future robustness milestone: (a) declare aMonitoringConfig.notificationssub-model (or haveAlertManagerread the raw YAML); (b) fixAlertManagerto readself.settings.config.monitoringinstead of the non-existentself.settings.monitoring; (c) add the SMTP fields toSettings(sourced from.env, never committed). Until then, alerts are console/log only and the default log-only behavior is the working behavior. - STEP10-UNCONSUMED-SILVER -- step 10 writes a silver table no downstream step reads.
pipeline/steps.py:211(step_build_market_anchors) saves the deprecatedbuild_market_anchor_featuresoutput to silvermarket_anchor_features, but the canonical gold is built on the fly by the separate Protocolbuild_features(features/market_anchors.py:937), which does not read that silver table. So the silver write is unconsumed by gold (see Section 8 D-11-E). The path is still LIVE and CRITICAL -- a crash there fails the Friday run -- so it cannot simply be removed. Deferred: reroute step 10 onto the Protocolbuild_featuresbuilder (and drop the unconsumed silver write) in a future cleanup; OUT of this documentation/verification phase's scope. - MA-MEDIAN-INT-TRUNC -- median moneyline truncated by
int().features/market_anchors.py:1037-1038: the compressedbuild_featurespath computesml_home_med = int(snap_ml_home_vals.median()), truncating toward zero; for an even number of books the median can be a half-integer (e.g. -127.5 -> -127), slightly biasing the implied probability. Pre-existing (outside this phase's diff). Deferred: round instead of truncate, or devig from the median raw probability rather than the median moneyline. - UV-PATH-MAINT -- absolute
uv.exepath is Python-major-version-pinned (maintenance note).deployment/windows_scheduler.xml: the scheduled task's<Command>is the absoluteC:\Users\jackc\AppData\Roaming\Python\Python313\Scripts\uv.exe(required because the S4U logon does not load the owner USER PATH, see Section 82061907). ThePython313segment is version-pinned: on a Python MAJOR upgrade (e.g. to Python 3.14) the per-user Scripts directory moves toPython314\anduv.exewill no longer resolve at the committed path. Maintenance action on a Python major upgrade: re-point the<Command>to the newScripts\uv.exepath and re-runuv run python deployment/setup_scheduling.py --installto re-register the task.
For the broader deferred robustness/hygiene catalog (D-11-B SQL-string-build,
silent in-memory-DB fallback, the 20-REVIEW.md WR-05..07 / IN-01..04 items, etc.) see
AUDIT-REPORT.md's "Non-correctness findings (deferred)" section.
AUDIT-REPORT.md-- the Phase 20 Data & Feature Correctness Audit handoff: the adopted canonical gold (D-02), the FIX-01 fixes, and the deferred findings record this document extends.PIPELINE.md-- the canonical 7-stage run sequence (ingest -> features -> train -> backtest -> predict -> build-cache -> serve) that the Friday orchestrator wraps for the current-week subset. The orchestrator does the ingest -> features -> predict -> validate work; train, backtest, and build-cache are the separate manual stages.- Phase 23 (forthcoming) -- the operational runbook + state-of-system summary will CITE this file. AUTOMATION.md is the EXPLANATION; the runbook is the operational procedure. This file does not duplicate runbook scope.
Phase 21 -- Automation Audit & Explanation. AUTO-04 end-to-end automation explanation + the D-12 FIX-vs-deferred findings record. ASCII only (no emoji, per CLAUDE.md).