Thibaut Forest Claude Opus 5 (1M context) commited on
Commit
c7d5d98
·
1 Parent(s): 9fd6de6

Phase 40: port the research loop's CV discipline + ship in-fleet pooling

Browse files

CV protocols move out of /tmp into batteryswap_example/cv.py, with the
champion config itself promoted from /tmp/all48.py into cv.CHAMPION:

all48 full-fleet walk-forward, three-regime scorecard
static the deployment gate - one foreign-fleet fit, no refits, 11s a
seed, ORDERING questions only (this construction manufactures a
sum(p42) undercount; phase 26 believed it and public went
1661 -> 2189)
screen offline model screen grouped by BATTERY, never by scenario/row

models_cv.rank_stats now runs inside walk_forward, so dev/panel/test also
report rec12/rec20, operating-point recall+precision, the count actually
bought, and the median rank of missed deaths beside the cost. Base
reproduces 1661.7 / 2379.7 / 1679.2 / 604.8 byte-exact - the additions are
diagnostic only.

What the new metrics show: the panel's missed deaths sit at median rank 57,
45 ranks past the cut. That is the closed appetite axis as a measurement
rather than an argument. screen shows scenario grouping inflates rf's
margin over logistic 8x on recall@12 (+0.023 -> +0.194); the effective n is
80 batteries with a death, not 1130 positive rows.

Three feature variants measured on both instruments, all rejected:
vrel +138.7, vcorr +32.7, zone +104.9 on all48. vcorr's +32.7 reproduces
the phase-34 +32.8 to within 0.1. vrel is the discipline working - it won
the static gate by the widest margin of the three (-24.6, W143/L66, 4 of 5
seeds) and won it entirely through the count (116 fewer swaps, 3 more
deaths, rec12/rec20 flat), which is the one reading that protocol is
documented as invalid for.

Latent deployment bug fixed: cost_curves and plan_tsroute hardcoded the
evaluator's unobserved_eol_days as 30.0 while the evaluator reads it
per-scenario - and submit.py imports plan_tsroute, so the hardcode shipped.
All 48 train scenarios use 30.0, which is exactly why no local protocol
could catch it: same shape as the hardcoded 42-day park that
repark_for_window exists to undo.

Also ships in-fleet pooling for held-out splits (HELDOUT_POOL, default on):
the target split's own closed-window pairs are pooled with the foreign 82
and refit on a cadence. Rehearsed end-to-end on the 2x held-out fixture -
79560 rows in 287s, pooling active on both splits at 164 own deaths vs 82
foreign, no budget degradation, no fallback. Degrades to the static foreign
fit (the prior champion) on any overrun, never to the pickle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

EXPERIMENTS.md CHANGED
@@ -35,6 +35,13 @@ All numbers are mean `total_cost` per scenario from the official `evaluate_plan`
35
  spent the test block. Per-phase "dev"/"test" columns below refer to these.
36
  - **Reference points** (test block): shipped example 6779 · defer-everything 2512
37
  · lowest-voltage-15 heuristic 2034 · oracle (true EOL) 186.
 
 
 
 
 
 
 
38
 
39
  ## Phase 0 - understanding the problem
40
 
@@ -1796,3 +1803,224 @@ negatives when it has 800+; the effect does not attenuate with data, it
1796
  separates the two. dq_g20 is intrinsically a cold-start mechanism and cannot be
1797
  made always-on - which, given static_uniq=82, means it cannot be made to matter
1798
  in deployment at all.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  spent the test block. Per-phase "dev"/"test" columns below refer to these.
36
  - **Reference points** (test block): shipped example 6779 · defer-everything 2512
37
  · lowest-voltage-15 heuristic 2034 · oracle (true EOL) 186.
38
+ - **Protocols live in `batteryswap_example/cv.py`** (phase 40), not in `/tmp`:
39
+ `all48` (full-fleet walk-forward, three-regime scorecard), `static` (the
40
+ deployment gate - one foreign-fleet fit, no refits, ORDERING questions only),
41
+ `screen` (offline model screen grouped by BATTERY, never by scenario or row).
42
+ All three report ranking precision at the operating N beside the cost: cost
43
+ alone cannot distinguish a change that reordered the list from one that only
44
+ cut it deeper, and AUC 0.947 has already coexisted with recall 0.000.
45
 
46
  ## Phase 0 - understanding the problem
47
 
 
1803
  separates the two. dq_g20 is intrinsically a cold-start mechanism and cannot be
1804
  made always-on - which, given static_uniq=82, means it cannot be made to matter
1805
  in deployment at all.
