Thibaut Forest Claude Opus 5 (1M context) commited on
Commit
fc780fa
·
1 Parent(s): faf80ea

Phase 27: held-out-parity audit - penalty wiring + 3-layer failure isolation

Browse files

Dress rehearsal (Docker main() over fake public+private with renamed device
IDs) exposed that one bad split could destroy the whole submission: a
check_dataset assertion killed the adaptive path AND the pickle fallback
identically, escaped main()'s handler, and no submission.csv was written.

- load_best_effort(): dataset consistency check demoted to a logged warning
- fallback guarded individually; new raw-file last resort (defer-everything
plans, alive-filtered) guarded again - a split can at worst lose itself
- cost_curves penalty constants (EARLY/LATE) wired from each scenario's
settings (evaluator charges these; constant 0.5/10.0 in train, byte-inert
at defaults); planning_window_days != 42 logs loudly
- audit: per-scenario settings vary only in base_location/base_room (16
bases; mobility sim already reads them live)

Rehearsal after fixes: both splits static-path, complete 2-split
submission.csv in 35s; all-48 checksum 1851.0186805555559 byte-exact.

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

Files changed (2) hide show
  1. EXPERIMENTS.md +34 -0
  2. batteryswap_example/submit.py +69 -3
EXPERIMENTS.md CHANGED
@@ -986,6 +986,40 @@ Applies ONLY to the static held-out path; the panel champion (rolling,
986
  native fleet) keeps alpha=0.8, where the same sweep was decisively rejected
987
  (phases 1/6). The train path never reads ALPHA_STATIC - all-48 unaffected.
988
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
989
  ## Test-look ledger
990
 
991
  8 looks total (now historical - see protocol change above); look 9 = the
 
986
  native fleet) keeps alpha=0.8, where the same sweep was decisively rejected
987
  (phases 1/6). The train path never reads ALPHA_STATIC - all-48 unaffected.
988
 
989
+ ## Phase 27 - held-out-parity audit & dress rehearsal (2 fixes shipped)
990
+
991
+ Question: does our setup correctly reflect public/private mechanics? Audit of
992
+ what can vary per scenario + a Docker dress rehearsal of the EXACT remote
993
+ invocation (main() over fake public+private splits with renamed device IDs so
994
+ the own-fleet guard routes to the static path).
995
+
996
+ Findings:
997
+ 1. **Per-scenario settings DO vary in train** - 27 of 48 scenarios differ,
998
+ but only in base_location/base_room (16 distinct technician bases). The
999
+ mobility sim reads the live settings object, so this was already handled.
1000
+ All penalties/limits/window are constant 0.5/10.0/42 across train.
1001
+ 2. **Hardening shipped: penalty wiring.** cost_curves priced with hardcoded
1002
+ EARLY/LATE while the evaluator charges per-scenario settings values - now
1003
+ wired through per scenario (byte-inert at default values; checksum
1004
+ confirms). planning_window_days != 42 now logs loudly (cannot be rewired:
1005
+ the 42d grid is baked into features/quantiles).
1006
+ 3. **Hardening shipped: failure isolation.** The rehearsal exposed that a
1007
+ data-shaped exception (e.g. a check_dataset assertion) killed the adaptive
1008
+ path AND the fallback identically - the exception escaped main(), and NO
1009
+ submission.csv was written: one bad split destroyed all splits' plans.
1010
+ Now three layers: load_best_effort (consistency check demoted to a logged
1011
+ warning), individually guarded pickle fallback, and a raw-file last resort
1012
+ (defer-everything plans; finite score, cannot poison other splits), itself
1013
+ guarded so a split can at worst lose only itself.
1014
+ 4. Rehearsal after fixes: public + private both take the static path (82
1015
+ deaths, own-fleet 0.00), the injected inconsistency logs as a warning,
1016
+ complete 2-split submission.csv in 35s. All-48 checksum byte-exact
1017
+ (1851.0186805555559).
1018
+ 5. Not verifiable locally, accepted as residual risk: held-out fleet size
1019
+ (~4x per the metadata leak; count rule is a ratio, runtime proven by two
1020
+ real submissions), scenario dates/weekdays, and the held-out label-lag
1021
+ process (the ALPHA_STATIC=1.5 bet rides on the undercount direction).
1022
+
1023
  ## Test-look ledger
