pliny-the-prompter commited on
Commit
b656edc
·
verified ·
1 Parent(s): 287e340

Upload 133 files

Browse files
app.py CHANGED
@@ -297,15 +297,16 @@ def _recover_after_obliterate():
297
  _short = model_choice.split("/")[-1] if "/" in model_choice else model_choice
298
  _label = f"{method} on {_short} ({_ts}) [recovered]"
299
  _last_obliterated_label = _label
300
- _session_models[_label] = {
301
- "model_id": data.get("model_id", model_choice),
302
- "model_choice": model_choice,
303
- "method": method or "unknown",
304
- "dataset_key": "",
305
- "prompt_volume": 0,
306
- "output_dir": save_dir,
307
- "source": "recovered",
308
- }
 
309
 
310
  if not recovered:
311
  with _lock:
@@ -430,15 +431,16 @@ def _recover_sessions_from_disk() -> None:
430
  label = data.get("label", p.name)
431
  if label in _session_models:
432
  continue # already registered
433
- _session_models[label] = {
434
- "model_id": data.get("model_id", ""),
435
- "model_choice": data.get("model_choice", data.get("model_id", "")),
436
- "method": data.get("method", "unknown"),
437
- "dataset_key": data.get("dataset_key", ""),
438
- "prompt_volume": data.get("prompt_volume", 0),
439
- "output_dir": str(p),
440
- "source": data.get("source", "recovered"),
441
- }
 
442
  found_any = True
443
  # Track the latest for auto-select
444
  _last_obliterated_label = label
@@ -453,10 +455,10 @@ def _recover_sessions_from_disk() -> None:
453
  # If we recovered sessions and _state has no valid output_dir, set it to
454
  # the most recent checkpoint so chat_respond can reload from disk.
455
  # Also overwrite a stale output_dir that points to a non-existent path.
456
- _cur_dir = _state.get("output_dir")
457
- _needs_update = not _cur_dir or not Path(_cur_dir).exists()
458
- if found_any and _needs_update:
459
- with _lock:
460
  latest = _last_obliterated_label
461
  if latest and latest in _session_models:
462
  _state["output_dir"] = _session_models[latest]["output_dir"]