1806
+
1807
+ ## Phase 38 - the label process reverse-engineered (2026-08-21)
1808
+
1809
+ User question ("the voltage continues past the EOL line - if the battery fails
1810
+ we should not have voltage anymore") exposed a documented error and, chasing
1811
+ it, the exact rule that generates `eol_times.csv`.
1812
+
1813
+ ### Correction: post-EOL telemetry is the SAME battery, not a replacement
1814
+
1815
+ Phase 16 recorded: "All 82 dead slots keep reporting past EOL (mean 107d, max
1816
+ 305d): **replacement batteries** under the same device_id", marked
1817
+ human-verified. The durations are right (measured: median 85 d, mean 105, max
1818
+ 305). **The interpretation is wrong.** Median voltage tracked against
1819
+ days-since-EOL over all 82:
1820
+
1821
+ | window | -90..-61 | -60..-31 | -30..-8 | -7..0 | +1..14 | +15..30 | +31..60 | +91..300 |
1822
+ |---|---|---|---|---|---|---|---|---|
1823
+ | median V | 2.562 | 2.510 | 2.454 | **2.398** | 2.379 | 2.363 | 2.352 | 2.295 |
1824
+
1825
+ Smooth through the line, no discontinuity, and only **3 of 78** devices ever
1826
+ reach fresh-battery level (>2.9 V) afterwards. Phase 16's own named example
1827
+ `d_57e99ff00802` reports to 2026-05-13 as claimed and never exceeds 2.559 V.
1828
+
1829
+ Genuine swaps DO exist and are unmistakable - 34 censored devices jump to
1830
+ >=2.95 V and hold (caveat 1 verified, ~30 claimed). They just never occur at
1831
+ recorded EOLs. **Consequence**: a feature window spanning a recorded EOL does
1832
+ not mix two batteries; segmenting history at recorded EOLs is unnecessary. The
1833
+ only genuine two-battery contamination is those 34 censored resets.
1834
+
1835
+ ### The rule: gated 10-30 C, threshold 2.40 V, sustained crossing
1836
+
1837
+ `smooth_series`'s 10-30 C band is the ORGANISERS' default
1838
+ (batteryswap_public/utils.py:42) but is never called by the evaluator - we
1839
+ reimplemented it and inherited the constants. Never swept until now. Which
1840
+ (band, threshold) best reproduces the recorded EOL dates:
1841
+
1842
+ | band | median lag at 2.40 | coverage |
1843
+ |---|---|---|
1844
+ | 0-50 | 6.0 d | 100% |
1845
+ | 5-35 | 6.0 d | 100% |
1846
+ | 8-32 | 4.0 d | 100% |
1847
+ | **10-30** | **3.0 d** | **100%** |
1848
+ | 12-28 | 3.0 d | 98% |
1849
+ | 15-25 | 5.5 d | 88% |
1850
+
1851
+ 2.42 is far worse everywhere (10-23 d); 2.38 loses coverage. **(10-30, 2.40)
1852
+ is the unique pair with 100% coverage at minimum lag.** The gate and the
1853
+ threshold are one definition, not two choices that happen to agree - a voltage
1854
+ reading is meaningless without a temperature. Independently, 10-30 is also the
1855
+ tightest band retaining all 82 deaths (12-28 drops to 77, 15-25 to 70) and
1856
+ peaks voltage AUC at 0.9604.
1857
+
1858
+ **EOL marks the SUSTAINED crossing.** Median lag 3 d (IQR 3-6), but 12 of 81
1859
+ devices lag >21 d, max 163. Those are not measurement gaps: during the lag they
1860
+ have a median of 23 gated readings and **83% go back above 2.40** (mean 57% of
1861
+ readings above). They wobble. The lag is NOT predictable from origin-time
1862
+ features: RF leave-out Spearman **+0.006** (p=0.95), R2 -0.193, and flagging
1863
+ the tail hits base rate exactly. Volatility runs the wrong way (vstd_30 rho
1864
+ -0.194). Characterisation, not a lever.
1865
+
1866
+ **No calendar structure**: EOL day-of-week is flat (weekend share 26% vs 29%
1867
+ chance), so no human/administrative recording process; month is confounded with
1868
+ fleet aging by window exposure; 68 distinct dates for 82 deaths is random
1869
+ collision. Calendar features rejected as a memorisation trap - fleet-history
1870
+ facts cannot transfer to splits at other dates.
1871
+
1872
+ **Consequence for relabelling (user-asked): do not.** The evaluator scores
1873
+ against `eol_times.csv` verbatim, the label is tight (100% within 7 d of a
1874
+ sub-2.40 gated reading, median 0), and the nearest relabelling - adding the ~26
1875
+ ghost deaths - was measured harmful at six thresholds in phase 37. Residual
1876
+ error is entirely trajectory-forecast error with no label-process component,
1877
+ which retro-explains phase 17's label-arrival-hazard rejection: arrival is
1878
+ near-deterministic, so there was no hazard to model.
1879
+
1880
+ Artifacts: `notebooks/all_batteries.ipynb` (all 461 devices, categorised
1881
+ DEAD-SILENT 18 / DEAD-VISIBLE 64 / RESET 34 / GHOST 17 / ADMIN-GONE 67 /
1882
+ HEALTHY 261), `notebooks/silent_deaths.ipynb`.
1883
+
1884
+ ## Phase 39 - four hypotheses closed, four measurement laws (2026-08-21)
1885
+
1886
+ Five parallel sessions. Every line closed negative; the durable output is the
1887
+ laws.
1888
+
1889
+ | hypothesis | verdict |
1890
+ |---|---|
1891
+ | install-batch death clock | CLOSED on 3 implementations. The famous "AUC 0.91 on silent pairs" rests on **4 positives** (phase 10 already knew: all-covered 0.584, dark bucket 0.426-0.410 - worse than chance). Coverage was never the blocker: 83-95% monthly at late origins. Gated rank-only form measured **+97.9 harm**. |
1892
+ | `temp_mean` (30 d, pre-filter) | Mechanism replicates (deaths 18.67 C vs survivors 20.18) but **absorbed**: -1.1pp recall@12 on production. Fifth temperature rejection; the beta correction already occupies the channel. Gains +6.4pp on a champion with no temperature handling. |
1893
+ | battery-type clustering | Types are real (13x risk spread surviving level-removal) but miss rate is FLAT across types. Cannot buy recall. |
1894
+ | dv42 voltage-drop regression | CLOSED on 4 implementations. **Out-of-time R2 is negative everywhere** (-0.117 / -0.029 / -0.172): the drop cannot be forecast. The score was a differently-parameterised linear classifier anchored on voltage - ties the incumbent at N=12. Censoring-aware variant exactly inert (3% of rows); quantile variant worst on a full panel. Phase 10's wall from the other side. |
1895
+
1896
+ ### The four measurement laws
1897
+
1898
+ All the same shape: **pick the denominator that matches the question.**
1899
+
1900
+ 1. **Report recall at the operating N, not AUC.** With ~400 negatives/origin,
1901
+ AUC 0.93-0.95 says nothing about a rule taking 12. Silent deaths measured
1902
+ AUC 0.947 ("p42's strongest region") and recall@12 **0.000 of 24**, median
1903
+ rank 60. Same fact, one currency.
1904
+ 2. **A candidate must beat the incumbent ranker ON ORDERING before any
1905
+ architecture can make blending it safe.** "Gated + rank-only" excludes
1906
+ calibration and count damage but NOT ordering damage: reordering a good list
1907
+ with a chance-level signal costs in proportion to the weight (+97.9 at
1908
+ w=0.05). No safe non-zero weight exists for a ~0.5-AUC feature.
1909
+ 3. **Condition on the outcome, not row shares.** Any partition (building,
1910
+ cohort, type, room) looks significant against row shares because missed
1911
+ deaths concentrate where deaths are. chi2 13.00 -> 2.67 on conditioning.
1912
+ 4. **Always report the test panel.** Requiring a recent in-band reading on test
1913
+ rows deleted 18% of deaths and swung v_now recall@12 from **0.800 to 0.382**
1914
+ - larger than every modelling effect measured that day, and it reversed the
1915
+ ranking of every arm. A recency filter deletes the silent population by
1916
+ construction.
1917
+
1918
+ ### Caution on the blind-harness champion lineage
1919
+
1920
+ Two sessions independently found raw current voltage BEATS their champion's own
1921
+ ranker at the operating N (.721 vs .668; .703 vs .672). On production the
1922
+ reverse holds (.572 vs .429, +14.3pp). That lineage was ablated on COST under a
1923
+ threshold rule where every feature change moves depth as well as order, so a
1924
+ 4-feature set losing to `last_voltage` on ordering is more likely over-pruned
1925
+ than optimal. **Do not port anything from it without re-running the ablation on
1926
+ recall at fixed N.**
1927
+
1928
+ ## Phase 40 - the research loop's CV discipline, ported into this repo
1929
+
1930
+ Every prior phase re-derived its harness in `/tmp` and lost it. The three
1931
+ measurement disciplines the blind loop paid leaderboard points to learn are now
1932
+ repo-resident in `batteryswap_example/cv.py`, with the champion config itself
1933
+ promoted out of `/tmp/all48.py` into `cv.CHAMPION`.
1934
+
1935
+ venv/bin/python -m batteryswap_example.cv all48 [base|vcorr|zone|vrel]
1936
+ venv/bin/python -m batteryswap_example.cv static [variant] [n_seeds]
1937
+ venv/bin/python -m batteryswap_example.cv screen
1938
+
1939
+ **1. Ranking precision at the operating N, reported beside the cost.**
1940
+ `models_cv.rank_stats` now runs inside `walk_forward`, so `dev`/`panel`/`test`
1941
+ and the new protocols all emit rec12, rec20, operating-point recall/precision,
1942
+ the count actually bought, and the median rank of missed deaths.
1943
+ `summarize()` prints them, pooling caught/deaths over origins rather than
1944
+ averaging per-origin ratios (a 1-death origin must not outvote a 12-death one).
1945
+
1946
+ Base reproduces **1661.7 / 2379.7 / 1679.2 / 604.8** exactly, so the additions
1947
+ are diagnostic only. What they show that eight phases of cost-only reporting
1948
+ could not:
1949
+
1950
+ | block | n | total | rec12 | rec20 | recall | prec | sched | median miss rank |
1951
+ |---|---|---|---|---|---|---|---|---|
1952
+ | warmup s_0-7 | 8 | 2379.7 | 0.526 | 0.735 | 0.763 | 0.570 | 19.8 | 30.5 |
1953
+ | panel s_8-41 | 34 | 1679.2 | 0.572 | 0.675 | 0.571 | 0.458 | 11.3 | **57.0** |
1954
+ | nearend s_42-47 | 6 | 604.8 | 0.837 | 0.935 | 0.893 | 0.250 | 16.7 | 50.8 |
1955
+ | ALL-48 | 48 | 1661.7 | 0.597 | 0.718 | 0.641 | 0.453 | 13.4 | 49.2 |
1956
+
1957
+ **The panel's missed deaths sit at median rank 57**, 45 ranks past the cut. That
1958
+ is the closed appetite axis stated as a measurement rather than as an argument:
1959
+ no value of N reaches rank 57, which is why +395 swaps on the real fleet caught
1960
+ zero. It also localises the warm-up floor - rec12 0.526 there but recall 0.763,
1961
+ i.e. the floor buys catches with depth (19.8 swaps), not with order.
1962
+
1963
+ **2. `screen`: leave-BATTERY-out vs leave-SCENARIO-out, side by side.**
1964
+ 66503 closed pairs, 461 batteries, 1130 positive rows from **80 batteries**.
1965
+ The effective n is 80, not 1130.
1966
+
1967
+ | model | battery AUC | battery rec12 | scenario AUC | scenario rec12 |
1968
+ |---|---|---|---|---|
1969
+ | logistic | 0.9669 | 0.541 | 0.9716 | 0.639 |
1970
+ | logbag | 0.9669 | 0.535 | 0.9718 | 0.641 |
1971
+ | rf | 0.9745 | 0.564 | **0.9946** | **0.832** |
1972
+ | gbm | 0.9749 | 0.540 | 0.9921 | 0.786 |
1973
+
1974
+ Scenario grouping inflates rf's margin over logistic from +0.008 to +0.023 AUC
1975
+ and from +0.023 to +0.194 rec12 - an 8x exaggeration of the tree advantage,
1976
+ because holding out a scenario leaves the same batteries in train and test.
1977
+ Levels are not comparable across columns (a battery fold ranks ~92 devices, a
1978
+ scenario fold ~430); the margin within a column is. Selection reads the battery
1979
+ column. This is the offline-screen counterpart of what all-48 already knew: the
1980
+ harness rejected `rankavg` (+55.3) after cross-fleet CV had confirmed it at
1981
+ -18.4, p=0.0002.
1982
+
1983
+ **3. `static`: the deployment gate, made measurable.**
1984
+ Fit ONE model on half the buildings' fully-closed pairs, plan every origin of
1985
+ the other half with it - no refits, no accumulating own history, cold-start
1986
+ floor off (82 foreign deaths keeps gate 20 permanently shut in deployment).
1987
+ That is submit.py's held-out branch. 11 s a seed, so variants are screened
1988
+ across 5 building splits, not one.
1989
+
1990
+ Its levels are NOT comparable to all48 (half the fleet, half the deaths, and
1991
+ the count rule scales with the fleet) and its appetite readings are known-bad -
1992
+ this construction manufactured the sum(p42) undercount that phase 20 refuted
1993
+ and phase 26 believed (public 1661 -> 2189). **Ordering only, variant vs
1994
+ variant.** Both facts are enforced in the printout.
1995
+
1996
+ ### What the two instruments said about three feature variants
1997
+
1998
+ | variant | static gate (5 seeds, paired) | mechanism on the gate | all48 | verdict |
1999
+ |---|---|---|---|---|
2000
+ | `vrel` (+vrel, vmin7_rank) | **-24.6** +/- 9.3, W143/L66 | caught 460 -> 463, **sched 1176 -> 1060**, rec12/rec20 flat | **+138.7** | reject |
2001
+ | `vcorr` (+v_corr_last, thresh_dist) | -24.8 +/- 13.5, W115/L98 | caught 460 -> **478**, sched 1176 -> 1191, rec20 +0.007 | **+32.7** | reject |
2002
+ | `zone` (+z_days, z_in, z_rate) | +12.3, prec 0.391 -> 0.349 | sched 4.9 -> 5.6 | +104.9 (phase 34) | reject |
2003
+
2004
+ Champion unchanged. Two things are worth keeping from the exercise.
2005
+
2006
+ `vcorr`'s +32.7 reproduces the phase-34 measurement of +32.8 to within 0.1 - a
2007
+ third independent confirmation that the all-48 harness is deterministic and
2008
+ sound.
2009
+
2010
+ `vrel` is the discipline working. It won the gate by the widest margin of the
2011
+ three (W143/L66, 4 of 5 seeds) and it won it **entirely through the count**:
2012
+ 116 fewer swaps for 3 more deaths, with rec12 and rec20 flat to three decimals.
2013
+ The gate's own docstring says a count reading from this protocol is invalid,
2014
+ and the native path then charged +138.7 for it. Had the gate been read the way
2015
+ phase 26 read its ancestor, this would have shipped.
2016
+
2017
+ ### Latent deployment bug fixed: `unobserved_eol_days`
2018
+
2019
+ `cost_curves` and `plan_tsroute` hardcoded the evaluator's imputed-EOL offset
2020
+ as `+ 30.0` while the evaluator reads `settings.unobserved_eol_days`
2021
+ per-scenario - and submit.py imports `plan_tsroute` from this module, so the
2022
+ hardcode shipped. All 48 train scenarios use 30.0, which is precisely why no
2023
+ local protocol could ever catch it: the same shape as the hardcoded 42-day park
2024
+ that `repark_for_window` exists to undo. Now a module global that
2025
+ `plan_tsroute` overwrites from the settings it is handed; verified to
2026
+ propagate (30 -> 90) and to leave all-48 byte-identical at 1661.7.
batteryswap_example/cv.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Repo-resident CV protocols (ported from the blind research loop, 2026-08-21).
2
+
3
+ This replaces the ad-hoc `/tmp/all48*.py` scripts that every prior phase had to
4
+ re-derive, and folds in the three measurement disciplines the blind loop paid
5
+ real leaderboard points to learn.
6
+
7
+ **1. Report ranking precision at the operating N, not AUC.**
8
+ A model can hold AUC 0.947 and recall 0.000 on the subgroup that drives the
9
+ late cost (the voltage-silent deaths: median rank 60 of ~450). Mean cost
10
+ hides whether a change reordered the list or just cut it deeper. Every
11
+ protocol here prints rec12/rec20, the operating-point precision, and the
12
+ median rank of missed deaths beside the cost. Implemented in
13
+ `models_cv.rank_stats`, so `dev`/`panel`/`test` get it too.
14
+
15
+ **2. Group by BATTERY, not by scenario or row.**
16
+ Each battery contributes one near-duplicate row per closed origin (~3600
17
+ rows from ~460 batteries), so holding out a SCENARIO leaves the same
18
+ batteries in train and test and tree models score their own memorised
19
+ signatures. Measured in the blind loop: leave-scenario-out put HGB ahead of
20
+ linear (AUC .9907 vs .9843); leave-battery-out REVERSED it (.9769 vs .9845)
21
+ and the cost harness agreed with the clean split. The effective sample size
22
+ is ~460, not ~3600. `screen` runs both groupings side by side so the gap
23
+ stays visible.
24
+
25
+ **3. The deployment gate: would this fire in the static branch?**
26
+ The scored splits are fleets the model has never seen; on them `submit.py`
27
+ fits ONE model on the foreign train pool and never refits - no per-origin
28
+ refit, no accumulating own history, no cold start (the pool always holds 82
29
+ deaths, so every `train_deaths < 20` gate is permanently shut). The walk-
30
+ forward harness does the opposite. A mechanism can be worth tens of points
31
+ at `all48` and exactly zero where it is scored; that has already happened
32
+ once (a validated -49.8 warm-up mechanism, structurally unreachable in
33
+ deployment). `static` runs the deployment protocol locally on a foreign
34
+ half-fleet so the gate is measurable instead of argued.
35
+
36
+ Validity boundary, learned twice the hard way: the half-fleet construction
37
+ manufactures a sum(p42) undercount that does NOT exist on the real held-out
38
+ fleet (phase 20 refuted a foreign floor with it; phase 26 adopted
39
+ ALPHA_STATIC=1.5 from it and public went 1661 -> 2189). Use `static` for
40
+ ORDERING questions only. Never read an appetite/count decision off it.
41
+
42
+ Usage
43
+ venv/bin/python -m batteryswap_example.cv all48 [variant]
44
+ venv/bin/python -m batteryswap_example.cv static [variant]
45
+ venv/bin/python -m batteryswap_example.cv screen
46
+ """
47
+ import sys
48
+ import time
49
+ from pathlib import Path
50
+
51
+ import numpy as np
52
+ import pandas as pd
53
+
54
+ from batteryswap_public.evaluate import evaluate_plan
55
+
56
+ from batteryswap_example import models_cv as mc
57
+
58
+ WINDOW_DAYS = int(mc.WINDOW_DAYS)
59
+ # pinned at import: variants build off this, never off the possibly
60
+ # already-mutated mc.FEATURES (a second seed used to append twice)
61
+ BASE_FEATURES = list(mc.FEATURES)
62
+
63
+ # The deployed champion, in the repo instead of in a /tmp script.
64
+ # Mirrors submit.py's rolling branch: logbag / tsroute alpha 0.8 + break-even
65
+ # top-up (ec_margin 0.8, ec_pmax 25) + 7d building split + censor-aware
66
+ # pricing + the gated cold-start floor (k=2.5, gate 20).
67
+ CHAMPION = dict(model='logbag', planner='tsroute', p_star=0.8, day_q=0.05,
68
+ ec_margin=0.8, ec_pmax=25.0, split_days=7,
69
+ floor=2.5, floor_gate=20, floor_zone=1)
70
+
71
+ BLOCKS = (('warmup s_0-7', lambda o: o <= 7),
72
+ ('panel s_8-41', lambda o: (o >= 8) & (o <= 41)),
73
+ ('nearend s_42-47', lambda o: o >= 42),
74
+ ('ALL-48', lambda o: o >= 0))
75
+
76
+
77
+ def load_table(dataset_path=Path('dataset')):
78
+ """The one table build. Every protocol starts here so a variant can never
79
+ silently differ from the champion by a feature-prep line."""
80
+ t = mc.load_features(dataset_path)
81
+ t['v_proj'] = t['v_last'] + t['slope_lastk'] * t['staleness']
82
+ # within-origin fleet ranks: leak-free (data <= origin only)
83
+ t['r_vlast'] = t.groupby('origin_idx')['v_last'].rank(pct=True)
84
+ t['r_tau'] = t.groupby('origin_idx')['tau_projected'].rank(pct=True)
85
+ return t
86
+
87
+
88
+ def scorecard(df, label):
89
+ """Three-regime scorecard with ranking precision beside the cost."""
90
+ print(f'\n=== {label} ===')
91
+ hdr = (f"{'block':16s} {'n':>3s} {'total':>8s} {'late':>7s} {'early':>6s} "
92
+ f"{'rec12':>6s} {'rec20':>6s} {'recall':>6s} {'prec':>5s} "
93
+ f"{'sched':>5s} {'miss':>5s}")
94
+ print(hdr)
95
+ for name, sel in BLOCKS:
96
+ d = df[sel(df['origin'])]
97
+ if d.empty:
98
+ continue
99
+ deaths, caught = d['n_deaths'].sum(), d['caught'].sum()
100
+ sched = d['n_sched'].sum()
101
+ print(f"{name:16s} {len(d):3d} {d['total_cost'].mean():8.1f} "
102
+ f"{d['late_swap'].mean():7.1f} {d['early_swap'].mean():6.1f} "
103
+ f"{d['rec12'].mean():6.3f} {d['rec20'].mean():6.3f} "
104
+ f"{caught / max(deaths, 1):6.3f} {caught / max(sched, 1):5.3f} "
105
+ f"{d['n_sched'].mean():5.1f} "
106
+ f"{d['miss_rank'].median():5.1f}")
107
+
108
+
109
+ # --------------------------------------------------------------------------
110
+ # protocol 1: full-fleet walk-forward, s_0..s_47
111
+ # --------------------------------------------------------------------------
112
+
113
+ def run_all48(variant='base', origins=range(48), dataset_path=Path('dataset')):
114
+ t = load_table(dataset_path)
115
+ mc.FEATURES = list(VARIANTS[variant](t))
116
+ ctx = mc.load_context(dataset_path)
117
+ df = mc.walk_forward(t, ctx, origins, [dict(CHAMPION)])
118
+ df['variant'] = variant
119
+ return df
120
+
121
+
122
+ # --------------------------------------------------------------------------
123
+ # protocol 2: the deployment gate - one static fit on a foreign fleet
124
+ # --------------------------------------------------------------------------
125
+
126
+ def fleet_halves(locations, seed=0):
127
+ """Split the 24 buildings into two disjoint fleets.
128
+
129
+ Split by BUILDING, not by device: rooms and buildings share install
130
+ batches and thermal environment, so a device-level split leaves the
131
+ target fleet's own siblings in the training pool and the fit is no longer
132
+ foreign in the way deployment's is.
133
+ """
134
+ b = np.sort(locations['building'].unique())
135
+ rng = np.random.RandomState(seed)
136
+ perm = rng.permutation(len(b))
137
+ a = set(b[perm[:len(b) // 2]])
138
+ return a, set(b[perm[len(b) // 2:]])
139
+
140
+
141
+ def run_static(variant='base', seed=0, origins=range(48),
142
+ dataset_path=Path('dataset')):
143
+ """Deployment protocol: fit once on fleet A's fully-closed pairs, then plan
144
+ every one of fleet B's origins with that single model. No refits, no
145
+ accumulating own history, no cold-start gate - exactly submit.py's
146
+ held-out branch."""
147
+ t = load_table(dataset_path)
148
+ mc.FEATURES = list(VARIANTS[variant](t))
149
+ locations, timeseries, eol_times, scenarios = mc.load_dataset(
150
+ dataset_path / 'train')
151
+ fleet_a, fleet_b = fleet_halves(locations, seed)
152
+ dev_bldg = locations.drop_duplicates('battery').set_index('battery')['building']
153
+ t['bldg'] = t['device_id'].map(dev_bldg)
154
+
155
+ # the foreign pool: every fleet-A pair whose 42d window has closed by the
156
+ # end of the data. Deployment's pool is likewise fully adjudicated at
157
+ # build time and independent of the target origin.
158
+ closed = t['origin'] <= t['origin'].max() - pd.Timedelta(days=WINDOW_DAYS)
159
+ foreign = t[closed & t['bldg'].isin(fleet_a)]
160
+ model = mc.SklearnRUL(CHAMPION['model']).fit(foreign)
161
+ uniq = int(foreign.loc[foreign['dies42'] == 1, 'device_id'].nunique())
162
+ print(f'static foreign fit: {len(foreign)} pairs, {uniq} unique deaths, '
163
+ f'{len(fleet_a)} buildings | target fleet {len(fleet_b)} buildings',
164
+ file=sys.stderr)
165
+
166
+ p = mc.plan_tsroute
167
+ p.alpha, p.p_be, p.floor_day, p.p_conf = CHAMPION['p_star'], 0.0, 0, 0.5
168
+ p.ec_margin, p.ec_pmax = CHAMPION['ec_margin'], CHAMPION['ec_pmax']
169
+ p.split_days = CHAMPION['split_days']
170
+ # the cold-start floor is permanently shut in deployment (82 foreign
171
+ # deaths >= gate 20). Leaving it live here would measure a mechanism the
172
+ # scored path can never reach.
173
+ p.floor_scale, p.recent_deaths = 0.0, 0.0
174
+
175
+ recs = []
176
+ for idx, (scenario, locs, cut, eol) in enumerate(mc.iterate_scenarios(
177
+ locations, timeseries, eol_times, scenarios)):
178
+ if idx not in origins:
179
+ continue
180
+ T = pd.Timestamp(scenario['start_time'])
181
+ locs_b = locs[locs['building'].isin(fleet_b)]
182
+ rows = (t[t['origin_idx'] == idx].set_index('device_id')
183
+ .reindex(locs_b['battery']).dropna(subset=['v_last']))
184
+ if rows.empty:
185
+ continue
186
+ locs_b = locs_b[locs_b['battery'].isin(rows.index)]
187
+ # the evaluator prices unobserved EOLs off `locations`, so the eol
188
+ # series has to be cut to the target fleet too
189
+ eol_b = eol.reindex(locs_b['battery'])
190
+ pred = model.predict(rows)
191
+ plan = mc.plan_tsroute(pred, None, locs_b, T, CHAMPION['day_q'],
192
+ travel_costs=scenario['travel_costs'],
193
+ settings=scenario['settings'])
194
+ _, _, overall = evaluate_plan(plan, locs_b, scenario['travel_costs'],
195
+ scenario['settings'], eol_times=eol_b,
196
+ start_time=T, verbose=0)
197
+ rs = mc.rank_stats(pred, plan, eol_b, T)
198
+ recs.append(dict(overall, **rs, origin=idx, variant=variant, seed=seed))
199
+ print(f"s_{idx}: total {overall['total_cost']:7.1f} "
200
+ f"late {overall['late_swap']:6.1f} deaths {rs.get('n_deaths', 0):2d} "
201
+ f"caught {rs.get('caught', 0):2d}/{rs.get('n_sched', 0):2d} "
202
+ f"rec12 {rs.get('rec12', float('nan')):.2f}", flush=True,
203
+ file=sys.stderr)
204
+ return pd.DataFrame(recs)
205
+
206
+
207
+ # --------------------------------------------------------------------------
208
+ # protocol 3: offline model screen, grouped by battery
209
+ # --------------------------------------------------------------------------
210
+
211
+ def screen(kinds=('logistic', 'logbag', 'rf', 'gbm'), n_folds=5, seed=0,
212
+ dataset_path=Path('dataset')):
213
+ """Leave-BATTERY-out vs leave-SCENARIO-out on the same pairs and the same
214
+ models. The gap between the two columns is the contamination; trust the
215
+ battery column. Headline is recall at the operating N, AUC secondary."""
216
+ from sklearn.metrics import roc_auc_score
217
+
218
+ t = load_table(dataset_path)
219
+ mc.FEATURES = list(BASE_FEATURES)
220
+ closed = t[t['origin'] <= t['origin'].max() - pd.Timedelta(days=WINDOW_DAYS)]
221
+ devs = np.sort(closed['device_id'].unique())
222
+ rng = np.random.RandomState(seed)
223
+ dev_fold = pd.Series(rng.permutation(len(devs)) % n_folds, index=devs)
224
+ org = np.sort(closed['origin_idx'].unique())
225
+ org_fold = pd.Series(np.arange(len(org)) % n_folds, index=org)
226
+
227
+ n_dev_pos = closed.loc[closed['dies42'] == 1, 'device_id'].nunique()
228
+ print(f'pairs {len(closed)} batteries {len(devs)} origins {len(org)} '
229
+ f'positives {int(closed["dies42"].sum())} rows / {n_dev_pos} batteries')
230
+ print('effective n is the BATTERY count, not the row count - regularisation '
231
+ 'tuned against rows is ~8x too weak here.\n')
232
+
233
+ schemes = {'battery': closed['device_id'].map(dev_fold),
234
+ 'scenario': closed['origin_idx'].map(org_fold)}
235
+ out = {}
236
+ for scheme, fold in schemes.items():
237
+ for kind in kinds:
238
+ aucs, r12, r20 = [], [], []
239
+ for f in range(n_folds):
240
+ tr, te = closed[fold != f], closed[fold == f]
241
+ if te['dies42'].sum() < 3:
242
+ continue
243
+ m = mc.SklearnRUL(kind).fit(tr)
244
+ p = m.predict(te)['p42']
245
+ aucs.append(roc_auc_score(te['dies42'], p))
246
+ # recall at the operating depth, per origin, scaled to the
247
+ # held-out fleet's size (12/461 of the fleet)
248
+ for oi, g in te.groupby('origin_idx'):
249
+ y = g['dies42'].values
250
+ if y.sum() == 0:
251
+ continue
252
+ n = max(1, int(round(12 * len(g) / 461)))
253
+ order = np.argsort(-p.loc[g.index].values)
254
+ r12.append(y[order[:n]].sum() / y.sum())
255
+ r20.append(y[order[:max(1, int(round(20 * len(g) / 461)))]].sum()
256
+ / y.sum())
257
+ out[(scheme, kind)] = (np.mean(aucs), np.mean(r12), np.mean(r20))
258
+
259
+ # LEVELS ARE NOT COMPARABLE ACROSS COLUMNS. A battery fold holds ~1/5 of
260
+ # the fleet at every origin (recall at depth 12/461 means top-2 of ~92);
261
+ # a scenario fold holds the whole fleet at 1/5 of the origins (top-11 of
262
+ # ~430). Same depth fraction, very different discreteness. What IS
263
+ # comparable is each model's margin over the linear reference WITHIN a
264
+ # column - that margin is the thing a model-selection decision reads.
265
+ ref = kinds[0]
266
+ print(f"{'model':10s} " + ' '.join(f'{s + " " + m:>16s}' for s in schemes
267
+ for m in ('AUC', 'rec12')))
268
+ for kind in kinds:
269
+ cells = []
270
+ for s in schemes:
271
+ a, r, _ = out[(s, kind)]
272
+ cells += [f'{a:16.4f}', f'{r:16.3f}']
273
+ print(f'{kind:10s} ' + ' '.join(cells))
274
+ print(f'\nmargin over {ref} (the decision-relevant quantity):')
275
+ for kind in kinds[1:]:
276
+ cells = []
277
+ for s in schemes:
278
+ a, r, _ = out[(s, kind)]
279
+ a0, r0, _ = out[(s, ref)]
280
+ cells += [f'{a - a0:+16.4f}', f'{r - r0:+16.3f}']
281
+ print(f'{kind:10s} ' + ' '.join(cells))
282
+ print('\nA model whose margin shrinks or reverses when the grouping goes '
283
+ 'from scenario to battery was reading memorised battery identity. '
284
+ 'Selection follows the battery column.')
285
+ return out
286
+
287
+
288
+ # --------------------------------------------------------------------------
289
+
290
+ def _base(t):
291
+ return BASE_FEATURES
292
+
293
+
294
+ def _zone(t):
295
+ dbz = t['days_below_245']
296
+ t['z_days'] = dbz.fillna(-1.0)
297
+ t['z_in'] = (dbz >= 0).astype(float)
298
+ t['z_rate'] = np.where(dbz >= 0, (2.45 - t['v_corr_last']) / np.clip(dbz, 1, None), 0.0)
299
+ return BASE_FEATURES + ['z_days', 'z_in', 'z_rate']
300
+
301
+
302
+ def _vcorr(t):
303
+ return BASE_FEATURES + ['v_corr_last', 'thresh_dist']
304
+
305
+
306
+ def _vrel(t):
307
+ # phase-38 fleet-relative features: a held-out fleet's absolute voltage
308
+ # scale is not the train fleet's, but its within-fleet ordering is
309
+ return BASE_FEATURES + [c for c in ('vrel', 'vmin7_rank')
310
+ if c in t.columns]
311
+
312
+
313
+ VARIANTS = {'base': _base, 'zone': _zone, 'vcorr': _vcorr, 'vrel': _vrel}
314
+
315
+
316
+ def main():
317
+ proto = sys.argv[1] if len(sys.argv) > 1 else 'all48'
318
+ variant = sys.argv[2] if len(sys.argv) > 2 else 'base'
319
+ t0 = time.time()
320
+ if proto == 'all48':
321
+ df = run_all48(variant)
322
+ df.to_csv(f'/tmp/cv_all48_{variant}.csv', index=False)
323
+ scorecard(df, f'all48 / {variant} / full-fleet walk-forward')
324
+ elif proto == 'static':
325
+ # one building split is one draw; a variant comparison needs several.
326
+ # 11s a seed, so there is no excuse for reading a single one.
327
+ seeds = range(int(sys.argv[3]) if len(sys.argv) > 3 else 5)
328
+ df = pd.concat([run_static(variant, seed=s) for s in seeds],
329
+ ignore_index=True)
330
+ df.to_csv(f'/tmp/cv_static_{variant}.csv', index=False)
331
+ for s in seeds:
332
+ scorecard(df[df['seed'] == s], f'static / {variant} / seed {s}')
333
+ scorecard(df, f'static / {variant} / DEPLOYMENT GATE, {len(seeds)} '
334
+ f'building splits pooled')
335
+ print('Levels are NOT comparable to all48 (half the fleet, half the '
336
+ 'deaths, and the count rule scales with the fleet). Read this '
337
+ 'protocol only as variant-vs-variant, ORDERING only - never for '
338
+ 'an appetite/count decision (see module docstring).')
339
+ elif proto == 'screen':
340
+ screen()
341
+ else:
342
+ raise SystemExit(f'unknown protocol {proto}')
343
+ print(f'\n[{time.time() - t0:.0f}s]')
344
+
345
+
346
+ if __name__ == '__main__':
347
+ main()
batteryswap_example/data/train_features.parquet CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:8ca530c2fee4c2e6457ee7a984286fcf2f21f4f8b058c033b653d6a67dd7392b
3
- size 5216659
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4d7e31ddec4d82eec9e062848ed0c019811068947a3bd6ccf7e42d6f7d8e7376
3
+ size 6159081
batteryswap_example/features.py CHANGED
@@ -112,6 +112,31 @@ def features_at_origin(daily: pd.DataFrame, devices: pd.DataFrame,
112
 
113
  feats['accel'] = feats['slope_30'] - feats['slope_90']
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  install = devices.set_index('battery')['start_time'].dt.tz_localize(None)
116
  eff_install = install.reindex(feats.index)
117
  if swaps is not None and len(swaps):
 
112
 
113
  feats['accel'] = feats['slope_30'] - feats['slope_90']
114
 
115
+ # ---- fleet-relative encoding -------------------------------------------
116
+ # Ported from the blind-harness champion, where vrel and vmin7_rank were
117
+ # the two largest single-feature wins. Both are computed at PLAN time over
118
+ # the fleet actually in front of the planner, so they absorb a fleet-level
119
+ # voltage shift that the absolute features (v_last, v_min_30, ...) cannot.
120
+ # The motivation is TRANSFER to a held-out fleet: on the native fleet there
121
+ # is no shift to absorb, which is why the one prior local test called them
122
+ # "redundant with aux features" (EXPERIMENTS.md, in-origin fleet ranks,
123
+ # 2055). That verdict cannot see the deployment regime.
124
+ #
125
+ # Deliberately NOT filled here. The classifier pipeline median-imputes and
126
+ # then scales, which parks a stale/dark device at "unremarkable" so it is
127
+ # never selected; three independent measurements found that making those
128
+ # devices visible instead is a monotone loss. Do not add fillna() or
129
+ # na_option= to the rank.
130
+ #
131
+ # Population note: the denominator is whatever has history at or before the
132
+ # origin, dead-but-not-yet-dropped devices included. That is identical in
133
+ # the training-table build and in the runtime path, which is what matters -
134
+ # both sides see the same population definition.
135
+ h7 = h[h['day'] > origin - pd.Timedelta(days=7)]
136
+ feats['v_min_7'] = h7.groupby('device_id')['v'].min()
137
+ feats['vrel'] = feats['v_last'] - feats['v_last'].median()
138
+ feats['vmin7_rank'] = feats['v_min_7'].rank(pct=True)
139
+
140
  install = devices.set_index('battery')['start_time'].dt.tz_localize(None)
141
  eff_install = install.reindex(feats.index)
142
  if swaps is not None and len(swaps):
batteryswap_example/models_cv.py CHANGED
@@ -23,7 +23,6 @@ Planners turn (p42, day_q) into a plan:
23
  """