1024
 
1025
  8 looks total (now historical - see protocol change above); look 9 = the
batteryswap_example/submit.py CHANGED
@@ -40,6 +40,7 @@ from batteryswap_example.features import (
40
  daily_voltage, daily_all, weekly_betas, master_curve, physics_features,
41
  features_at_origin, WINDOW_DAYS, BETA_DEFAULT)
42
  from batteryswap_example.models_cv import SklearnRUL, plan_tsroute
 
43
 
44
  TRAIN_TABLE = Path(__file__).parent / 'data' / 'train_features.parquet'
45
  GRID_STEP_DAYS = 4
@@ -112,8 +113,23 @@ def build_split_table(locations, eol_times, scen_starts, daily, dall, betas,
112
  return table
113
 
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  def adaptive_split_plans(split, split_path, log=print, deadline=None):
116
- locations, timeseries, eol_times, scenarios = load_dataset(split_path)
117
  scen_starts = [pd.Timestamp(s['start_time']) for s in scenarios]
118
  daily = daily_voltage(timeseries)
119
  dall = daily_all(timeseries)
@@ -174,6 +190,19 @@ def adaptive_split_plans(split, split_path, log=print, deadline=None):
174
  # runtime kill with a partial submission
175
  raise TimeoutError('split deadline exceeded mid-adaptive')
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  if static_model is not None:
178
  model, train_uniq = static_model, static_uniq
179
  elif T not in fitted:
@@ -238,7 +267,7 @@ def fallback_split_plans(split, split_path, log=print):
238
  log(f' FALLBACK to static pickle for split={split}')
239
  with open(Path(__file__).parent / 'planners' / 'best.pickle', 'rb') as f:
240
  planner = pickle.load(f)
241
- locations, timeseries, eol_times, scenarios = load_dataset(split_path)
242
  plans = []
243
  for scenario, locs, cut, eol in iterate_scenarios(
244
  locations, timeseries, eol_times, scenarios):
@@ -249,6 +278,32 @@ def fallback_split_plans(split, split_path, log=print):
249
  return plans
250
 
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  def main():
253
  dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', '/tmp/data'))
254
  splits = os.environ.get('BATTERYSWAP_SPLITS', 'public,private').split(',')
@@ -270,7 +325,18 @@ def main():
270
  log=log, deadline=deadline))
271
  except Exception:
272
  traceback.print_exc()
273
- all_plans.extend(fallback_split_plans(split, dataset_path / split, log=log))
 
 
 
 
 
 
 
 
 
 
 
274
 
275
  submission = pd.concat(all_plans)[['day', 'battery', 'split', 'scenario']]
276
  submission.to_csv('submission.csv', index=False)
 
40
  daily_voltage, daily_all, weekly_betas, master_curve, physics_features,
41
  features_at_origin, WINDOW_DAYS, BETA_DEFAULT)
42
  from batteryswap_example.models_cv import SklearnRUL, plan_tsroute
43
+ from batteryswap_example import models_cv
44
 
45
  TRAIN_TABLE = Path(__file__).parent / 'data' / 'train_features.parquet'
46
  GRID_STEP_DAYS = 4
 
113
  return table
114
 
115
 
116
+ def load_best_effort(split_path, log):
117
+ """load_dataset with the consistency check demoted to a logged warning:
118
+ a benign set mismatch in a held-out split must not cost the whole split
119
+ (dress rehearsal: an assertion here killed adaptive AND the fallback,
120
+ which re-raises identically - one bad split -> empty submission)."""
121
+ from batteryswap_public.utils import check_dataset
122
+ locations, timeseries, eol_times, scenarios = load_dataset(
123
+ split_path, check=False)
124
+ try:
125
+ check_dataset(locations, timeseries, eol_times, scenarios)
126
+ except Exception as e:
127
+ log(f' WARNING check_dataset: {e}')
128
+ return locations, timeseries, eol_times, scenarios
129
+
130
+
131
  def adaptive_split_plans(split, split_path, log=print, deadline=None):
