Phase 27: held-out-parity audit - penalty wiring + 3-layer failure isolation
Browse filesDress 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>
- EXPERIMENTS.md +34 -0
- batteryswap_example/submit.py +69 -3
|
@@ -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
|
|
@@ -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 =
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|