24
 
25
  from pathlib import Path
26
- import itertools
27
  import os
28
  import sys
29
  import warnings
@@ -47,6 +46,14 @@ FEATURES = ['v_last', 'v_drop', 'slope_30', 'slope_90', 'v_min_30',
47
  'staleness', 'age_days',
48
  'slope_lastk', 'drop_recent', 'below_frac', 'accel', 'n_30',
49
  'tau_projected']
 
 
 
 
 
 
 
 
50
  # vstd_30/rng_30 (plunge-precursor volatility, AUC 0.83/0.81 on the silent
51
  # cohort) rejected as GLOBAL features: dev +17, test -0.7 - the signal is
52
  # diluted across the healthy majority. Used instead by the SilentFix override.
@@ -1077,6 +1084,15 @@ EARLY, LATE = 0.5, 10.0
1077
  # decisions at near-data-end origins and loses there - the underprice acts as
1078
  # a regularizer against over-including marginal survivors. Keep 45.
1079
  EMERGENCY_DAY = WINDOW_DAYS + 3
 
 
 
 
 
 
 
 
 
1080
  CENSOR_AWARE = True # price unobservable (post-data-end) deaths via imputation
1081
  PARK_MOBILITY = 0.0 # extra cost per expected emergency (travel/overtime)
