From 54de05df5be8f245485d47cd27e8625fbc1a5378 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 18:00:03 +0000 Subject: [PATCH 1/2] slice-7: Phase 2 carry-forwards (overtaking call-site tests, per-circuit fit, Miami quali fallback) - build_overtaking_data(event_name=...) fits only the forecast circuit; cli.forecast_weekend and backtest.forecast_race_challenger pass it. - tests/test_overtaking_wiring.py fails if either call site stops building the fit, or fits more than one circuit per forecast. - collect_v2 falls back to the fastest non-deleted session lap when a classified quali row has no Q1/Q2/Q3 time; flagged q_segment='laps' and logged. Refs #19 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EiJgo5GgDN14Q7LndX14Qv --- docs/autopilot/decisions.md | 12 ++ docs/autopilot/queue.md | 2 +- src/backtest.py | 5 +- src/cli.py | 11 +- src/collect_v2.py | 71 ++++++++++-- src/model/overtaking.py | 9 +- tests/test_collect_quali_fallback.py | 131 ++++++++++++++++++++++ tests/test_overtaking_wiring.py | 162 +++++++++++++++++++++++++++ 8 files changed, 388 insertions(+), 15 deletions(-) create mode 100644 tests/test_collect_quali_fallback.py create mode 100644 tests/test_overtaking_wiring.py diff --git a/docs/autopilot/decisions.md b/docs/autopilot/decisions.md index 7f56a9b..0e38cf3 100644 --- a/docs/autopilot/decisions.md +++ b/docs/autopilot/decisions.md @@ -148,3 +148,15 @@ Question: `RaceTrialInputs.dnf_prob` is the fold of both channels, so the res Chosen: Carry the channels as two *optional* fields beside `dnf_prob`, which stays what `simulate_positions` draws against. A caller holding only a combined probability leaves them None and gets the pre-slice-6 flat scaling rather than an invented split. `cli.forecast_weekend` fills them; `backtest.forecast_race_challenger` does not, because it resolves the Grand Prix alone through `simulate_positions` and has no sprint to price. Rejected: Making the channels the primary fields and deriving `dnf_prob` as a computed property. Tidier, and it removes the chance of the fold disagreeing with its parts — but a dataclass property cannot be passed to the constructor, so every existing `RaceTrialInputs(dnf_prob=...)` call site and four test modules would have to be rewritten, for no change in behaviour. Also rejected: a single `incident_share` scalar, which assumes one split for the whole field when the mechanical channel is per team and the incident channel per driver. Blast radius: Two fields on `RaceTrialInputs` and two keyword arguments in `cli.forecast_weekend`. The risk worth naming: `dnf_prob` and the channels are now two statements of the same quantity and nothing enforces that they agree — a caller could set channels inconsistent with the fold and the Grand Prix would use one while the sprint used the other. `tests/test_cli.py::test_both_channels_reach_the_resolver_and_fold_to_dnf_prob` asserts the production path keeps them consistent; the dataclass itself does not. + +## 2026-10-20 — slice-7 +Question: The criterion allows either fitting only the forecast round's circuit or caching the full fit per pre-round window. Which? +Chosen: Fit only the forecast circuit: `build_overtaking_data(..., event_name=...)`, passed by both call sites through a new `cli._round_event_name`. One fit per forecast, whatever the window holds. Omitting `event_name` still fits the whole window, so the slice-4 tests and any diagnostic caller are unchanged. +Rejected: A cache keyed on the pre-round window. Each backtest round has a different window, so a cache only helps when the same round is forecast twice in one process, and it adds invalidation questions (a mutated `driver_rounds` frame) for no gain on the backtest path the carry-forward was about. +Blast radius: One keyword on `build_overtaking_data`, two call sites. The fitted value for the forecast circuit is identical either way (`tests/test_overtaking_wiring.py::TestSingleCircuitFit` asserts equality with the full fit), so no forecast number moves. + +## 2026-10-20 — slice-7 +Question: How should `collect_v2` flag a quali row whose time came from the session laps (2025 Miami), and which laps count? +Chosen: `q_segment = "laps"` on the row plus a WARNING log naming the drivers. Per row: only a row with a `Position` and no Q1/Q2/Q3 time falls back, to that driver's fastest lap with `Deleted` laps excluded. Pole is still the official Q3→Q2→Q1 minimum wherever any official time exists; lap times set pole only when the session has none at all. +Rejected: A new `time_source` column. Clearer, but it changes the `driver_rounds.csv` schema (COLUMNS, the validator, every existing file) for one round in nine seasons; `q_segment` has no readers in `src/` or `tests/`, so overloading it is free. Also rejected: triggering only when the whole session lacks times. Simpler, but the criterion is per row, and a partial upstream gap would then still drop drivers silently. +Blast radius: `_fastest_session_laps` and the fallback block in `collect_v2._build_quali_rows`. Fully-timed sessions are byte-for-byte unchanged (the laps are never read; a test asserts it). Lap-derived times can order near-ties differently from the official classification (NOR/PIA/ANT in Miami sit within 0.002s); `finish_position` still comes from the results, so the grid is unaffected. diff --git a/docs/autopilot/queue.md b/docs/autopilot/queue.md index 1d28092..f830d81 100644 --- a/docs/autopilot/queue.md +++ b/docs/autopilot/queue.md @@ -337,7 +337,7 @@ includes `--grid-from-quali` and the post-quali backtest arm. ## slice-7: Phase 2 carry-forwards -Status: todo +Status: in-progress Spec: `docs/phase2-status.md` "What is still open"; slice-4 notes above. diff --git a/src/backtest.py b/src/backtest.py index b1c15d0..d8bb799 100644 --- a/src/backtest.py +++ b/src/backtest.py @@ -182,7 +182,7 @@ def forecast_race_challenger(challenger_model, reliability_data, driver_rounds: combined_dnf_prob, sample_incident_dnf_prob, sample_mechanical_dnf_prob, ) from sim.race import RaceTrialInputs, simulate_positions, summarize_trials, load_points_table - from cli import _entrants_for_round, _circuit_info + from cli import _entrants_for_round, _circuit_info, _round_event_name from model.overtaking import build_overtaking_data rng = np.random.default_rng(seed) @@ -190,7 +190,8 @@ def forecast_race_challenger(challenger_model, reliability_data, driver_rounds: # Same fitted overtaking parameter the Bayesian path uses, off the same # pre-round window -- "same race resolver" above has to stay true. overtaking_data = build_overtaking_data(driver_rounds, circuits, - before_season=season, before_round=round_) + before_season=season, before_round=round_, + event_name=_round_event_name(driver_rounds, season, round_)) circuit_type, overtaking_difficulty = _circuit_info(circuits, driver_rounds, season, round_, overtaking_data) diff --git a/src/cli.py b/src/cli.py index b6b103c..65ecc30 100644 --- a/src/cli.py +++ b/src/cli.py @@ -243,6 +243,12 @@ def _quali_grid_for_round(driver_rounds: pd.DataFrame, season: int, round_: int, return values.rank(method="first", na_option="bottom").to_numpy(dtype=int) +def _round_event_name(driver_rounds: pd.DataFrame, season: int, round_: int): + """The round's event name, or None when the round has no rows.""" + rows = driver_rounds[(driver_rounds["season"] == season) & (driver_rounds["round"] == round_)] + return rows.iloc[0]["event_name"] if not rows.empty else None + + def _circuit_info(circuits: pd.DataFrame, driver_rounds: pd.DataFrame, season: int, round_: int, overtaking_data=None) -> tuple[str, float]: """(circuit_type, overtaking_difficulty) for one round. @@ -257,7 +263,7 @@ def _circuit_info(circuits: pd.DataFrame, driver_rounds: pd.DataFrame, season: i rows = driver_rounds[(driver_rounds["season"] == season) & (driver_rounds["round"] == round_)] if rows.empty: return "mixed", DEFAULT_DIFFICULTY - event_name = rows.iloc[0]["event_name"] + event_name = _round_event_name(driver_rounds, season, round_) circuit_type = rows.iloc[0]["circuit_type"] if pd.notna(rows.iloc[0]["circuit_type"]) else "mixed" match = circuits[circuits["event_name"] == event_name] hand_rating = float(match.iloc[0]["overtaking_difficulty"]) if not match.empty else None @@ -303,7 +309,8 @@ def forecast_weekend(idata, data, reliability_data, driver_rounds: pd.DataFrame, # model on a parameter that already saw the answer. if overtaking_data is None: overtaking_data = build_overtaking_data(driver_rounds, circuits, - before_season=season, before_round=round_) + before_season=season, before_round=round_, + event_name=_round_event_name(driver_rounds, season, round_)) circuit_type, overtaking_difficulty = _circuit_info(circuits, driver_rounds, season, round_, overtaking_data) round_idx = data.round_to_idx.get((season, round_)) # None for a genuinely future round -> use latest diff --git a/src/collect_v2.py b/src/collect_v2.py index ee3df4b..63fb165 100644 --- a/src/collect_v2.py +++ b/src/collect_v2.py @@ -133,6 +133,34 @@ def _weather_summary(session) -> tuple[bool | None, float | None]: return None, None +# `q_segment` on a quali row whose time came from the session laps rather +# than the official Q1/Q2/Q3 columns. Nothing reads q_segment as a segment +# name, so the flag needs no schema change. +LAPS_FALLBACK_SEGMENT = "laps" + + +def _fastest_session_laps(session) -> dict: + """abbreviation -> fastest non-deleted lap in seconds, off the session laps. + + The fallback for a quali whose results carry positions but no Q1/Q2/Q3 + times (2025 R6 Miami upstream). Deleted laps (track limits) are excluded, + so a driver whose only lap was struck off still gets no time. + """ + try: + laps = session.laps + except Exception: + log.warning(" session laps unavailable for %s %s", getattr(session, "event", "?"), + getattr(session, "name", "?"), exc_info=True) + return {} + if laps is None or laps.empty or "LapTime" not in laps.columns: + return {} + laps = laps.dropna(subset=["LapTime"]) + if "Deleted" in laps.columns: + laps = laps[laps["Deleted"].fillna(False) != True] # noqa: E712 -- object dtype + fastest = laps.groupby("Driver")["LapTime"].min() + return {str(d): t / np.timedelta64(1, "s") for d, t in fastest.items()} + + def _build_quali_rows(year: int, rnd: int, event_name: str, circuit_type: str) -> list[dict]: rows = [] try: @@ -147,22 +175,47 @@ def _build_quali_rows(year: int, rnd: int, event_name: str, circuit_type: str) - return rows is_wet, air_temp = _weather_summary(session) - pole_time = None - for q_col in ("Q3", "Q2", "Q1"): - col = results[q_col].dropna() - if not col.empty: - pole_time = col.min() / np.timedelta64(1, "s") - break - + best_by_row = [] for _, r in results.iterrows(): - best = None - q_segment = None + best, q_segment = None, None for q_col in ("Q3", "Q2", "Q1"): t = r.get(q_col) if pd.notna(t): best = t / np.timedelta64(1, "s") q_segment = q_col break + best_by_row.append((best, q_segment)) + + # A classified row with no Q time is the Miami 2025 gap: fall back to the + # driver's fastest session lap, and say so in the row and the log. + needs_laps = [i for i, (_, r) in enumerate(results.iterrows()) + if best_by_row[i][0] is None and pd.notna(r.get("Position"))] + if needs_laps: + lap_times = _fastest_session_laps(session) + filled = [] + for i in needs_laps: + abbr = str(results.iloc[i].get("Abbreviation", "")) + if abbr in lap_times: + best_by_row[i] = (lap_times[abbr], LAPS_FALLBACK_SEGMENT) + filled.append(abbr) + if filled: + log.warning(" %d Round %d quali: no Q1/Q2/Q3 time for %s -- best_lap_s taken from " + "the fastest session lap (q_segment=%r)", year, rnd, ", ".join(filled), + LAPS_FALLBACK_SEGMENT) + + # Pole is the official one wherever a Q column has times, exactly as + # before; lap-derived times set it only when the session has none at all. + pole_time = None + for q_col in ("Q3", "Q2", "Q1"): + col = results[q_col].dropna() + if not col.empty: + pole_time = col.min() / np.timedelta64(1, "s") + break + timed = [b for b, _ in best_by_row if b is not None] + if pole_time is None and timed: + pole_time = min(timed) + + for (_, r), (best, q_segment) in zip(results.iterrows(), best_by_row): rows.append({ "season": year, "round": rnd, "event_name": event_name, "circuit_type": circuit_type, "session_type": "Q", diff --git a/src/model/overtaking.py b/src/model/overtaking.py index db416db..151413f 100644 --- a/src/model/overtaking.py +++ b/src/model/overtaking.py @@ -179,15 +179,22 @@ def fit_circuit(rows: pd.DataFrame, event_name: str, hand_rating: float | None) def build_overtaking_data(driver_rounds: pd.DataFrame, circuits: pd.DataFrame | None = None, before_season: int | None = None, - before_round: int | None = None) -> OvertakingData: + before_round: int | None = None, + event_name: str | None = None) -> OvertakingData: """Fit every circuit with enough history in the window. `before_season`/`before_round` cut the window to rounds strictly *before* that round, which is what keeps a backtest honest: the round being forecast must not be in its own overtaking fit. + + `event_name` restricts the fit to that one circuit. A forecast consumes + only its own circuit's parameter, so fitting the rest of the calendar on + every call made a backtest pay rounds x circuits fits (slice-7). """ ratings = hand_ratings(circuits) df = _usable_race_rows(driver_rounds) + if event_name is not None and not df.empty: + df = df[df["event_name"].astype(str) == str(event_name)] if before_season is not None: cutoff = (before_season, before_round if before_round is not None else 0) keep = [(s, r) < cutoff for s, r in zip(df["season"], df["round"])] diff --git a/tests/test_collect_quali_fallback.py b/tests/test_collect_quali_fallback.py new file mode 100644 index 0000000..cdf4732 --- /dev/null +++ b/tests/test_collect_quali_fallback.py @@ -0,0 +1,131 @@ +"""`collect_v2` quali rows when the results carry positions but no Q1/Q2/Q3 +times -- the 2025 R6 Miami gap, repaired by hand for the Phase 2 backtest +(docs/phase2-status.md, "Data"). Autopilot slice-7. + +FastF1 is mocked throughout: no network, no cache. +""" + +import logging + +import numpy as np +import pandas as pd +import pytest + +import collect_v2 +from collect_v2 import LAPS_FALLBACK_SEGMENT, _build_quali_rows + + +def _td(seconds): + return pd.Timedelta(seconds=seconds) if seconds is not None else pd.NaT + + +class _StubQuali: + def __init__(self, results, laps): + self.results = results + self.laps = laps + self.weather_data = pd.DataFrame({"AirTemp": [25.0], "Rainfall": [False]}) + self.event, self.name = "Miami Grand Prix", "Qualifying" + + def load(self, **kwargs): + pass + + +def _results(q_times): + """q_times: abbreviation -> (Q1, Q2, Q3) in seconds or None, in position order.""" + rows = [] + for pos, (abbr, (q1, q2, q3)) in enumerate(q_times.items(), start=1): + rows.append({"Abbreviation": abbr, "FullName": abbr, "TeamName": "T", + "Position": float(pos), "Q1": _td(q1), "Q2": _td(q2), "Q3": _td(q3)}) + return pd.DataFrame(rows) + + +def _laps(rows): + """rows: (driver, lap_time_s, deleted).""" + return pd.DataFrame([{"Driver": d, "LapTime": _td(t), "Deleted": deleted} + for d, t, deleted in rows]) + + +MIAMI_LAPS = _laps([ + ("NOR", 86.400, False), ("NOR", 86.269, False), + ("PIA", 86.271, False), ("PIA", 85.900, True), # struck off: must not count + ("ANT", 86.271, False), ("ANT", None, False), +]) + + +def _rows(monkeypatch, results, laps): + monkeypatch.setattr(collect_v2.fastf1, "get_session", lambda *a: _StubQuali(results, laps)) + return {r["abbreviation"]: r for r in _build_quali_rows(2025, 6, "Miami Grand Prix", "mixed")} + + +class TestNoQTimesFallsBackToSessionLaps: + RESULTS = _results({"NOR": (None, None, None), "PIA": (None, None, None), + "ANT": (None, None, None)}) + + def test_best_lap_is_the_fastest_non_deleted_session_lap(self, monkeypatch): + rows = _rows(monkeypatch, self.RESULTS, MIAMI_LAPS) + assert rows["NOR"]["best_lap_s"] == pytest.approx(86.269) + assert rows["PIA"]["best_lap_s"] == pytest.approx(86.271) + assert rows["ANT"]["best_lap_s"] == pytest.approx(86.271) + + def test_fallback_rows_are_flagged_and_classified(self, monkeypatch): + rows = _rows(monkeypatch, self.RESULTS, MIAMI_LAPS) + assert {r["q_segment"] for r in rows.values()} == {LAPS_FALLBACK_SEGMENT} + assert all(r["classified"] for r in rows.values()) + + def test_gaps_are_to_the_fastest_lap_derived_time(self, monkeypatch): + rows = _rows(monkeypatch, self.RESULTS, MIAMI_LAPS) + assert rows["NOR"]["gap_to_best_s"] == pytest.approx(0.0) + assert rows["PIA"]["gap_to_best_s"] == pytest.approx(0.002) + + def test_positions_are_kept_from_the_results(self, monkeypatch): + rows = _rows(monkeypatch, self.RESULTS, MIAMI_LAPS) + assert [rows[d]["finish_position"] for d in ("NOR", "PIA", "ANT")] == [1.0, 2.0, 3.0] + + def test_the_fallback_is_logged_with_the_drivers(self, monkeypatch, caplog): + with caplog.at_level(logging.WARNING, logger=collect_v2.log.name): + _rows(monkeypatch, self.RESULTS, MIAMI_LAPS) + assert any("fastest session lap" in m and "NOR" in m for m in caplog.messages) + + def test_a_driver_with_only_a_deleted_lap_stays_untimed(self, monkeypatch): + laps = _laps([("NOR", 86.3, False), ("PIA", 85.9, True), ("ANT", 86.4, False)]) + rows = _rows(monkeypatch, self.RESULTS, laps) + assert rows["PIA"]["best_lap_s"] is None + assert rows["PIA"]["q_segment"] is None and not rows["PIA"]["classified"] + + +class TestTimedSessionsAreUnchanged: + def test_official_times_win_and_laps_are_not_read(self, monkeypatch): + results = _results({"VER": (87.0, 86.5, 86.1), "NOR": (87.1, 86.6, 86.2)}) + + class NoLaps(_StubQuali): + @property + def laps(self): + raise AssertionError("laps read for a session with Q times") + + @laps.setter + def laps(self, value): + pass + + monkeypatch.setattr(collect_v2.fastf1, "get_session", lambda *a: NoLaps(results, None)) + rows = {r["abbreviation"]: r for r in _build_quali_rows(2024, 1, "X", "mixed")} + assert rows["VER"]["best_lap_s"] == pytest.approx(86.1) + assert rows["NOR"]["q_segment"] == "Q3" + assert rows["NOR"]["gap_to_best_s"] == pytest.approx(0.1) + + def test_partial_gap_fills_only_the_missing_row_and_keeps_official_pole(self, monkeypatch): + results = _results({"VER": (87.0, 86.5, 86.1), "NOR": (None, None, None)}) + laps = _laps([("VER", 85.0, False), ("NOR", 86.3, False)]) + rows = _rows(monkeypatch, results, laps) + assert rows["VER"]["best_lap_s"] == pytest.approx(86.1) + assert rows["VER"]["q_segment"] == "Q3" + assert rows["NOR"]["q_segment"] == LAPS_FALLBACK_SEGMENT + assert rows["NOR"]["gap_to_best_s"] == pytest.approx(0.2) + + def test_an_unclassified_row_without_times_is_left_alone(self, monkeypatch): + results = _results({"VER": (87.0, 86.5, 86.1)}) + results = pd.concat([results, pd.DataFrame([{ + "Abbreviation": "SAR", "FullName": "SAR", "TeamName": "T", "Position": np.nan, + "Q1": pd.NaT, "Q2": pd.NaT, "Q3": pd.NaT}])], ignore_index=True) + laps = _laps([("SAR", 88.0, False)]) + rows = _rows(monkeypatch, results, laps) + assert rows["SAR"]["best_lap_s"] is None and rows["SAR"]["q_segment"] is None diff --git a/tests/test_overtaking_wiring.py b/tests/test_overtaking_wiring.py new file mode 100644 index 0000000..fd97d0c --- /dev/null +++ b/tests/test_overtaking_wiring.py @@ -0,0 +1,162 @@ +"""The fitted overtaking parameter reaches both production forecasts, and each +forecast fits only its own circuit (autopilot slice-7, slice-4 carry-forwards). + +PR #10's review found that `build_overtaking_data` could be deleted from +either call site (`cli.forecast_weekend`, `backtest.forecast_race_challenger`) +with the whole suite green: the tests handed the fit in at the `_circuit_info` +seam, so nothing checked that a forecast builds one for itself. The fixture +below makes the fit depart from the hand rating, so a forecast that silently +falls back to `data/circuits.csv` is caught. +""" + +import numpy as np +import pandas as pd +import pytest + +from cli import forecast_weekend +from model.overtaking import build_overtaking_data +from test_cli import DRIVERS, _fake_fit, _sprint_weekend_rounds + +HAND_RATING = 4.5 +HISTORY_EVENTS = ["Bahrain Grand Prix", "Monaco Grand Prix", "Italian Grand Prix", + "Japanese Grand Prix", "Brazilian Grand Prix"] +CIRCUITS = pd.DataFrame({ + "event_name": HISTORY_EVENTS, "circuit_type": ["mixed"] * len(HISTORY_EVENTS), + "overtaking_difficulty": [HAND_RATING] * len(HISTORY_EVENTS), +}) + + +def _locked_history(events=HISTORY_EVENTS, rounds_per_event=6, n_drivers=8): + """2023 races where nobody passes: the fastest car starts last and + finishes last. Only a very low difficulty explains that, so every + circuit's fit lands well below the 4.5 hand rating.""" + rows = [] + rnd = 0 + for event in events: + for _ in range(rounds_per_event): + rnd += 1 + for i in range(n_drivers): + grid = float(n_drivers - i) + rows.append(dict( + season=2023, round=rnd, event_name=event, circuit_type="mixed", + session_type="R", abbreviation=f"H{i}", team="Alpha" if i % 2 else "Beta", + grid_position=grid, finish_position=grid, classified=True, + status="Finished", dnf_cause=None, + gap_to_winner_median_clean_air_s=1.0 * i, is_wet=False, + )) + return pd.DataFrame(rows) + + +def _driver_rounds(): + return pd.concat([_locked_history(), _sprint_weekend_rounds()], ignore_index=True) + + +def _fitted_bahrain(driver_rounds): + fit = build_overtaking_data(driver_rounds, CIRCUITS, before_season=2024, + before_round=2).fit_for("Bahrain Grand Prix") + assert fit is not None + # The fixture is only useful if the fit really departs from the rating. + assert abs(fit.shrunk_difficulty - HAND_RATING) > 1.0 + return fit.shrunk_difficulty + + +def _count_fits(monkeypatch): + import model.overtaking + + fitted = [] + real = model.overtaking.fit_circuit + + def counting(rows, event_name, hand_rating): + fitted.append(event_name) + return real(rows, event_name, hand_rating) + + monkeypatch.setattr(model.overtaking, "fit_circuit", counting) + return fitted + + +def _run_forecast_weekend(monkeypatch, driver_rounds): + import sim.race + from model.reliability import build_reliability_data + + captured = {} + real = sim.race.simulate_weekend + + def spy(inputs, *a, **kw): + captured["inputs"] = inputs + return real(inputs, *a, **kw) + + monkeypatch.setattr(sim.race, "simulate_weekend", spy) + idata, data = _fake_fit(driver_rounds) + forecast_weekend(idata, data, build_reliability_data(driver_rounds), driver_rounds, + CIRCUITS, season=2024, round_=2, n_trials=200, seed=0) + return captured["inputs"] + + +def _run_challenger(monkeypatch, driver_rounds): + import model.challenger + import sim.race + from backtest import forecast_race_challenger + from model.reliability import build_reliability_data + + captured = {} + real = sim.race.simulate_positions + + def spy(inputs, *a, **kw): + captured["inputs"] = inputs + return real(inputs, *a, **kw) + + def fake_pace(model, team, driver, circuit_type, trp, drp, n, rng): + return rng.normal(DRIVERS.index(driver) * 0.3, 0.1, n) + + monkeypatch.setattr(sim.race, "simulate_positions", spy) + monkeypatch.setattr(model.challenger, "sample_pace", fake_pace) + forecast_race_challenger(None, build_reliability_data(driver_rounds), driver_rounds, + CIRCUITS, season=2024, round_=2, n_trials=200, seed=0) + return captured["inputs"] + + +RUNNERS = {"forecast_weekend": _run_forecast_weekend, + "forecast_race_challenger": _run_challenger} + + +@pytest.mark.parametrize("call_site", sorted(RUNNERS)) +class TestBothCallSitesFitTheirOwnCircuit: + def test_the_resolver_gets_the_fitted_value_not_the_hand_rating(self, monkeypatch, call_site): + driver_rounds = _driver_rounds() + inputs = RUNNERS[call_site](monkeypatch, driver_rounds) + assert inputs.overtaking_difficulty == pytest.approx(_fitted_bahrain(driver_rounds)) + assert inputs.overtaking_difficulty != pytest.approx(HAND_RATING) + + def test_one_circuit_is_fitted_however_many_the_window_holds(self, monkeypatch, call_site): + """Five circuits in the window, one fit per forecast. Before slice-7 + this counted every circuit with history (five here).""" + driver_rounds = _driver_rounds() + fitted = _count_fits(monkeypatch) + RUNNERS[call_site](monkeypatch, driver_rounds) + assert fitted == ["Bahrain Grand Prix"] + + def test_the_fit_count_does_not_grow_with_the_window(self, monkeypatch, call_site): + small = pd.concat([_locked_history(HISTORY_EVENTS[:1]), _sprint_weekend_rounds()], + ignore_index=True) + counts = [] + for driver_rounds in (small, _driver_rounds()): + fitted = _count_fits(monkeypatch) + RUNNERS[call_site](monkeypatch, driver_rounds) + counts.append(len(fitted)) + assert counts == [1, 1] + + +class TestSingleCircuitFit: + def test_restricting_to_one_event_matches_that_event_in_the_full_fit(self): + driver_rounds = _locked_history() + full = build_overtaking_data(driver_rounds, CIRCUITS) + one = build_overtaking_data(driver_rounds, CIRCUITS, event_name="Monaco Grand Prix") + assert set(one.by_circuit) == {"Monaco Grand Prix"} + assert one.fit_for("Monaco Grand Prix") == full.fit_for("Monaco Grand Prix") + + def test_an_event_with_no_history_gets_no_fit(self): + one = build_overtaking_data(_locked_history(), CIRCUITS, event_name="Las Vegas Grand Prix") + assert one.by_circuit == {} and one.skipped == {} + + def test_no_event_still_fits_the_whole_window(self): + assert len(build_overtaking_data(_locked_history(), CIRCUITS).by_circuit) == len(HISTORY_EVENTS) From 3bd3b22aee10ff7b2d56da407f63430d1b3ee14b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 18:00:42 +0000 Subject: [PATCH 2/2] queue: slice-7 in-review (PR #20) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EiJgo5GgDN14Q7LndX14Qv --- docs/autopilot/queue.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/autopilot/queue.md b/docs/autopilot/queue.md index f830d81..1bb4297 100644 --- a/docs/autopilot/queue.md +++ b/docs/autopilot/queue.md @@ -337,7 +337,7 @@ includes `--grid-from-quali` and the post-quali backtest arm. ## slice-7: Phase 2 carry-forwards -Status: in-progress +Status: in-review (PR #20) Spec: `docs/phase2-status.md` "What is still open"; slice-4 notes above.