Architecture review — tif1

2026-08-08
module seam leakage deep module strong worth exploring speculative

The package has no CONTEXT.md and no ADRs; domain language is taken from AGENTS.md and project-architecture.md. Candidates are deepenings — refactors that turn shallow modules into deep ones.

1. Collapse the session pipeline's three fetch stacks

Strong mock
cdn.py:134-163 · async_fetch.py:341-639, 496-599 · core.py:1814-1826, 1970-2006, 2008-2052 · events.py:21-23, 161-187 · http_session.py:28-35, 167-186
Before — retry & fallback written 3×, plus a foreign grammar
                flowchart LR
                  C1[Session self._fetch_from_cdn] --> T1[cdn.try_sources loop]
                  C2[Session self._fetch_from_cdn_fast] --> T1
                  C3[Session._fetch_one prefetch] --> T3[own per-source fallback, swallows errors]
                  A[fetch_json_async] --> T2[own retry loop + own CDN fallback]
                  A -. own lazy niquests import + own key format .-> X[(cache & session)]
                  T1 --> D[CDN sources]
                  T2 --> D
                  T3 --> D
                  E[events.py schedule fetch] --> T4[hardcoded jsDelivr URL for a foreign repo]
                  T4 --> D
                  classDef leak stroke:#dc2626,stroke-width:2px;
                  class T2,T3,T4 leak
              

After — one engine, adapters at the seam

Laps / telemetry / weather / race control / schedule

Session pipeline fetch engine

retry · circuit breaker · CDN fallback · cache read/write · key format

sync adapter
asyncio.run
async engine
native
schedule adapter
vendored file
test adapter
stubs today

Problem

Retry, circuit-breaking, CDN fallback and cache-key knowledge is copied into four modules — async_fetch, cdn (used by core only), core's prefetch, and events' schedule fetch, which appends a fourth, foreign URL grammar.

Solution

Treat the async fetch as the one fetch engine behind a single seam; sync callers, prefetch, and the schedule fetch become adapters on it, and fallback, breaker and key format migrate behind the engine's interface.

  • locality: one place to patch a retry bug, not four
  • events.py stops importing private http symbols
  • tests already stub one seam today
  • cache-key drift cured by construction
  • cdn.try_sources finds its only caller

2. Splice the sync/async twins in core

Strong in-process
core.py: 3026-3076 ≡ 3234-3284 · 3723-3768 ≡ 3811-3856 (verbatim dead copies) · 3078/3286 · 3631/3677 · 3770/3858 · 3928/3964 · 4000/4033 · 4435/4481 · 4509/4556 · 4834/4860 · 5005/5143 · 5331/5380 · 5452/5544 · 575-603 ≡ 5976-6003 (identical telemetry fetch)

Before — 11 twin pairs, 2 dead copies

_fetch_laptime_payloads= _…_async51 extra lines ×2
_get_fastest_laps_from_raw= _…_async
_find_fastest_lap_reference…= _…_async
_process_laptime_payloaddefined twice — first copy dead
_process_fastest_lap_refs…defined twice — first copy dead
Lap._fetch_telemetry ≡ _LapInternal._fetch_telemetryidentical bodies
… plus 6 more pairs (batch, tels, fastest-lap)

After — one engine, sync is a thin adapter

caller
sync or async
lap & telemetry engine
one coroutine per concern
sync entry = asyncio.run(…)

Property tests (test_sync_async_equivalence.py) already pin sync ≡ async — they stay green throughout.

Problem

Every concern in the lap and telemetry engines exists twice — sync and async — and two pairs are verbatim duplicates whose first copies are dead code; defects already diverged (e.g. the cache-flag bug at 4517 vs 4579).

Solution

Keep one coroutine-based engine per concern; make sync entry points 1-2 line adapters over it (they already are, e.g. fetch_all_laps_telemetry). Delete the dead first copies outright.

  • bugs can't diverge between twins
  • ~700 duplicated lines collapse
  • tests assert once per behaviour
  • interface unchanged for users

3. Delete the pass-through shims

Worth exploring in-process
session.py (5 lines) · models.py (13) · io_pipeline.py (19) · lap_ops.py (10) · __init__.py:100-115 lazy map

Before — four shallow re-export modules

session.py“from .core import Session”
models.py7 re-exports
io_pipeline.pyre-exports _create_lap_df et al. — privates shipped public
lap_ops.pyre-exports _coerce_*, _get_lap_column — privates shipped public
test suiteone identity assertion per shim

After — the interface lives at the lazy exports

tif1.__getattr__ / core

one alias per public name · zero files · zero tests

Deletion test: complexity vanishes — it was never in the shims. They only add three import paths and two publicly-visible private symbols.

Problem