1082
 
@@ -1166,7 +1182,7 @@ def cost_curves(pred, locations, start_time, blend=0.0):
1166
  # precede the window, and the evaluator then charges a swap as LATE at
1167
  # 10/day - the old clip(lower=43) made exactly those devices look free
1168
  e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400
1169
- + 30.0).values
1170
 
1171
  # censoring-aware: deaths after the split's data end are never in the
1172
  # shipped eol_times, so the evaluator cannot charge late/emergency for
@@ -1418,6 +1434,9 @@ def plan_tsroute(pred, included, locations, start_time, day_q,
1418
  floor_day - defers uncertain insurance swaps that would otherwise
1419
  pay early-replacement for weeks [floor_day 0 = off]
1420
  """
 
 
 
1421
  psum = pred['p42_sum'].iloc[0] if 'p42_sum' in pred else pred['p42'].sum()
1422
  if plan_tsroute.floor_scale > 0:
1423
  # cold-start inclusion floor: sum(p42) tracks prevalence only once the
@@ -1448,7 +1467,7 @@ def plan_tsroute(pred, included, locations, start_time, day_q,
1448
  # counterfactual cheap-price CV shows -76 where wrong costs ~19.
1449
  end = locations.set_index('battery')['end_time'].dt.tz_localize(None)
1450
  e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400
1451
- + 30.0).values
1452
  # top-up only fresh devices (imputed EOL beyond the window): for stale
1453
  # ones the survivor branch is a bookkeeping fiction - swapping "on the
1454
  # imputed day" scores ~0 but buys nothing and costs real mobility
@@ -1799,7 +1818,8 @@ def walk_forward(table, scenario_ctx, origins, configs):
1799
  _, _, overall = evaluate_plan(
1800
  plan, ctx['locs'], ctx['travel_costs'], ctx['settings'],
1801
  eol_times=ctx['eol'], start_time=origin_time, verbose=0)
1802
- records.append(dict(overall, origin=idx, **cfg))
 
1803
 
1804
  auc = {m: roc_auc_score(rows['dies42'], p['p42'])
1805
  for m, p in preds.items() if rows['dies42'].sum() > 0}
@@ -1808,6 +1828,49 @@ def walk_forward(table, scenario_ctx, origins, configs):
1808
  return pd.DataFrame(records)
1809
 
1810
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1811
  def load_context(dataset_path):
1812
  locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train')
1813
  ctx = {}
@@ -1845,6 +1908,19 @@ def summarize(df, label):
1845
  if ops:
1846
  dec['ops'] = df.groupby('name')[ops].mean().sum(axis=1)
1847
  print(dec.round(1).reindex(mean.index).to_string())
 
 
 
 
 
 
 
 
 
 
 
 
 
1848
  return mean
1849
 
1850
 
 
23
  """
24
 
25
  from pathlib import Path
 
26
  import os
27
  import sys
28
  import warnings
 
46
  'staleness', 'age_days',
47
  'slope_lastk', 'drop_recent', 'below_frac', 'accel', 'n_30',
48
  'tau_projected']
