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_*.json files — they still carry converged at 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 the reprice_lag_min field and never written back to lag_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 because reprice_lag_min is 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 a CONVERGE_HORIZON_MIN constant), 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 scriptsmeasure_shard_N.py (one per shard 0–5). Each pulls CLOB price history for ~4,807 cheap entries and computes, per entry, the forward reprice_lag_min (time until the book reprices to 0.97) and a converged boolean.
  • Aggregatoraggregate_lag.py reads all measured_shard_*.json, pools them, and writes lag_distribution.json (the persisted artifact the report cites as its source).
  • Inputsshard_N.json (input entries), entries.json (8.7M, the 28,839-entry build from build_entries.py).

Gotcha — there is NO lib.py in this directory

The task that surfaced this referenced a shared lib.py. It does not exist. The CLOB-fetch + lag logic is inlined into each measure_shard_N.py — and they are not even uniform copies (different scripts bake converged with different expressions: lag <= 120.0 in shard_1, lag is not None and lag <= 120 in shard_3, a separate reprice_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.json showed stale 120-min numbers — a latent inconsistency between the published report and the persisted artifacts.
  • Every row with reprice_lag_min in (120, 180] has converged = False in all 5 existing measured_shard_*.json files (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  <-- MISMATCH

Confirmed in the shipped scripts (still stale at the time of this finding):

scriptlinebaked horizon
measure_shard_1.py105lag is not None and lag <= 120.0
measure_shard_3.py96conv = lag is not None and lag <= 120
measure_shard_4.py110reprice_lag(hist, entry_ts, 0.97, 120)
measure_shard_0.py / measure_shard.pyconv 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:

metricpersisted (120-min, stale)report published (180-min)
convergence rate0.44920.511
conditional reach@15 (P(lag>15|converged))0.63310.677
converged median lag (min)29.5637.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 <= 180

Re-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:

metricre-derived from existing shards
convergence rate0.5109
conditional reach@150.6774
converged median lag37.03 min
p25 / p75 / p908.37 / 81.99 / 127.26
unconditional reach@150.3461
entries gained 120 → 180exactly 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 61

Why this is the right fix

The aggregator is now authoritative on the horizon and immune to stale baked converged flags 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 from reprice_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-city n and 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.