Four tiny modules contribute zero behaviour: two of them move private core helpers into the public name space, and every one is deleted-and-nothing-happens by the deletion test.

Solution

Keep exactly two compatibility import paths (session.py → Session for fastf1 users, models.py → classes), absorb their names straight into __init__'s lazy map, and drop io_pipeline.py / lap_ops.py entirely.

  • privates stop looking public
  • interface shrinks, callers keep their import
  • one way to reach core, not four

4. One schema vocabulary, not four

Worth exploring in-process
core_utils/constants.py:11-82 · validation.py:255-298, 362-371 · types.py:143-152 · events.py:27-36

Before — the same fact sits in 2-3 modules

raw key → canonical name ×2
sesT→Time dNum→DriverNumber wAT→AirTemp

constants.LAP_RENAME_MAP + validation.LapData aliases

session-type vocabulary ×3
Race Qualifying Sprint

types.SessionType literal · validation.SessionType enum · events._SESSION_TYPES

weather pascal-case map ×2
AirTemp Humidity

constants.WEATHER_RENAME_MAP · validation.WeatherData._normalize_pascalcase_keys

year bounds ×2
MIN_YEAR MAX_YEAR

constants · validation

After — constants owns the dictionary, modules consume it

constants — the one schema sheet

maps · literals · year bounds · session types

validation.py
consumes maps
types.py
string-union alias
events.py
casefold lookup

Change a CDN key once. The DataFrame layer and the validation layer read the same sheet, and it stops drifting.

Problem

The same raw-key→canonical-name facts, session-type vocabulary and year bounds are hand-maintained in two to three modules, so they drift — and validation stays off by default (config flags default false) while the rename maps do the real lifting.

Solution

Make core_utils/constants the single schema dictionary; validation.py builds its pydantic aliases from it, types.py derives its Literals from it, events.py consumes the same session-type table.

  • locality: renames happen in one place
  • validation pays its own stripes
  • one table to migrate when CDN changes

5. The cache module owns its keys

Worth exploring local-substitutable
async_fetch.py:381, 389-391 · core.py:1957, 2060, 4104-4107 · cache.py:427, 263-373 · fastf1_compat.py:82-84, 103, 113, 249-251

Before — key format hand-rolled three times, privates poked

async_fetch.py:381 f"{year}/{gp}/{session}/{path}"
core.py:1957, 2060 same string, re-typed
core.py:4107 third format, laps in-memory
async_fetch calls cache._get_from_memory (private)
fastf1_compat reads cache_module._cache wholesale

After — key builder on the inside of the seam

cache.get(year, gp, session, path)
key built inside · memory + sqlite unified
coreasync_fetchcompat

The key format becomes part of the cache module's interface — callers stop being able to get it wrong.

Problem

The cache key is a serialized-format fact, but it lives in callers — three modules hand-type the same f-string, and two reach past the seam into private state to do their own lookups.

Solution

Extend the cache module's interface with the structured key (or a make-key helper), let async_fetch and core hand it the four pieces, and retire the private _get_from_memory-style pokes.

  • format fixed in exactly one implementation
  • callers lose an entire error-modes category
  • compat shim stops dual-writes

6. Schedule fetch joins the repo's engine

Speculative mock
events.py:21-23, 144-187, 202-206 · schedule_schema.py:10-54 · core_utils/resource_manager.py

Before — a lone fetch outside every seam

events._load_f1schedule_year_from_cdn
hardcoded jsDelivr URL (other repo)
get_http_session().get private-adjacent — no cache, no CDN manager, no breaker
imports http_session._track_request (private)
vendored schedule fallback: f1schedule → resources
validation: schedule_schema.py (its own schema)

After — two adapters at one seam

vendored schedule files

schedule intake

validate → normalize → Event/EventSchedule

CDN fetch adapter
reserved count → cache, breaker, manager

Two adapters today make this a real seam: vendored files in production, CDN as fallback.

Problem

The schedule is the only data that bypasses the pipeline: a foreign URL grammar, no cache, no CDN manager, a scratch retry and a private import from http_session's stats tracker.

Solution

Keep the vendored schedule as the primary adapter, and run the CDN fallback through the same intake interface as everything else — schedule_schema stays the validator, events.py the transformer.

  • one architecture for every fetch
  • private import goes away
  • schedule lookups can be cached

Top recommendation

Start with #2 — splice the sync/async twins

It is pure in-process work behind the existing Session interface: no network semantics change, no seam moves, no schema decisions. It deletes ~700 lines of drift including two dead verbatim copies, and the property suite (test_sync_async_equivalence.py) already pins sync ≡ async at the exact interface in question — so the deepening is verified before and after. Finishing it makes candidate 1 (one fetch engine) a rearrangement instead of a fishing expedition, because the same 11 pairs are the load-bearing walls of every fetch stack.

All six candidates →