49
+ # NOT in FEATURES, deliberately: 'vrel', 'vmin7_rank'. features.py computes
50
+ # both (and v_min_7), and the training table carries them, so enabling them is
51
+ # a one-line change here. Held back from the pooling submission so that
52
+ # submission isolates ONE change and stays attributable - phase 28 stacked four
53
+ # and needed two further phases to untangle the damage. Evidence as of
54
+ # 2026-08-21 is also unsettled: vrel survived a leave-one-out ablation on the
55
+ # blind harness, vmin7_rank was ablated OUT (-12.8), and a static-fit gate
56
+ # showed the whole feature-set swap swinging +55 to -109 depending on fit pool.
57
  # vstd_30/rng_30 (plunge-precursor volatility, AUC 0.83/0.81 on the silent
58
  # cohort) rejected as GLOBAL features: dev +17, test -0.7 - the signal is
59
  # diluted across the healthy majority. Used instead by the SilentFix override.
 
1084
  # decisions at near-data-end origins and loses there - the underprice acts as
1085
  # a regularizer against over-including marginal survivors. Keep 45.
1086
  EMERGENCY_DAY = WINDOW_DAYS + 3
1087
+ # The evaluator prices a swap on a device with no observed EOL against
1088
+ # (location end_time + settings.unobserved_eol_days). That setting is
1089
+ # per-scenario and READ FROM THE SCENARIO at runtime; it was hardcoded to the
1090
+ # default 30.0 in two places here, and submit.py imports plan_tsroute from
1091
+ # this module, so the hardcode shipped. Every train scenario happens to use
1092
+ # 30.0, which is exactly why local CV could never catch it - same failure
1093
+ # shape as the hardcoded 42-day park that repark_for_window exists to undo.
1094
+ # plan_tsroute now overwrites this from the settings it is handed.
1095
+ UNOBSERVED_EOL_DAYS = 30.0
1096
  CENSOR_AWARE = True # price unobservable (post-data-end) deaths via imputation