132
+ locations, timeseries, eol_times, scenarios = load_best_effort(split_path, log)
133
  scen_starts = [pd.Timestamp(s['start_time']) for s in scenarios]
134
  daily = daily_voltage(timeseries)
135
  dall = daily_all(timeseries)
 
190
  # runtime kill with a partial submission
191
  raise TimeoutError('split deadline exceeded mid-adaptive')
192
 
193
+ # cost_curves prices with module-level penalty constants; the
194
+ # evaluator charges whatever THIS scenario's settings say. Constant
195
+ # 0.5/10.0 across all train scenarios, but wire them through so a
196
+ # held-out scenario with different penalties is priced correctly.
197
+ stg = scenario['settings']
198
+ models_cv.EARLY = float(stg.early_replacement_penalty_daily)
199
+ models_cv.LATE = float(stg.late_replacement_penalty_daily)
200
+ if int(stg.planning_window_days) != WINDOW_DAYS:
201
+ # the 42-day grid is baked into features/labels/quantiles and
202
+ # cannot be rewired at runtime - flag it loudly and plan anyway
203
+ log(f' WARNING {scenario["name"]}: planning_window_days='
204
+ f'{stg.planning_window_days} != {WINDOW_DAYS}')
205
+
206
  if static_model is not None:
207
  model, train_uniq = static_model, static_uniq
208
  elif T not in fitted:
 
267
  log(f' FALLBACK to static pickle for split={split}')
268
  with open(Path(__file__).parent / 'planners' / 'best.pickle', 'rb') as f:
269
  planner = pickle.load(f)
270
+ locations, timeseries, eol_times, scenarios = load_best_effort(split_path, log)
271
  plans = []
272
  for scenario, locs, cut, eol in iterate_scenarios(
273
  locations, timeseries, eol_times, scenarios):
 
278
  return plans
279
 
280
 
281
+ def last_resort_split_plans(split, split_path, log=print):
282
+ """Defer-everything plans from raw files only (no load_dataset, no
283
+ model, no pickle): every battery alive at the scenario origin is placed
284
+ one day past the window, which the evaluator cuts - misses become
285
+ emergencies, but the split scores finitely and validly."""
286
+ import json
287
+ log(f' LAST RESORT (defer plans) for split={split}')
288
+ devices = pd.read_csv(split_path / 'devices.csv')
289
+ eol = pd.read_csv(split_path / 'eol_times.csv')
290
+ eol['end_time'] = pd.to_datetime(eol['end_time'])
291
+ eol = eol.set_index('device_id')['end_time']
292
+ scenarios = json.load(open(split_path / 'scenarios.json'))
293
+ plans = []
294
+ for s in scenarios:
295
+ T = pd.Timestamp(s['start_time'])
296
+ e = eol.reindex(devices['device_id'])
297
+ alive = devices['device_id'][e.isna().values | (e > T).values]
298
+ plans.append(pd.DataFrame({
299
+ 'day': (T + pd.Timedelta(days=WINDOW_DAYS + 1)).normalize(),
300
+ 'battery': alive.values,
301
+ 'split': split,
302
+ 'scenario': s['name'],
303
+ }))
304
+ return plans
305
+
306
+
307
  def main():
308
  dataset_path = Path(os.environ.get('BATTERYSWAP_DATASET_PATH', '/tmp/data'))
309
  splits = os.environ.get('BATTERYSWAP_SPLITS', 'public,private').split(',')
 
325
  log=log, deadline=deadline))
326
  except Exception:
327
  traceback.print_exc()
328
+ try:
329
+ all_plans.extend(
330
+ fallback_split_plans(split, dataset_path / split, log=log))
331
+ except Exception:
332
+ traceback.print_exc()
333
+ try:
334
+ all_plans.extend(
335
+ last_resort_split_plans(split, dataset_path / split, log=log))
336
+ except Exception:
337
+ # partial submission beats no submission: never let one
338
+ # split's failure destroy the other splits' plans
339
+ traceback.print_exc()
340
 
341
  submission = pd.concat(all_plans)[['day', 'battery', 'split', 'scenario']]
342
  submission.to_csv('submission.csv', index=False)