A data-pipeline integrity finding in the repricing-lag distribution study: the converged boolean was baked at a 120-min horizon in every measured shard, while the report published 180-min numbers — and those 180-min numbers were never persisted to the on-disk aggregate. The 180-min convergence is fully recoverable from the existing shards (no CLOB re-pull needed), and the fix makes the aggregator authoritative on the horizon. Pipeline lives at research/copytrade/top50/lag/; report at research/reports/weather-lag-distribution-2026-06-19.md.
For Agents
This is the data-integrity backstory behind the weather-top50-decypher-2026-06-19 report’s R3 (“120/180 horizon mismatch — fixed”). Three load-bearing facts: (1) the fix R3 claims was applied was NOT baked into the 5 shipped
measured_shard_*.jsonfiles — they still carryconvergedat 120 min. (2) The report’s headline 180-min numbers (convergence 0.511, conditional reach@15 0.677, median lag 37.03) were re-derived at analysis time from thereprice_lag_minfield and never written back tolag_distribution.json, which still showed the stale 120-min numbers (0.4492 / 0.6331 / 29.56). (3) The 180-min answer is recoverable without re-pulling CLOB data becausereprice_lag_minis already a clean 180-min measurement in every shard —converged@180 == (reprice_lag_min is not None). The fix moves convergence computation into the aggregator (is_converged()against aCONVERGE_HORIZON_MINconstant), making it immune to whatever horizon any individual shard baked. This is a research/analytics finding, not production code.
The pipeline
A 6-shard reprice-lag measurement under research/copytrade/top50/lag/:
- Per-shard measure scripts —
measure_shard_N.py(one per shard 0–5). Each pulls CLOB price history for ~4,807 cheap entries and computes, per entry, the forwardreprice_lag_min(time until the book reprices to 0.97) and aconvergedboolean. - Aggregator —
aggregate_lag.pyreads allmeasured_shard_*.json, pools them, and writeslag_distribution.json(the persisted artifact the report cites as its source). - Inputs —
shard_N.json(input entries),entries.json(8.7M, the 28,839-entry build frombuild_entries.py).
Gotcha — there is NO
lib.pyin this directoryThe task that surfaced this referenced a shared
lib.py. It does not exist. The CLOB-fetch + lag logic is inlined into eachmeasure_shard_N.py— and they are not even uniform copies (different scripts bakeconvergedwith different expressions:lag <= 120.0in shard_1,lag is not None and lag <= 120in shard_3, a separatereprice_lag(..., 0.97, 120)call in shard_4). Because the logic is duplicated per shard, the convergence horizon drifted independently in each — which is exactly how the 120/180 split happened and why centralizing it in the aggregator is the DRY fix.
Symptoms
- The report (
weather-lag-distribution-2026-06-19.md) publishes 180-min convergence numbers and says R3 was “fixed.” - But the on-disk
lag_distribution.jsonshowed stale 120-min numbers — a latent inconsistency between the published report and the persisted artifacts. - Every row with
reprice_lag_minin(120, 180]hasconverged = Falsein all 5 existingmeasured_shard_*.jsonfiles (0, 1, 3, 4, 5). Empirically proven across all five.
Root cause — three layered bugs
1. Convergence horizon baked at 120, not 180
In the measure scripts, reprice_lag_min is computed at a 180-min window but converged is computed at 120 min — the exact mismatch:
reprice_lag_min = first_at_or_after(..., 180) # 180-min measurement
converged = first_at_or_after(..., 120) is not None # 120-min flag <-- MISMATCHConfirmed in the shipped scripts (still stale at the time of this finding):
| script | line | baked horizon |
|---|---|---|
measure_shard_1.py | 105 | lag is not None and lag <= 120.0 |
measure_shard_3.py | 96 | conv = lag is not None and lag <= 120 |
measure_shard_4.py | 110 | reprice_lag(hist, entry_ts, 0.97, 120) |
measure_shard_0.py / measure_shard.py | — | conv computed upstream at 120 |
Every one of the 5 measured outputs (0, 1, 3, 4, 5) therefore carries converged truncated at 120 min.
2. The report’s numbers were never persisted
The old aggregate_lag.py (old line 53) read converged directly from the measured files with no horizon re-application:
converged = [e for e in measured if e["converged"] and e["reprice_lag_min"] is not None]So lag_distribution.json on disk reflected the stale 120-min flags:
| metric | persisted (120-min, stale) | report published (180-min) |
|---|---|---|
| convergence rate | 0.4492 | 0.511 |
conditional reach@15 (P(lag>15|converged)) | 0.6331 | 0.677 |
| converged median lag (min) | 29.56 | 37.03 |
The 180-min numbers were generated by re-deriving converged at 180 from reprice_lag_min at analysis time — and never written back to lag_distribution.json. The report and the artifact disagreed.
3. The 180-min answer is fully recoverable — no CLOB re-pull
The key insight: reprice_lag_min is already a clean 180-min measurement in every shard (it was always measured at 180). So 180-min convergence is just:
converged_at_180 = (reprice_lag_min is not None) # i.e. lag <= 180Re-deriving from the 5 existing shards exactly reproduced the report’s numbers — proving the report’s analysis-time derivation was correct and that no re-fetch is required:
| metric | re-derived from existing shards |
|---|---|
| convergence rate | 0.5109 |
| conditional reach@15 | 0.6774 |
| converged median lag | 37.03 min |
| p25 / p75 / p90 | 8.37 / 81.99 / 127.26 |
| unconditional reach@15 | 0.3461 |
| entries gained 120 → 180 | exactly 1,475 |
The fix
Two changes, the second being the load-bearing, DRY one:
(a) measure_shard_2.py — converged threshold changed 120.0 -> 180.0 (so the one re-run shard bakes the coherent horizon):
row["converged"] = lag is not None and lag <= 180.0 # measure_shard_2.py:113(b) aggregate_lag.py — convergence is now computed at aggregation time against a single constant, replacing the direct read of the baked flag:
CONVERGE_HORIZON_MIN = 180.0 # aggregate_lag.py:51
def is_converged(e): # lines 54–56
lag = e.get("reprice_lag_min")
return lag is not None and lag <= CONVERGE_HORIZON_MIN
converged = [e for e in measured if is_converged(e)] # line 61Why this is the right fix
The aggregator is now authoritative on the horizon and immune to stale baked
convergedflags in any shard — even the 5 shards still baked at 120 min produce correct 180-min numbers once pooled, because the aggregator re-derives convergence fromreprice_lag_min. This is the DRY fix (one horizon constant instead of N drifting per-shard expressions) and it matches exactly how the report’s numbers were actually derived. Changing the horizon later is now a one-line constant edit, not a 6-script re-bake.
Population context
- Each shard holds ~4,807 entries.
shard_2(16.7% of the full population) was missing its measured output — and the loss is not random by city: it concentrates in headline cities (london 510, nyc 443, seoul 292, hong-kong 278, atlanta 169, paris 164). This is the report’s R5 and the reason every per-citynand the city ranking are flagged provisional pending a shard_2 re-run.- The build produced 28,839 entries; 23,907 were successfully measured (
pull_failed=false) across the 5 shards.
Related
- weather-top50-decypher-2026-06-19 — the v2 decypher whose Section 2 / R3 this finding is the data-integrity backstory for; the repricing-lag layer is its candidate fillable edge
- weather-strategy-validated-plan — the GO plan (late-settlement / repricing-lag) the lag study informs
- wx-market-prices-history — the CLOB
/prices-historyclient pattern the inlined per-shard fetch mirrors - weather-bet-live-status — live
wx-signalengine + the dry-rehearsal gate the lag distribution feeds - weather-bet — project overview