1097
  PARK_MOBILITY = 0.0 # extra cost per expected emergency (travel/overtime)
1098
 
 
1182
  # precede the window, and the evaluator then charges a swap as LATE at
1183
  # 10/day - the old clip(lower=43) made exactly those devices look free
1184
  e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400
1185
+ + UNOBSERVED_EOL_DAYS).values
1186
 
1187
  # censoring-aware: deaths after the split's data end are never in the
1188
  # shipped eol_times, so the evaluator cannot charge late/emergency for
 
1434
  floor_day - defers uncertain insurance swaps that would otherwise
1435
  pay early-replacement for weeks [floor_day 0 = off]
1436
  """
1437
+ if settings is not None:
1438
+ global UNOBSERVED_EOL_DAYS
1439
+ UNOBSERVED_EOL_DAYS = float(settings.unobserved_eol_days)
1440
  psum = pred['p42_sum'].iloc[0] if 'p42_sum' in pred else pred['p42'].sum()
1441
  if plan_tsroute.floor_scale > 0:
1442
  # cold-start inclusion floor: sum(p42) tracks prevalence only once the
 
1467
  # counterfactual cheap-price CV shows -76 where wrong costs ~19.
1468
  end = locations.set_index('battery')['end_time'].dt.tz_localize(None)
1469
  e_far = ((end.reindex(pred.index) - start_time).dt.total_seconds() / 86400
1470
+ + UNOBSERVED_EOL_DAYS).values
1471
  # top-up only fresh devices (imputed EOL beyond the window): for stale
1472
  # ones the survivor branch is a bookkeeping fiction - swapping "on the
1473
  # imputed day" scores ~0 but buys nothing and costs real mobility
 
1818
  _, _, overall = evaluate_plan(
1819
  plan, ctx['locs'], ctx['travel_costs'], ctx['settings'],
1820
  eol_times=ctx['eol'], start_time=origin_time, verbose=0)
1821
+ rs = rank_stats(pred, plan, ctx['eol'], origin_time)
1822
+ records.append(dict(overall, **rs, origin=idx, **cfg))
1823
 
1824
  auc = {m: roc_auc_score(rows['dies42'], p['p42'])
1825
  for m, p in preds.items() if rows['dies42'].sum() > 0}
 
1828
  return pd.DataFrame(records)
1829
 
1830
 
1831
+ RANK_NS = (12, 20)
1832
+
1833
+
1834
+ def rank_stats(pred, plan, eol, start_time, order_col=None):
1835
+ """Ranking precision at the operating point, per origin.
1836
+
1837
+ Measurement law #1 from the research loop: mean cost and AUC both hide
1838
+ whether a change moved the ORDER or just the depth of the cut. A model
1839
+ can hold AUC 0.947 and still have recall 0.000 on the subgroup that
1840
+ actually drives late cost. So every protocol here reports, alongside the
1841
+ cost:
1842
+
1843
+ rec12/rec20 ordering only - share of the window's deaths inside the
1844
+ top 12/20 of the score, independent of how many the
1845
+ planner chose to swap;
1846
+ n_sched what the planner actually bought (in-window swaps);
1847
+ caught/prec the operating point: deaths among those swaps, and the
1848
+ share of swaps that were real deaths;
1849
+ miss_rank median rank of the deaths that were missed - the number
1850
+ that says whether a miss was near the cut or invisible.
1851
+ """
1852
+ order = pred[order_col] if order_col and order_col in pred.columns else (
1853
+ pred['rank_score'] if 'rank_score' in pred.columns else pred['p42'])
1854
+ order = order.sort_values(ascending=False)
1855
+ window_end = start_time + pd.Timedelta(days=WINDOW_DAYS)
1856
+ e = eol.reindex(order.index)
1857
+ deaths = set(e.index[(e > start_time) & (e <= window_end)])
1858
+ out = {'n_deaths': len(deaths)}
1859
+ if not deaths:
1860
+ return out
1861
+ ranks = {b: r for r, b in enumerate(order.index, start=1)}
1862
+ dr = sorted(ranks[b] for b in deaths if b in ranks)
1863
+ for n in RANK_NS:
1864
+ out[f'rec{n}'] = sum(1 for r in dr if r <= n) / len(dr)
1865
+ missed = [r for r in dr if r > max(RANK_NS)]
1866
+ out['miss_rank'] = float(np.median(missed)) if missed else np.nan
1867
+ sched = set(plan.loc[plan['day'] <= window_end, 'battery'])
1868
+ out['n_sched'] = len(sched)
1869
+ out['caught'] = len(sched & deaths)
1870
+ out['prec'] = out['caught'] / len(sched) if sched else np.nan
1871
+ out['recall'] = out['caught'] / len(deaths)
1872
+ return out
1873
+
1874
  def load_context(dataset_path):
1875
  locations, timeseries, eol_times, scenarios = load_dataset(dataset_path / 'train')
1876
  ctx = {}
 
1908
  if ops:
1909
  dec['ops'] = df.groupby('name')[ops].mean().sum(axis=1)
1910
  print(dec.round(1).reindex(mean.index).to_string())
1911
+ # ranking precision at the operating N (measurement law #1): cost alone
1912
+ # cannot say whether a change reordered the list or just cut it deeper
1913
+ rk = [c for c in ('rec12', 'rec20', 'recall', 'prec', 'n_sched',
1914
+ 'miss_rank', 'n_deaths') if c in df.columns]
1915
+ if rk:
1916
+ g = df.groupby('name')
1917
+ r = g[rk].mean()
1918
+ # pool over origins rather than averaging per-origin ratios: a
1919
+ # 1-death origin must not outvote a 12-death one
1920
+ if {'caught', 'n_deaths', 'n_sched'} <= set(df.columns):
1921
+ r['recall'] = g['caught'].sum() / g['n_deaths'].sum()
1922
+ r['prec'] = g['caught'].sum() / g['n_sched'].sum()
1923
+ print(r.round(3).reindex(mean.index).to_string())
1924
  return mean
1925
 
1926
 
batteryswap_example/submit.py CHANGED
@@ -1,10 +1,14 @@
1
  """