@@ -821,6 +823,15 @@ def push_session_to_hub(
821
  if not output_dir or not Path(output_dir).exists():
822
  yield f"**Error:** Model directory not found: `{output_dir}`", ""
823
  return
 
 
 
 
 
 
 
 
 
824
 
825
  # Resolve repo ID
826
  repo_id = hub_repo_id.strip() if hub_repo_id else ""
@@ -1086,11 +1097,9 @@ def _cleanup_disk():
1086
  shutil.rmtree(path, ignore_errors=True)
1087
  freed += size
1088
 
1089
- # Clear session model cache (checkpoints are gone)
1090
- _session_models.clear()
1091
-
1092
- # Clear stale output_dir reference (checkpoints were just deleted)
1093
  with _lock:
 
1094
  _state["output_dir"] = None
1095
  _state["model_name"] = None
1096
  _state["method"] = None
@@ -1574,14 +1583,15 @@ def benchmark(
1574
  bench_save_path = f"/tmp/bench_{method_key}"
1575
  if entry.get("error") is None:
1576
  label = f"{entry['method']} on {model_id.split('/')[-1]}"
1577
- _bench_configs[label] = {
1578
- "model_id": model_id,
1579
- "model_choice": model_choice,
1580
- "method": method_key,
1581
- "dataset_key": dataset_key,
1582
- "prompt_volume": prompt_volume,
1583
- "output_dir": bench_save_path,
1584
- }
 
1585
  _persist_session_meta(bench_save_path, label, {
1586
  "model_id": model_id,
1587
  "model_choice": model_choice,
@@ -1928,14 +1938,15 @@ def benchmark_multi_model(
1928
  mm_save_path = f"/tmp/bench_mm_{mi}"
1929
  if entry.get("error") is None:
1930
  label = f"{method_key} on {model_id.split('/')[-1]}"
1931
- _bench_configs[label] = {
1932
- "model_id": model_id,
1933
- "model_choice": model_display,
1934
- "method": method_key,
1935
- "dataset_key": dataset_key,
1936
- "prompt_volume": prompt_volume,
1937
- "output_dir": mm_save_path,
1938
- }
 
1939
  _persist_session_meta(mm_save_path, label, {
1940
  "model_id": model_id,
1941
  "model_choice": model_display,
@@ -2067,9 +2078,17 @@ def _restore_and_run_stage(pipeline, stage_method_name):
2067
  Module-level function so it is picklable for ZeroGPU serialization.
2068
  Wraps execution in try/except to preserve the full traceback before
2069
  ZeroGPU's error handler reduces it to just the exception class name.
 
 
 
 
2070
  """
2071
  try:
2072
- pipeline._restore_to_gpu()
 
 
 
 
2073
  getattr(pipeline, stage_method_name)()
2074
  except Exception as e:
2075
  import traceback as _tb
@@ -2209,7 +2228,15 @@ def obliterate(model_choice: str, method_choice: str,
2209
  return
2210
 
2211
  # Resolve dataset source — custom prompts override the dropdown
 
2212
  use_custom = custom_harmful and custom_harmful.strip()
 
 
 
 
 
 
 
2213
  dataset_key = get_source_key_from_label(dataset_source_choice) if dataset_source_choice else "builtin"
2214
 
2215
  # Unstick stale "obliterating" status left behind by ZeroGPU timeout
@@ -2366,37 +2393,50 @@ def obliterate(model_choice: str, method_choice: str,
2366
  # ── Staged GPU execution (tourney-style) ──────────────────
2367
  # Each stage gets its own 5-minute GPU allocation instead of
2368
  # sharing a single 300s budget. Between stages the model is
2369
- # moved to CPU and the GPU is released.
 
 
 
2370
  on_log("[staged] ZeroGPU detected — using staged GPU execution (up to 5 min per stage)")
2371
 
2372
- if method == "informed":
2373
- # Informed pipeline: SUMMON+PROBE | ANALYZE+DISTILL+EXCISE | VERIFY+REBIRTH
2374
- on_log("\n\u26a1 [staged] GPU Stage 1/3: SUMMON + PROBE")
2375
- _gpu_run_picklable(pipeline, pipeline.run_stage_summon_probe, time.time())
2376
- pipeline._offload_to_cpu()
2377
- on_log("[staged] GPU released after Stage 1\n")
2378
-
2379
- on_log("\u26a1 [staged] GPU Stage 2/3: ANALYZE + DISTILL + EXCISE")
2380
- _gpu_run_picklable(pipeline, _restore_and_run_stage, pipeline, "run_stage_analyze_distill_excise")
2381
- pipeline._offload_to_cpu()
2382
- on_log("[staged] GPU released after Stage 2\n")
2383
-
2384
- on_log("\u26a1 [staged] GPU Stage 3/3: VERIFY + REBIRTH")
2385
- _gpu_run_picklable(pipeline, _restore_and_run_stage, pipeline, "run_stage_verify_rebirth_informed")
2386
- else:
2387
- # Standard pipeline: SUMMON+PROBE | DISTILL+EXCISE | VERIFY+REBIRTH
2388
- on_log("\n\u26a1 [staged] GPU Stage 1/3: SUMMON + PROBE")
2389
- _gpu_run_picklable(pipeline, pipeline.run_stage_summon_probe, time.time())
2390
- pipeline._offload_to_cpu()
2391
- on_log("[staged] GPU released after Stage 1\n")
2392
-
2393
- on_log("\u26a1 [staged] GPU Stage 2/3: DISTILL + EXCISE")
2394
- _gpu_run_picklable(pipeline, _restore_and_run_stage, pipeline, "run_stage_distill_excise")
2395
- pipeline._offload_to_cpu()
2396
- on_log("[staged] GPU released after Stage 2\n")
2397
-
2398
- on_log("\u26a1 [staged] GPU Stage 3/3: VERIFY + REBIRTH")
2399
- _gpu_run_picklable(pipeline, _restore_and_run_stage, pipeline, "run_stage_verify_rebirth")
 
 
 
 
 
 
 
 
 
 
2400
  else:
2401
  # ── Local/non-ZeroGPU: single-shot execution ──────────────
2402
  on_log(f"[timing] Running locally (no GPU time limit)")
@@ -2531,6 +2571,20 @@ def obliterate(model_choice: str, method_choice: str,
2531
 
2532
  worker.join(timeout=30)
2533
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2534
  # Handle error
2535
  if error_ref[0] is not None:
2536
  _mark_live_log_finished()
@@ -2851,7 +2905,8 @@ def obliterate(model_choice: str, method_choice: str,
2851
  # Set skip flag so the .change handler doesn't trigger a wasteful
2852
  # GPU re-allocation — the model is already loaded.
2853
  global _skip_session_load
2854
- _skip_session_load = 2 # both session_model_dd and ab_session_model_dd fire .change
 
2855
  _dd_update = gr.update(
2856
  choices=_get_session_model_choices(),
2857
  value=_last_obliterated_label or None,
@@ -3151,8 +3206,11 @@ def load_bench_into_chat(choice: str, progress=gr.Progress()):
3151
  # Skip if the obliterate function just set the dropdown value — the model
3152
  # is already loaded and we'd just waste GPU quota re-allocating.
3153
  global _skip_session_load
3154
- if _skip_session_load > 0:
3155
- _skip_session_load -= 1
 
 
 
3156
  # Verify the model is actually usable — not just that status says "ready".
3157
  # ZeroGPU can evict the model while status stays "ready", and the counter
3158
  # can get out of sync if only one dropdown .change fires instead of both.
@@ -3336,6 +3394,7 @@ def load_bench_into_chat(choice: str, progress=gr.Progress()):
3336
  _clear_gpu()
3337
  with _lock:
3338
  _state["status"] = "idle"
 
3339
  yield (
3340
  f"**Error:** Could not load {choice} from checkpoint (GPU too small).",
3341
  get_chat_header(),
@@ -3391,6 +3450,7 @@ def load_bench_into_chat(choice: str, progress=gr.Progress()):
3391
  if error_ref[0] is not None:
3392
  with _lock:
3393
  _state["status"] = "idle"
 
3394
  yield f"**Error loading {choice}:** {error_ref[0]}", get_chat_header()
3395
  return
3396
 
@@ -3400,6 +3460,7 @@ def load_bench_into_chat(choice: str, progress=gr.Progress()):
3400
  _state["tokenizer"] = pipeline.handle.tokenizer
3401
  _state["steering"] = None
3402
  _state["status"] = "ready"
 
3403
  _state["output_dir"] = "/tmp/obliterated" # re-abliteration fallback path
3404
 
3405
  pipeline_ref[0] = None
@@ -3640,6 +3701,9 @@ def ab_chat_respond(message: str, history_left: list[dict], history_right: list[
3640
 
3641
  except Exception as e:
3642
  original_response = f"*Could not load original model for comparison: {e}*"
 
 
 
3643
 
3644
  # Restore abliterated model to GPU for subsequent chat/operations.
3645
  # Use torch.device("cuda") rather than the captured abl_device, since
@@ -3728,7 +3792,7 @@ def strength_sweep(model_choice: str, method_choice: str,
3728
  nonlocal run_error
3729
  run_error = e
3730
 
3731
- worker = threading.Thread(target=_run_sweep_point)
3732
  worker.start()
3733
  while worker.is_alive():
3734
  worker.join(timeout=2.0)
@@ -4122,15 +4186,16 @@ def export_artifacts():
4122
  import zipfile
4123
  import os
4124
 
4125
- if _state["status"] != "ready":
4126
- return None, "No abliterated model loaded. Run obliteration first."
 
 
 
 
 
4127
 
4128
  export_dir = tempfile.mkdtemp(prefix="obliteratus_export_")
4129
 
4130
- model_name = _state.get("model_name", "unknown")
4131
- method = _state.get("method", "unknown")
4132
- log_lines = _state.get("log", [])
4133
-
4134
  exported_files = []
4135
 
4136
  # 1. Pipeline log
@@ -4145,7 +4210,6 @@ def export_artifacts():
4145
  exported_files.append("pipeline_log.txt")
4146
 
4147
  # 2. Steering metadata (refusal directions + strong layers)
4148
- steering = _state.get("steering")
4149
  if steering:
4150
  # Save directions as .pt
4151
  directions = steering.get("refusal_directions", {})
 
297
  _short = model_choice.split("/")[-1] if "/" in model_choice else model_choice
298
  _label = f"{method} on {_short} ({_ts}) [recovered]"
299
  _last_obliterated_label = _label
300
+ with _lock:
301
+ _session_models[_label] = {
302
+ "model_id": data.get("model_id", model_choice),
303
+ "model_choice": model_choice,
304
+ "method": method or "unknown",
305
+ "dataset_key": "",
306
+ "prompt_volume": 0,
307
+ "output_dir": save_dir,
308
+ "source": "recovered",
309
+ }
310
 
311
  if not recovered:
312
  with _lock:
 
431
  label = data.get("label", p.name)
432
  if label in _session_models:
433
  continue # already registered
434
+ with _lock:
435
+ _session_models[label] = {
436
+ "model_id": data.get("model_id", ""),
437
+ "model_choice": data.get("model_choice", data.get("model_id", "")),
438
+ "method": data.get("method", "unknown"),
439
+ "dataset_key": data.get("dataset_key", ""),
440
+ "prompt_volume": data.get("prompt_volume", 0),
441
+ "output_dir": str(p),
442
+ "source": data.get("source", "recovered"),
443
+ }
444
  found_any = True
445
  # Track the latest for auto-select
446
  _last_obliterated_label = label
 
455
  # If we recovered sessions and _state has no valid output_dir, set it to
456
  # the most recent checkpoint so chat_respond can reload from disk.
457
  # Also overwrite a stale output_dir that points to a non-existent path.
458
+ with _lock:
459
+ _cur_dir = _state.get("output_dir")
460
+ _needs_update = not _cur_dir or not Path(_cur_dir).exists()
461
+ if found_any and _needs_update:
462
  latest = _last_obliterated_label
463
  if latest and latest in _session_models:
464
  _state["output_dir"] = _session_models[latest]["output_dir"]
 
823
  if not output_dir or not Path(output_dir).exists():
824
  yield f"**Error:** Model directory not found: `{output_dir}`", ""
825
  return
826
+ # Validate output_dir is under /tmp to prevent directory traversal
827
+ try:
828
+ _resolved = Path(output_dir).resolve()
829
+ if not str(_resolved).startswith("/tmp/"):
830
+ yield "**Error:** Model directory must be under `/tmp`.", ""
831
+ return
832
+ except Exception:
833
+ yield "**Error:** Invalid model directory path.", ""
834
+ return
835
 
836
  # Resolve repo ID
837
  repo_id = hub_repo_id.strip() if hub_repo_id else ""
 
1097
  shutil.rmtree(path, ignore_errors=True)
1098
  freed += size
1099
 
1100
+ # Clear session model cache and stale state (checkpoints are gone)
 
 
 
1101
  with _lock:
1102
+ _session_models.clear()
1103
  _state["output_dir"] = None
1104
  _state["model_name"] = None
1105
  _state["method"] = None
 
1583
  bench_save_path = f"/tmp/bench_{method_key}"
1584
  if entry.get("error") is None:
1585
  label = f"{entry['method']} on {model_id.split('/')[-1]}"
1586
+ with _lock:
1587
+ _bench_configs[label] = {
1588
+ "model_id": model_id,
1589
+ "model_choice": model_choice,
1590
+ "method": method_key,
1591
+ "dataset_key": dataset_key,
1592
+ "prompt_volume": prompt_volume,
1593
+ "output_dir": bench_save_path,
1594
+ }
1595
  _persist_session_meta(bench_save_path, label, {
1596
  "model_id": model_id,
1597
  "model_choice": model_choice,
 
1938
  mm_save_path = f"/tmp/bench_mm_{mi}"
1939
  if entry.get("error") is None:
1940
  label = f"{method_key} on {model_id.split('/')[-1]}"
1941
+ with _lock:
1942
+ _bench_configs[label] = {
1943
+ "model_id": model_id,
1944
+ "model_choice": model_display,
1945
+ "method": method_key,
1946
+ "dataset_key": dataset_key,
1947
+ "prompt_volume": prompt_volume,
1948
+ "output_dir": mm_save_path,
1949
+ }
1950
  _persist_session_meta(mm_save_path, label, {
1951
  "model_id": model_id,
1952
  "model_choice": model_display,
 
2078
  Module-level function so it is picklable for ZeroGPU serialization.
2079
  Wraps execution in try/except to preserve the full traceback before
2080
  ZeroGPU's error handler reduces it to just the exception class name.
2081
+
2082
+ If the pipeline model is not in memory (ZeroGPU state loss), the stage
2083
+ method itself handles recovery via ``_reload_model_for_stage()`` and
2084
+ ``_load_staged_state()`` when ``_staged_state_dir`` is set.
2085
  """
2086
  try:
2087
+ # Try to restore model to GPU if it's already in memory (same-process
2088
+ # case or non-ZeroGPU). If the model is None (ZeroGPU state loss),
2089
+ # skip — the stage method handles recovery.
2090
+ if pipeline.handle is not None and pipeline.handle.model is not None:
2091
+ pipeline._restore_to_gpu()
2092
  getattr(pipeline, stage_method_name)()
2093
  except Exception as e:
2094
  import traceback as _tb
 
2228
  return
2229
 
2230
  # Resolve dataset source — custom prompts override the dropdown
2231
+ _MAX_CUSTOM_PROMPT_LINES = 10_000
2232
  use_custom = custom_harmful and custom_harmful.strip()
2233
+ if use_custom and custom_harmful.count("\n") > _MAX_CUSTOM_PROMPT_LINES:
2234
+ yield (
2235
+ f"**Error:** Custom prompts exceed {_MAX_CUSTOM_PROMPT_LINES} lines. "
2236
+ "Please reduce the number of prompts to avoid memory exhaustion.",
2237
+ "", gr.update(), gr.update(), gr.update(), gr.update(),
2238
+ )
2239
+ return
2240
  dataset_key = get_source_key_from_label(dataset_source_choice) if dataset_source_choice else "builtin"
2241
 
2242
  # Unstick stale "obliterating" status left behind by ZeroGPU timeout
 
2393
  # ── Staged GPU execution (tourney-style) ──────────────────
2394
  # Each stage gets its own 5-minute GPU allocation instead of
2395
  # sharing a single 300s budget. Between stages the model is
2396
+ # saved to disk so state survives ZeroGPU's cross-process
2397
+ # serialization (each @spaces.GPU call runs in a separate
2398
+ # worker process that pickles args, so in-memory mutations
2399
+ # to the pipeline don't propagate back).
2400
  on_log("[staged] ZeroGPU detected — using staged GPU execution (up to 5 min per stage)")
2401
 
2402
+ # Create a temp dir for cross-process state persistence
2403
+ import tempfile as _tempfile
2404
+ _staged_dir = _tempfile.mkdtemp(prefix="obliterate_staged_")
2405
+ pipeline._staged_state_dir = _staged_dir
2406
+ on_log(f"[staged] State persistence dir: {_staged_dir}")
2407
+
2408
+ try:
2409
+ if method == "informed":
2410
+ # Informed pipeline: SUMMON+PROBE | ANALYZE+DISTILL+EXCISE | VERIFY+REBIRTH
2411
+ on_log("\n\u26a1 [staged] GPU Stage 1/3: SUMMON + PROBE")
2412
+ _gpu_run_picklable(pipeline, pipeline.run_stage_summon_probe, time.time())
2413
+ on_log("[staged] GPU released after Stage 1\n")
2414
+
2415
+ on_log("\u26a1 [staged] GPU Stage 2/3: ANALYZE + DISTILL + EXCISE")
2416
+ _gpu_run_picklable(pipeline, _restore_and_run_stage, pipeline, "run_stage_analyze_distill_excise")
2417
+ on_log("[staged] GPU released after Stage 2\n")
2418
+
2419
+ on_log("\u26a1 [staged] GPU Stage 3/3: VERIFY + REBIRTH")
2420
+ _gpu_run_picklable(pipeline, _restore_and_run_stage, pipeline, "run_stage_verify_rebirth_informed")
2421
+ else:
2422
+ # Standard pipeline: SUMMON+PROBE | DISTILL+EXCISE | VERIFY+REBIRTH
2423
+ on_log("\n\u26a1 [staged] GPU Stage 1/3: SUMMON + PROBE")
2424
+ _gpu_run_picklable(pipeline, pipeline.run_stage_summon_probe, time.time())
2425
+ on_log("[staged] GPU released after Stage 1\n")
2426
+
2427
+ on_log("\u26a1 [staged] GPU Stage 2/3: DISTILL + EXCISE")
2428
+ _gpu_run_picklable(pipeline, _restore_and_run_stage, pipeline, "run_stage_distill_excise")
2429
+ on_log("[staged] GPU released after Stage 2\n")
2430
+
2431
+ on_log("\u26a1 [staged] GPU Stage 3/3: VERIFY + REBIRTH")
2432
+ _gpu_run_picklable(pipeline, _restore_and_run_stage, pipeline, "run_stage_verify_rebirth")
2433
+ finally:
2434
+ # Clean up staged state temp dir
2435
+ import shutil as _shutil
2436
+ try:
2437
+ _shutil.rmtree(_staged_dir, ignore_errors=True)
2438
+ except Exception:
2439
+ pass
2440
  else:
2441
  # ── Local/non-ZeroGPU: single-shot execution ──────────────
2442
  on_log(f"[timing] Running locally (no GPU time limit)")
 
2571
 
2572
  worker.join(timeout=30)
2573
 
2574
+ # If worker is still alive after join timeout, it's hung — treat as error
2575
+ if worker.is_alive():
2576
+ _mark_live_log_finished()
2577
+ log_lines.append("\nERROR: Pipeline worker thread did not finish within 30s after loop exit.")
2578
+ with _lock:
2579
+ _state["status"] = "idle"
2580
+ _state["obliterate_started_at"] = None
2581
+ _state["log"] = log_lines
2582
+ yield (
2583
+ "**Error:** Pipeline worker hung after completion. Check logs for details.",
2584
+ "\n".join(log_lines), get_chat_header(), gr.update(), gr.update(), gr.update(),
2585
+ )
2586
+ return
2587
+
2588
  # Handle error
2589
  if error_ref[0] is not None:
2590
  _mark_live_log_finished()
 
2905
  # Set skip flag so the .change handler doesn't trigger a wasteful
2906
  # GPU re-allocation — the model is already loaded.
2907
  global _skip_session_load
2908
+ with _lock:
2909
+ _skip_session_load = 2 # both session_model_dd and ab_session_model_dd fire .change
2910
  _dd_update = gr.update(
2911
  choices=_get_session_model_choices(),
2912
  value=_last_obliterated_label or None,
 
3206
  # Skip if the obliterate function just set the dropdown value — the model
3207
  # is already loaded and we'd just waste GPU quota re-allocating.
3208
  global _skip_session_load
3209
+ with _lock:
3210
+ _should_skip = _skip_session_load > 0
3211
+ if _should_skip:
3212
+ _skip_session_load -= 1
3213
+ if _should_skip:
3214
  # Verify the model is actually usable — not just that status says "ready".
3215
  # ZeroGPU can evict the model while status stays "ready", and the counter
3216
  # can get out of sync if only one dropdown .change fires instead of both.
 
3394
  _clear_gpu()
3395
  with _lock:
3396
  _state["status"] = "idle"
3397
+ _state["obliterate_started_at"] = None
3398
  yield (
3399
  f"**Error:** Could not load {choice} from checkpoint (GPU too small).",
3400
  get_chat_header(),
 
3450
  if error_ref[0] is not None:
3451
  with _lock:
3452
  _state["status"] = "idle"
3453
+ _state["obliterate_started_at"] = None
3454
  yield f"**Error loading {choice}:** {error_ref[0]}", get_chat_header()
3455
  return
3456
 
 
3460
  _state["tokenizer"] = pipeline.handle.tokenizer
3461
  _state["steering"] = None
3462
  _state["status"] = "ready"
3463
+ _state["obliterate_started_at"] = None
3464
  _state["output_dir"] = "/tmp/obliterated" # re-abliteration fallback path
3465
 
3466
  pipeline_ref[0] = None
 
3701
 
3702
  except Exception as e:
3703
  original_response = f"*Could not load original model for comparison: {e}*"
3704
+ # Ensure GPU memory is freed even if original model load/gen failed
3705
+ gc.collect()
3706
+ dev.empty_cache()
3707
 
3708
  # Restore abliterated model to GPU for subsequent chat/operations.
3709
  # Use torch.device("cuda") rather than the captured abl_device, since
 
3792
  nonlocal run_error
3793
  run_error = e
3794
 
3795
+ worker = threading.Thread(target=_run_sweep_point, daemon=True)
3796
  worker.start()
3797
  while worker.is_alive():
3798
  worker.join(timeout=2.0)
 
4186
  import zipfile
4187
  import os
4188
 
4189
+ with _lock:
4190
+ if _state["status"] != "ready":
4191
+ return None, "No abliterated model loaded. Run obliteration first."
4192
+ model_name = _state.get("model_name", "unknown")
4193
+ method = _state.get("method", "unknown")
4194
+ log_lines = list(_state.get("log", [])) # copy to avoid mutation
4195
+ steering = _state.get("steering")
4196
 
4197
  export_dir = tempfile.mkdtemp(prefix="obliteratus_export_")
4198
 
 
 
 
 
4199
  exported_files = []
4200
 
4201
  # 1. Pipeline log
 
4210
  exported_files.append("pipeline_log.txt")
4211
 
4212
  # 2. Steering metadata (refusal directions + strong layers)
 
4213
  if steering:
4214
  # Save directions as .pt
4215
  directions = steering.get("refusal_directions", {})
obliteratus/abliterate.py CHANGED
@@ -763,6 +763,7 @@ class AbliterationPipeline:
763
  self.refinement_passes = min(self.refinement_passes, 1)
764
 
765
  self.handle: ModelHandle | None = None
 
766
  self._staged_t0: float = time.time() # overwritten in run_stage_summon_probe; init here for safety
767
  self.refusal_directions: dict[int, torch.Tensor] = {} # per-layer primary direction
768
  self.refusal_subspaces: dict[int, torch.Tensor] = {} # per-layer SVD subspace (n_dirs x hidden)
@@ -998,6 +999,10 @@ class AbliterationPipeline:
998
 
999
  After this stage the caller should call ``_offload_to_cpu()`` to
1000
  move the model off GPU before releasing the ZeroGPU allocation.
 
 
 
 
1001
  """
1002
  for h in self._steering_hooks:
1003
  h.remove()
@@ -1010,6 +1015,8 @@ class AbliterationPipeline:
1010
  self._probe()
1011
  self.log(f"[timing] PROBE complete at +{time.time() - self._staged_t0:.1f}s")
1012
  self._free_gpu_memory()
 
 
1013
 
1014
  def run_stage_distill_excise(self):
1015
  """GPU Stage 2: Extract directions, modify weights, save checkpoint
@@ -1019,14 +1026,19 @@ class AbliterationPipeline:
1019
  move the model off GPU before releasing the ZeroGPU allocation.
1020
  """
1021
  if self.handle is None or self.handle.model is None:
1022
- raise RuntimeError(
1023
- "Model not loaded at start of DISTILL+EXCISE stage. "
1024
- "This usually means ZeroGPU pickling lost pipeline state "
1025
- "between GPU stages (model was loaded in stage 1's worker "
1026
- "but not propagated back to the main process). "
1027
- f"handle={self.handle!r}, _harmful_means has "
1028
- f"{len(self._harmful_means)} layers"
1029
- )
 
 
 
 
 
1030
  # Reset time budget for this GPU allocation
1031
  self._pipeline_start_time = time.time()
1032
  self._distill()
@@ -1053,10 +1065,16 @@ class AbliterationPipeline:
1053
  Path to the saved model directory.
1054
  """
1055
  if self.handle is None or self.handle.model is None:
1056
- raise RuntimeError(
1057
- "Model not loaded at start of VERIFY+REBIRTH stage. "
1058
- "Pipeline state may have been lost between GPU stages."
1059
- )
 
 
 
 
 
 
1060
  # Reset time budget for this GPU allocation
1061
  self._pipeline_start_time = time.time()
1062
  self._verify()
@@ -1111,11 +1129,9 @@ class AbliterationPipeline:
1111
  self.refusal_subspaces[k] = v.cpu()
1112
 
1113
  # Move baseline KL logits to CPU
1114
- if hasattr(self, "_kl_baseline_logits") and self._kl_baseline_logits is not None:
1115
- self._kl_baseline_logits = [
1116
- t.cpu() if isinstance(t, torch.Tensor) and t.device.type != "cpu" else t
1117
- for t in self._kl_baseline_logits
1118
- ]
1119
 
1120
  dev.free_gpu_memory()
1121
  elapsed = time.time() - t0
@@ -1137,9 +1153,108 @@ class AbliterationPipeline:
1137
  self.log(f"[staged] Moving model to {target}...")
1138
  t0 = time.time()
1139
  self.handle.model.to(target)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1140
  elapsed = time.time() - t0
1141
  self.log(f"[staged] Model restored to {target} ({elapsed:.1f}s)")
1142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1143
  # ── Stage 1: SUMMON ─────────────────────────���───────────────────────
1144
 
1145
  def _summon(self):
@@ -6233,7 +6348,17 @@ class AbliterationPipeline:
6233
  dat_file = Path(offload_dir) / f"{key}.dat"
6234
  if safetensors_file.exists():
6235
  data = load_file(str(safetensors_file))
6236
- state_dict[key] = data[key] if key in data else next(iter(data.values()))
 
 
 
 
 
 
 
 
 
 
6237
  elif dat_file.exists():
6238
  # Accelerate's .dat format: raw tensor bytes with shape/dtype metadata
6239
  import numpy as np
 
763
  self.refinement_passes = min(self.refinement_passes, 1)
764
 
765
  self.handle: ModelHandle | None = None
766
+ self._staged_state_dir: str | None = None # temp dir for cross-process state persistence (ZeroGPU)
767
  self._staged_t0: float = time.time() # overwritten in run_stage_summon_probe; init here for safety
768
  self.refusal_directions: dict[int, torch.Tensor] = {} # per-layer primary direction
769
  self.refusal_subspaces: dict[int, torch.Tensor] = {} # per-layer SVD subspace (n_dirs x hidden)
 
999
 
1000
  After this stage the caller should call ``_offload_to_cpu()`` to
1001
  move the model off GPU before releasing the ZeroGPU allocation.
1002
+
1003
+ When ``_staged_state_dir`` is set, intermediate state (activations,
1004
+ means, routing data) is saved to disk at the end of this stage so
1005
+ it can survive ZeroGPU's cross-process serialization boundary.
1006
  """
1007
  for h in self._steering_hooks:
1008
  h.remove()
 
1015
  self._probe()
1016
  self.log(f"[timing] PROBE complete at +{time.time() - self._staged_t0:.1f}s")
1017
  self._free_gpu_memory()
1018
+ # Save state to disk so it survives ZeroGPU worker process boundary
1019
+ self._save_staged_state()
1020
 
1021
  def run_stage_distill_excise(self):
1022
  """GPU Stage 2: Extract directions, modify weights, save checkpoint
 
1026
  move the model off GPU before releasing the ZeroGPU allocation.
1027
  """
1028
  if self.handle is None or self.handle.model is None:
1029
+ if self._staged_state_dir:
1030
+ self.log("[staged] Model not in memory recovering from ZeroGPU state loss...")
1031
+ self._reload_model_for_stage()
1032
+ self._load_staged_state()
1033
+ else:
1034
+ raise RuntimeError(
1035
+ "Model not loaded at start of DISTILL+EXCISE stage. "
1036
+ "This usually means ZeroGPU pickling lost pipeline state "
1037
+ "between GPU stages (model was loaded in stage 1's worker "
1038
+ "but not propagated back to the main process). "
1039
+ f"handle={self.handle!r}, _harmful_means has "
1040
+ f"{len(self._harmful_means)} layers"
1041
+ )
1042
  # Reset time budget for this GPU allocation
1043
  self._pipeline_start_time = time.time()
1044
  self._distill()
 
1065
  Path to the saved model directory.
1066
  """
1067
  if self.handle is None or self.handle.model is None:
1068
+ if self._staged_state_dir:
1069
+ # Load the modified model from the quick checkpoint saved
1070
+ # by Stage 2 (weights were modified by EXCISE).
1071
+ self.log("[staged] Model not in memory — recovering from ZeroGPU state loss...")
1072
+ self._reload_model_for_stage(source=str(self.output_dir))
1073
+ else:
1074
+ raise RuntimeError(
1075
+ "Model not loaded at start of VERIFY+REBIRTH stage. "
1076
+ "Pipeline state may have been lost between GPU stages."
1077
+ )
1078
  # Reset time budget for this GPU allocation
1079
  self._pipeline_start_time = time.time()
1080
  self._verify()
 
1129
  self.refusal_subspaces[k] = v.cpu()
1130
 
1131
  # Move baseline KL logits to CPU
1132
+ if self._baseline_first_token_logits is not None:
1133
+ if isinstance(self._baseline_first_token_logits, torch.Tensor) and self._baseline_first_token_logits.device.type != "cpu":
1134
+ self._baseline_first_token_logits = self._baseline_first_token_logits.cpu()
 
 
1135
 
1136
  dev.free_gpu_memory()
1137
  elapsed = time.time() - t0
 
1153
  self.log(f"[staged] Moving model to {target}...")
1154
  t0 = time.time()
1155
  self.handle.model.to(target)
1156
+
1157
+ # Move cached tensors back to GPU (mirrors _offload_to_cpu)
1158
+ for tensor_dict in (
1159
+ self._harmful_means, self._harmless_means,
1160
+ self._jailbreak_means,
1161
+ ):
1162
+ for k in list(tensor_dict.keys()):
1163
+ if isinstance(tensor_dict[k], torch.Tensor) and tensor_dict[k].device != target:
1164
+ tensor_dict[k] = tensor_dict[k].to(target)
1165
+
1166
+ for k in list(self.refusal_directions.keys()):
1167
+ v = self.refusal_directions[k]
1168
+ if isinstance(v, torch.Tensor) and v.device != target:
1169
+ self.refusal_directions[k] = v.to(target)
1170
+ for k in list(self.refusal_subspaces.keys()):
1171
+ v = self.refusal_subspaces[k]
1172
+ if isinstance(v, torch.Tensor) and v.device != target:
1173
+ self.refusal_subspaces[k] = v.to(target)
1174
+
1175
  elapsed = time.time() - t0
1176
  self.log(f"[staged] Model restored to {target} ({elapsed:.1f}s)")
1177
 
1178
+ def _save_staged_state(self):
1179
+ """Save intermediate pipeline state to disk for ZeroGPU cross-process persistence.
1180
+
1181
+ ZeroGPU runs each ``@spaces.GPU``-decorated call in a separate worker
1182
+ process. Mutations to the pipeline (model loading, activation
1183
+ collection) happen on a pickled copy inside the worker and are NOT
1184
+ propagated back. This method saves the state that subsequent stages
1185
+ need to a temporary directory on disk so it survives the process
1186
+ boundary.
1187
+ """
1188
+ if not self._staged_state_dir:
1189
+ return
1190
+ import os
1191
+ os.makedirs(self._staged_state_dir, exist_ok=True)
1192
+ t0 = time.time()
1193
+
1194
+ def _cpu(t):
1195
+ return t.cpu() if isinstance(t, torch.Tensor) and t.device.type != "cpu" else t
1196
+
1197
+ state = {
1198
+ "_staged_t0": self._staged_t0,
1199
+ "_pipeline_start_time": getattr(self, "_pipeline_start_time", self._staged_t0),
1200
+ "_harmful_acts": {k: [_cpu(t) for t in v] for k, v in self._harmful_acts.items()},
1201
+ "_harmless_acts": {k: [_cpu(t) for t in v] for k, v in self._harmless_acts.items()},
1202
+ "_jailbreak_acts": {k: [_cpu(t) for t in v] for k, v in self._jailbreak_acts.items()},
1203
+ "_harmful_means": {k: _cpu(v) for k, v in self._harmful_means.items()},
1204
+ "_harmless_means": {k: _cpu(v) for k, v in self._harmless_means.items()},
1205
+ "_jailbreak_means": {k: _cpu(v) for k, v in self._jailbreak_means.items()},
1206
+ "_routing_harmful": {k: [_cpu(t) for t in v] for k, v in self._routing_harmful.items()},
1207
+ "_routing_harmless": {k: [_cpu(t) for t in v] for k, v in self._routing_harmless.items()},
1208
+ "_baseline_first_token_logits": _cpu(self._baseline_first_token_logits) if self._baseline_first_token_logits is not None else None,
1209
+ "_kl_eval_prompts": list(self._kl_eval_prompts),
1210
+ }
1211
+ state_path = os.path.join(self._staged_state_dir, "staged_state.pt")
1212
+ torch.save(state, state_path)
1213
+ elapsed = time.time() - t0
1214
+ self.log(f"[staged] Saved intermediate state to disk ({elapsed:.1f}s)")
1215
+
1216
+ def _load_staged_state(self):
1217
+ """Load intermediate pipeline state saved by ``_save_staged_state``.
1218
+
1219
+ Called at the start of Stage 2 (and later) when ZeroGPU pickling
1220
+ caused the in-memory state to be lost.
1221
+ """
1222
+ import os
1223
+ state_path = os.path.join(self._staged_state_dir, "staged_state.pt")
1224
+ if not os.path.exists(state_path):
1225
+ raise RuntimeError(
1226
+ f"Staged state file not found at {state_path}. "
1227
+ "Stage 1 may not have saved its state correctly."
1228
+ )
1229
+ t0 = time.time()
1230
+ state = torch.load(state_path, weights_only=False)
1231
+ for key, value in state.items():
1232
+ setattr(self, key, value)
1233
+ elapsed = time.time() - t0
1234
+ self.log(f"[staged] Loaded intermediate state from disk ({elapsed:.1f}s)")
1235
+
1236
+ def _reload_model_for_stage(self, source: str | None = None):
1237
+ """Reload the model for a new GPU stage after ZeroGPU state loss.
1238
+
1239
+ Args:
1240
+ source: Path to load the model from. If None, reloads from the
1241
+ original HuggingFace model (for Stage 2, where weights are
1242
+ unmodified). If a path (e.g. ``self.output_dir``), loads
1243
+ from a previously saved checkpoint (for Stage 3, after
1244
+ weight modification by EXCISE).
1245
+ """
1246
+ model_name = source or self.model_name
1247
+ self.log(f"[staged] Reloading model from {'checkpoint' if source else 'HF cache'}: {model_name}")
1248
+ self.handle = load_model(
1249
+ model_name=model_name,
1250
+ task="causal_lm",
1251
+ device=self.device,
1252
+ dtype=self.dtype,
1253
+ trust_remote_code=self.trust_remote_code,
1254
+ quantization=self.quantization,
1255
+ )
1256
+ self.log(f"[staged] Model reloaded successfully")
1257
+
1258
  # ── Stage 1: SUMMON ─────────────────────────���───────────────────────
1259
 
1260
  def _summon(self):
 
6348
  dat_file = Path(offload_dir) / f"{key}.dat"
6349
  if safetensors_file.exists():
6350
  data = load_file(str(safetensors_file))
6351
+ if key in data:
6352
+ state_dict[key] = data[key]
6353
+ elif len(data) == 1:
6354
+ # Accelerate stores single-tensor files; use the only tensor
6355
+ state_dict[key] = next(iter(data.values()))
6356
+ else:
6357
+ raise RuntimeError(
6358
+ f"Cannot resolve meta tensor '{key}': safetensors file "
6359
+ f"contains {len(data)} tensors but key not found. "
6360
+ f"Available keys: {list(data.keys())}"
6361
+ )
6362
  elif dat_file.exists():
6363
  # Accelerate's .dat format: raw tensor bytes with shape/dtype metadata
6364
  import numpy as np
obliteratus/informed_pipeline.py CHANGED
@@ -306,6 +306,16 @@ class InformedAbliterationPipeline(AbliterationPipeline):
306
 
307
  def run_stage_analyze_distill_excise(self):
308
  """GPU Stage 2 (informed): ANALYZE + DISTILL + EXCISE + quick checkpoint."""
 
 
 
 
 
 
 
 
 
 
309
  # Reset time budget for this GPU allocation
310
  self._pipeline_start_time = time.time()
311
  self._analyze()
@@ -319,6 +329,15 @@ class InformedAbliterationPipeline(AbliterationPipeline):
319
  Stores the result path and report in instance attributes so the
320
  caller can retrieve them after the GPU allocation returns.
321
  """
 
 
 
 
 
 
 
 
 
322
  # Reset time budget for this GPU allocation
323
  self._pipeline_start_time = time.time()
324
  self._verify_and_compensate()
 
306
 
307
  def run_stage_analyze_distill_excise(self):
308
  """GPU Stage 2 (informed): ANALYZE + DISTILL + EXCISE + quick checkpoint."""
309
+ if self.handle is None or self.handle.model is None:
310
+ if self._staged_state_dir:
311
+ self.log("[staged] Model not in memory — recovering from ZeroGPU state loss...")
312
+ self._reload_model_for_stage()
313
+ self._load_staged_state()
314
+ else:
315
+ raise RuntimeError(
316
+ "Model not loaded at start of ANALYZE+DISTILL+EXCISE stage. "
317
+ "Pipeline state may have been lost between GPU stages."
318
+ )
319
  # Reset time budget for this GPU allocation
320
  self._pipeline_start_time = time.time()
321
  self._analyze()
 
329
  Stores the result path and report in instance attributes so the
330
  caller can retrieve them after the GPU allocation returns.
331
  """
332
+ if self.handle is None or self.handle.model is None:
333
+ if self._staged_state_dir:
334
+ self.log("[staged] Model not in memory — recovering from ZeroGPU state loss...")
335
+ self._reload_model_for_stage(source=str(self.output_dir))
336
+ else:
337
+ raise RuntimeError(
338
+ "Model not loaded at start of VERIFY+REBIRTH stage. "
339
+ "Pipeline state may have been lost between GPU stages."
340
+ )
341
  # Reset time budget for this GPU allocation
342
  self._pipeline_start_time = time.time()
343
  self._verify_and_compensate()
obliteratus/telemetry.py CHANGED
@@ -622,31 +622,33 @@ def restore_from_hub() -> int:
622
  return 0
623
 
624
  try:
625
- # Read existing local keys for dedup
626
- existing_keys: set[tuple[str, str]] = set()
627
- if TELEMETRY_FILE.exists():
628
- try:
629
- with open(TELEMETRY_FILE) as f:
630
- for line in f:
631
- line = line.strip()
632
- if not line:
633
- continue
634
- try:
635
- r = json.loads(line)
636
- existing_keys.add(
637
- (r.get("session_id", ""), r.get("timestamp", ""))
638
- )
639
- except json.JSONDecodeError:
640
- continue
641
- except Exception:
642
- pass
643
-
644
  hub_records = fetch_hub_records()
645
  if not hub_records:
646
  return 0
647
 
648
  new_count = 0
649
  with _write_lock:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
650
  with open(TELEMETRY_FILE, "a") as f:
651
  for r in hub_records:
652
  key = (r.get("session_id", ""), r.get("timestamp", ""))
 
622
  return 0
623
 
624
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
  hub_records = fetch_hub_records()
626
  if not hub_records:
627
  return 0
628
 
629
  new_count = 0
630
  with _write_lock:
631
+ # Read existing local keys for dedup INSIDE the write lock to
632
+ # prevent a concurrent log_benchmark() from inserting between
633
+ # our read and append, which would cause duplicate records.
634
+ existing_keys: set[tuple[str, str]] = set()
635
+ if TELEMETRY_FILE.exists():
636
+ try:
637
+ with open(TELEMETRY_FILE) as f:
638
+ for line in f:
639
+ line = line.strip()
640
+ if not line:
641
+ continue
642
+ try:
643
+ r = json.loads(line)
644
+ existing_keys.add(
645
+ (r.get("session_id", ""), r.get("timestamp", ""))
646
+ )
647
+ except json.JSONDecodeError:
648
+ continue
649
+ except Exception:
650
+ pass
651
+
652
  with open(TELEMETRY_FILE, "a") as f:
653
  for r in hub_records:
654
  key = (r.get("session_id", ""), r.get("timestamp", ""))
obliteratus/tourney.py CHANGED
@@ -70,10 +70,15 @@ def composite_score(metrics: dict[str, Any]) -> float:
70
  spec = metrics.get("spectral_certification")
71
  degen = metrics.get("degenerate_count", 0) or 0
72
 
73
- refusal_score = (1.0 - rr) if rr is not None else 0.0
74
- coherence_score = co if co is not None else 0.0
75
- kl_score = 1.0 / (1.0 + kl) if kl is not None else 0.5
76
- ppl_score = 1.0 / (1.0 + pp / 100.0) if pp is not None else 0.5
 
 
 
 
 
77
 
78
  # Spectral certification: GREEN=1.0, YELLOW=0.5, RED=0.0, None=0.5 (neutral)
79
  if spec == "GREEN":
@@ -254,7 +259,11 @@ def _save_checkpoint(
254
  "timestamp": datetime.now().isoformat(),
255
  }
256
  path = output_dir / CHECKPOINT_FILENAME
257
- path.write_text(json.dumps(checkpoint, indent=2))
 
 
 
 
258
  return path
259
 
260
 
@@ -1202,10 +1211,10 @@ class TourneyRunner:
1202
  # Determine alive list from checkpoint
1203
  alive = list(checkpoint.get("alive", self.methods))
1204
 
1205
- # Remove the checkpoint file now that we've loaded it
1206
- ckpt_path = self.output_dir / CHECKPOINT_FILENAME
1207
- if ckpt_path.exists():
1208
- ckpt_path.unlink()
1209
  else:
1210
  # Checkpoint doesn't match current config — start fresh
1211
  checkpoint = None
@@ -1393,6 +1402,11 @@ class TourneyRunner:
1393
  result.rounds.append(rnd)
1394
  alive = list(rnd.advanced_to)
1395
 
 
 
 
 
 
1396
  # Schedule next round dynamically
1397
  if round_num == 1 and len(alive) > 1:
1398
  r2_advance = max(2, math.ceil(len(alive) / 2))
 
70
  spec = metrics.get("spectral_certification")
71
  degen = metrics.get("degenerate_count", 0) or 0
72
 
73
+ import math
74
+
75
+ def _finite(v: Any) -> bool:
76
+ return v is not None and isinstance(v, (int, float)) and math.isfinite(v)
77
+
78
+ refusal_score = (1.0 - rr) if _finite(rr) else 0.0
79
+ coherence_score = co if _finite(co) else 0.0
80
+ kl_score = 1.0 / (1.0 + max(kl, 0.0)) if _finite(kl) else 0.5
81
+ ppl_score = 1.0 / (1.0 + max(pp, 0.0) / 100.0) if _finite(pp) else 0.5
82
 
83
  # Spectral certification: GREEN=1.0, YELLOW=0.5, RED=0.0, None=0.5 (neutral)
84
  if spec == "GREEN":
 
259
  "timestamp": datetime.now().isoformat(),
260
  }
261
  path = output_dir / CHECKPOINT_FILENAME
262
+ # Write to temp file then rename for crash-safe atomicity —
263
+ # prevents corrupted checkpoint if process is killed mid-write.
264
+ tmp_path = path.with_suffix(".tmp")
265
+ tmp_path.write_text(json.dumps(checkpoint, indent=2))
266
+ tmp_path.replace(path)
267
  return path
268
 
269
 
 
1211
  # Determine alive list from checkpoint
1212
  alive = list(checkpoint.get("alive", self.methods))
1213
 
1214
+ # NOTE: Don't delete checkpoint here if the round crashes
1215
+ # before completion, we lose all progress. The checkpoint is
1216
+ # overwritten by _save_checkpoint() on quota error, or deleted
1217
+ # after the resumed round finishes successfully (see below).
1218
  else:
1219
  # Checkpoint doesn't match current config — start fresh
1220
  checkpoint = None
 
1402
  result.rounds.append(rnd)
1403
  alive = list(rnd.advanced_to)
1404
 
1405
+ # Clean up stale checkpoint now that the round completed successfully
1406
+ _stale_ckpt = self.output_dir / CHECKPOINT_FILENAME
1407
+ if _stale_ckpt.exists():
1408
+ _stale_ckpt.unlink(missing_ok=True)
1409
+
1410
  # Schedule next round dynamically
1411
  if round_num == 1 and len(alive) > 1:
1412
  r2_advance = max(2, math.ceil(len(alive) / 2))