2
  Adaptive submission generator.
3
 
4
- Held-out splits (fleet not in the packaged train table): ONE model fitted on
5
- the full foreign train pool per split (phase 24) - per-origin refits with the
6
- target's own masked history were a statistical tie in the holdout harness and
7
- only added runtime/fallback risk.
 
 
 
 
8
 
9
  Train-fleet splits: refit at every scenario origin on the split's own
10
  self-masked history, identical to the walk-forward procedure validated
@@ -43,6 +47,49 @@ from batteryswap_example import models_cv
43
 
44
  TRAIN_TABLE = Path(__file__).parent / 'data' / 'train_features.parquet'
45
  GRID_STEP_DAYS = 4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  MODEL_KIND = 'logbag'
47
  # Inclusion appetite, tuned by local CV: alpha sweep 0.7-1.3 on dev has a clear
48
  # minimum at 0.8 (beats every alternative on 11-12 of 16 origins), confirmed on
@@ -108,13 +155,21 @@ CLIFF_MIN_N = 20
108
 
109
 
110
  def build_split_table(locations, eol_times, scen_starts, daily, dall, betas,
111
- log=print):
112
- """(device, origin) training pairs from this split's own history."""
 
 
 
 
 
113
  t0 = dall['day'].min() + pd.Timedelta(days=120)
114
- grid = pd.date_range(t0, max(scen_starts), freq=f'{GRID_STEP_DAYS}D')
115
 
116
  rows = []
117
  for origin in grid:
 
 
 
118
  alive = eol_times.isna() | (eol_times > origin)
119
  f = features_at_origin(daily, locations, origin).reindex(eol_times.index[alive])
120
 
@@ -206,6 +261,7 @@ def adaptive_split_plans(split, split_path, log=print, deadline=None):
206
  if base is not None else 1.0)
207
 
208
  static_model, static_uniq, cliff_counts = None, 0, None
 
209
  if base is not None and own_fleet < 0.5:
210
  # PHASE 24 (static fit): on a held-out split, per-origin refits with
211
  # the target's own masked history are a statistical tie against ONE
@@ -220,6 +276,31 @@ def adaptive_split_plans(split, split_path, log=print, deadline=None):
220
  log(f' static foreign fit: {len(foreign)} pairs, {static_uniq} unique '
221
  f'deaths (own-fleet frac {own_fleet:.2f})')
222
  cliff_counts = cliff_day_counts(timeseries) if CLIFF_SCALE != 1.0 else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  else:
224
  # target split IS (mostly) the train fleet: keep the rolling
225
  # walk-forward path - its own labels are the answer key and must stay
@@ -241,8 +322,8 @@ def adaptive_split_plans(split, split_path, log=print, deadline=None):
241
 
242
  plans = []
243
  fitted = {}
244
- for scenario, locs, cut, eol in iterate_scenarios(
245
- locations, timeseries, eol_times, scenarios):
246
  T = pd.Timestamp(scenario['start_time'])
247
 
248
  if deadline is not None and time.time() > deadline:
@@ -263,22 +344,42 @@ def adaptive_split_plans(split, split_path, log=print, deadline=None):
263
  log(f' WARNING {scenario["name"]}: planning_window_days='
264
  f'{stg.planning_window_days} != {WINDOW_DAYS}')
265
 
266
- if static_model is not None:
 
 
 
 
 
 
 
 
 
 
 
267
  model, train_uniq = static_model, static_uniq
268
- elif T not in fitted:
269
- # LABEL INTEGRITY: own pairs only where the window closed before T
270
- # (the split's eol_times is its answer key). Foreign train-fleet
271
- # pairs are used in full: masking them was CV hygiene misapplied
272
- # to deployment, starving early origins to 3-20 unique training
273
- # deaths out of the 82 available (the cold-start mechanism).
274
- closed = pool['origin'] <= T - pd.Timedelta(days=WINDOW_DAYS)
275
- train = pool[closed | pool['foreign']]
276
- own_pairs = train[~train['foreign']]
277
- assert (own_pairs['origin'] + pd.Timedelta(days=WINDOW_DAYS) <= T).all()
278
- uniq = int(train.loc[train['dies42'] == 1, 'device_id'].nunique())
279
- fitted[T] = (SklearnRUL(MODEL_KIND).fit(train), uniq)
280
- if static_model is None:
281
- model, train_uniq = fitted[T]
 
 
 
 
 
 
 
 
 
282
 
283
  feats = features_at_origin(daily, locs, T)
284
  past = betas[betas['week_end'] <= T]['beta']
 
1
  """
2
  Adaptive submission generator.
3
 
4
+ Held-out splits (fleet not in the packaged train table): the foreign train pool
5
+ POOLED with the split's own closed-window pairs, refit on a cadence as the pool
6
+ grows. Phase 24b had switched the own-history half off after the half-fleet
7
+ holdout called static-vs-rolling a tie - the instrument later retired, and its
8
+ small target fleet distorts exactly this comparison. Degrades to the plain
9
+ static foreign fit (the prior champion) if the build or the refits run long;
10
+ never to the pickle, which is far below it. Set BATTERYSWAP_HELDOUT_POOL=0 to
11
+ restore the pure static behaviour.
12
 
13
  Train-fleet splits: refit at every scenario origin on the split's own
14
  self-masked history, identical to the walk-forward procedure validated
 
47
 
48
  TRAIN_TABLE = Path(__file__).parent / 'data' / 'train_features.parquet'
49
  GRID_STEP_DAYS = 4
50
+ # Held-out in-fleet pooling. The static branch (phase 24b) fits ONE model on
51
+ # the 82 foreign train deaths and discards the target split's own history
52
+ # entirely. But that split ships its own eol_times.csv, and every death with
53
+ # eol <= T - and every training pair whose 42d window CLOSED before T - was
54
+ # observable at planning time (iterate_scenarios reads the same file for the
55
+ # same reason). On a ~2x fleet those in-fleet pairs outnumber the foreign 82
56
+ # by the late origins, and they are the only signal that can correct a
57
+ # fleet-level shift the foreign fit never saw.
58
+ #
59
+ # Phase 24b measured static-vs-rolling as a tie and switched this off - on the
60
+ # half-fleet holdout, the instrument later retired precisely because its small
61
+ # target fleet distorts exactly this comparison.
62
+ #
63
+ # NOT LOCALLY MEASURABLE: the panel and all-48 run the ROLLING branch (the
64
+ # train fleet is its own fleet), so the static branch never executes locally
65
+ # and this change is byte-identical on every local metric by construction.
66
+ # It is gated on the phase-27 plumbing rehearsal instead, and on the fail-safe
67
+ # below - never on a local score, because there cannot be one.
68
+ #
69
+ # Runtime is the reason it was affordable to skip: build_split_table at the
70
+ # native 4-day grid was ~17 min for 461 devices, so ~35 for a 2x fleet, twice
71
+ # over, against a 30-minute hard limit. HELDOUT_GRID_STEP_DAYS coarsens the
72
+ # grid for this path only; the pairs it drops are pairs we do not have at all
73
+ # today. If the build overruns HELDOUT_BUILD_BUDGET_S we degrade to the plain
74
+ # static foreign fit - i.e. to the CURRENT CHAMPION - and never to the pickle
75
+ # fallback, which is far below it. Set HELDOUT_POOL=0 to disable.
76
+ HELDOUT_POOL = os.environ.get('BATTERYSWAP_HELDOUT_POOL', '1') != '0'
77
+ HELDOUT_GRID_STEP_DAYS = int(os.environ.get('BATTERYSWAP_HELDOUT_GRID', '16'))
78
+ HELDOUT_BUILD_BUDGET_S = float(os.environ.get('BATTERYSWAP_HELDOUT_BUDGET_S', '360'))
79
+ # Refit cadence. The rehearsal showed the table build is cheap (7s at grid 16d
80
+ # on 461 devices) and the PER-ORIGIN REFITS dominate (~4.5s each x 48). At a
81
+ # ~2x held-out fleet that is ~8 min/split, ~16 for both, against a 20-min
82
+ # budget - too close for comfort when the real fleet size is unobservable from
83
+ # here. Reusing one fit across REFIT_EVERY consecutive origins halves the cost
84
+ # for almost no information: consecutive origins are days apart and the pool
85
+ # grows by a handful of closed pairs between them. 1 = refit at every origin.
86
+ # MEASURED (Docker, 2x fleet of 922 devices/split, both splits): at every-2
87
+ # origins a split costs ~10 min and the pair lands at 20m11s - over budget, and
88
+ # the guard correctly degraded private from s_31 onward, which silently throws
89
+ # away the pooling benefit on a third of that split. Refits are ~23 s each at
90
+ # this fleet size and dominate everything (table build is 22 s). Every-4 halves
91
+ # them again: ~5.5 min/split, ~11 min for the pair, comfortably inside 20.
92
+ HELDOUT_REFIT_EVERY = int(os.environ.get('BATTERYSWAP_HELDOUT_REFIT_EVERY', '4'))
93
  MODEL_KIND = 'logbag'
94
  # Inclusion appetite, tuned by local CV: alpha sweep 0.7-1.3 on dev has a clear
95
  # minimum at 0.8 (beats every alternative on 11-12 of 16 origins), confirmed on
 
155
 
156
 
157
  def build_split_table(locations, eol_times, scen_starts, daily, dall, betas,
158
+ log=print, step_days=GRID_STEP_DAYS, deadline=None):
159
+ """(device, origin) training pairs from this split's own history.
160
+
161
+ deadline: wall-clock epoch after which the build aborts with TimeoutError.
162
+ The held-out path passes one so an overrun degrades to the static foreign
163
+ fit (the current champion) instead of eating the whole split's budget.
164
+ """
165
  t0 = dall['day'].min() + pd.Timedelta(days=120)
166
+ grid = pd.date_range(t0, max(scen_starts), freq=f'{step_days}D')
167
 
168
  rows = []
169
  for origin in grid:
170
+ if deadline is not None and time.time() > deadline:
171
+ raise TimeoutError(
172
+ f'split-table build exceeded budget at {len(rows)}/{len(grid)} origins')
173
  alive = eol_times.isna() | (eol_times > origin)
174
  f = features_at_origin(daily, locations, origin).reindex(eol_times.index[alive])
175
 
 
261
  if base is not None else 1.0)
262
 
263
  static_model, static_uniq, cliff_counts = None, 0, None
264
+ pool = None
265
  if base is not None and own_fleet < 0.5:
266
  # PHASE 24 (static fit): on a held-out split, per-origin refits with
267
  # the target's own masked history are a statistical tie against ONE
 
276
  log(f' static foreign fit: {len(foreign)} pairs, {static_uniq} unique '
277
  f'deaths (own-fleet frac {own_fleet:.2f})')
278
  cliff_counts = cliff_day_counts(timeseries) if CLIFF_SCALE != 1.0 else None
279
+
280
+ # In-fleet pooling for the held-out path. Same label discipline as the
281
+ # rolling branch: own pairs enter only where the 42d window closed
282
+ # before T (asserted below), foreign pairs are a different fleet whose
283
+ # labels were all known when this submission was built. static_model
284
+ # stays live as the degradation target.
285
+ if HELDOUT_POOL:
286
+ t_build = time.time()
287
+ try:
288
+ own = build_split_table(
289
+ locations, eol_times, scen_starts, daily, dall, betas,
290
+ log=log, step_days=HELDOUT_GRID_STEP_DAYS,
291
+ deadline=t_build + HELDOUT_BUILD_BUDGET_S)
292
+ own['foreign'] = False
293
+ f2 = foreign[np.intersect1d(foreign.columns, own.columns)].copy()
294
+ f2['foreign'] = True
295
+ pool = pd.concat([own, f2], ignore_index=True)
296
+ own_uniq = int(own.loc[own['dies42'] == 1, 'device_id'].nunique())
297
+ log(f' in-fleet pooling ON: +{len(own)} own pairs, {own_uniq} '
298
+ f'own deaths vs {static_uniq} foreign '
299
+ f'({time.time()-t_build:.0f}s, grid {HELDOUT_GRID_STEP_DAYS}d)')
300
+ except Exception as e:
301
+ # degrade to the CURRENT CHAMPION, never to the pickle
302
+ log(f' WARNING in-fleet pooling skipped ({e}); '
303
+ f'staying on the static foreign fit')
304
  else:
305
  # target split IS (mostly) the train fleet: keep the rolling
306
  # walk-forward path - its own labels are the answer key and must stay
 
322
 
323
  plans = []
324
  fitted = {}
325
+ for si, (scenario, locs, cut, eol) in enumerate(iterate_scenarios(
326
+ locations, timeseries, eol_times, scenarios)):
327
  T = pd.Timestamp(scenario['start_time'])
328
 
329
  if deadline is not None and time.time() > deadline:
 
344
  log(f' WARNING {scenario["name"]}: planning_window_days='
345
  f'{stg.planning_window_days} != {WINDOW_DAYS}')
346
 
347
+ # Mid-loop degradation: if the per-origin refits are eating the split
348
+ # budget, drop to the static foreign fit for the REMAINING origins
349
+ # rather than letting the loop-level deadline raise, which lands in the
350
+ # pickle fallback - far below the static champion.
351
+ if (pool is not None and static_model is not None and deadline is not None
352
+ and time.time() > deadline - 4 * 60):
353
+ log(f' {scenario["name"]}: refit budget low - degrading to the '
354
+ f'static foreign fit for the remaining origins')
355
+ pool = None
356
+
357
+ if pool is None:
358
+ # no in-fleet pairs (pooling off, build overran, or budget low)
359
  model, train_uniq = static_model, static_uniq
360
+ else:
361
+ # Cadence applies to the HELD-OUT path only (static_model set).
362
+ # The train path keeps its per-T key so the all-48 checksum is
363
+ # untouched. Reusing a bucket's fit at a later origin only ever
364
+ # uses FEWER closed pairs than were available - conservative, never
365
+ # a leak.
366
+ fkey = (si // HELDOUT_REFIT_EVERY) if static_model is not None else T
367
+ if fkey not in fitted:
368
+ # LABEL INTEGRITY: own pairs only where the window closed
369
+ # before T (the split's eol_times is its answer key). Foreign
370
+ # train-fleet pairs are used in full: masking them was CV
371
+ # hygiene misapplied to deployment, starving early origins to
372
+ # 3-20 unique training deaths out of the 82 available (the
373
+ # cold-start mechanism). This is the assertion that keeps the
374
+ # held-out path honest now that it reads the split's own
375
+ # eol_times: every own label used was observable at T.
376
+ closed = pool['origin'] <= T - pd.Timedelta(days=WINDOW_DAYS)
377
+ train = pool[closed | pool['foreign']]
378
+ own_pairs = train[~train['foreign']]
379
+ assert (own_pairs['origin'] + pd.Timedelta(days=WINDOW_DAYS) <= T).all()
380
+ uniq = int(train.loc[train['dies42'] == 1, 'device_id'].nunique())
381
+ fitted[fkey] = (SklearnRUL(MODEL_KIND).fit(train), uniq)
382
+ model, train_uniq = fitted[fkey]
383
 
384
  feats = features_at_origin(daily, locs, T)
385
  past = betas[betas['week_end'] <= T]['beta']