summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYurenHao0426 <Blackhao0426@gmail.com>2026-07-22 19:59:28 -0500
committerYurenHao0426 <Blackhao0426@gmail.com>2026-07-22 19:59:28 -0500
commit2b8b6168a486778251746f50247e917a124e6630 (patch)
tree3ad443fce56b22966272144fd663d603d193c5c8
parent3c19f11acf3ebe290df439a3cb0558bdab0b3522 (diff)
protocol: freeze D4-gated oral B recovery
-rw-r--r--ORAL_B_RECOVERY.md59
-rw-r--r--experiments/analyze_bci_td_development.py201
-rw-r--r--experiments/bci_td_development_screen.sh15
-rw-r--r--experiments/bci_td_run.py274
4 files changed, 540 insertions, 9 deletions
diff --git a/ORAL_B_RECOVERY.md b/ORAL_B_RECOVERY.md
index 5880101..2d9e8bf 100644
--- a/ORAL_B_RECOVERY.md
+++ b/ORAL_B_RECOVERY.md
@@ -63,12 +63,53 @@ only permits a separately committed training-only structural screen after the
D4 accept confirmation closes. No success-rate endpoint, hyperparameter
selection, or oral-B score change is authorized by R0.
-## Boundary for the next gate
-
-Before any recovery task run, R1 must freeze its development-only task seeds,
-forward/vectorizer rates, role-estimator cadence, cost accounting, and a stop
-rule requiring both positive derivative sign and actual learning. A sign that
-is positive merely because `delta_t` was inserted is not evidence of credit
-assignment. Confirmation seeds 10--15 from the original oral-B protocol
-remain untouched unless an R1 candidate passes every frozen learning,
-innovation, role-vectorization, and plasticity-lesion requirement.
+## R1 frozen development gate
+
+R1 is hard-gated in code on a complete D4 pass with reviewer score 7. It reuses
+only the already-development task seeds 0--2 and model seed 0; confirmation
+seeds 10--15 remain untouched. It tests exactly two forward rates, `{0.03,
+0.1}`, with vectorizer rate `0.03`, 14 days, 64 episodes per day, 28 steps per
+episode, one role perturbation event every four temporal steps, `kappa=0`, and
+all other dynamics copied from the failed original screen.
+
+Before task learning, every condition receives 100 instruction-off batches of
+64 neural-state probes. The per-cell neutral predictor uses these batches; two
+scalar antithetic cursor observations per example calibrate the learned-role
+conditions. Fixed-vectorizer ignores the role targets but consumes the same
+observations and random stream. The oracle-role condition explicitly reads the
+synthetic environment map only as a labelled diagnostic ceiling and is
+ineligible for selection. Neutral warmup must leave maximum predictor error at
+most `1e-5`; all warmup and online scalar observations are reported.
+
+For each rate and task seed, run four paired conditions on identical context,
+noise, perturbations, and evaluation trajectories:
+
+1. intact learned-role temporal-difference innovation;
+2. fixed random vectorizer;
+3. plasticity lesion with role learning intact; and
+4. an exact-role diagnostic oracle.
+
+Every one of the three task seeds must pass every requirement:
+
+- early-to-late success gain at least 10 points and final development success
+ at least 70%;
+- final success at least 20 points above fixed vectorizer and no more than 10
+ points below the exact-role oracle;
+- causal-role sign-inversion index at least `0.01` and absolute CV correlation
+ advantage of performance velocity over error magnitude at least `0.05`;
+- mean absolute innovation-soma correlation at most `0.10`, with raw minus
+ innovation correlation at least `0.20`;
+- the plasticity lesion retains at most half the intact learning gain; and
+- learned role cosine at least `0.80`, with all trajectories and audited values
+ finite.
+
+Select the eligible rate with the largest worst-task final success, breaking a
+tie by the smaller rate. If neither rate is eligible, the recovery closes and
+no confirmation is run. R1 is development evidence and cannot change the
+reviewer score. A sign that is positive merely because `delta_t` was inserted
+is therefore insufficient: it must coexist with actual learning, vectorizer
+necessity, innovation identification, and a causal plasticity lesion.
+
+The executable runner, complete-grid analyzer, and shell entry point are
+`experiments/bci_td_run.py`, `experiments/analyze_bci_td_development.py`, and
+`experiments/bci_td_development_screen.sh`. None may run before D4 closes.
diff --git a/experiments/analyze_bci_td_development.py b/experiments/analyze_bci_td_development.py
new file mode 100644
index 0000000..74e80e8
--- /dev/null
+++ b/experiments/analyze_bci_td_development.py
@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+"""Audit and select the D4-gated temporal-difference oral-B R1 screen."""
+import argparse
+import glob
+import hashlib
+import json
+import math
+import os
+
+
+ETAS = (0.03, 0.1)
+TASK_SEEDS = (0, 1, 2)
+
+
+def require(condition, message):
+ if not condition:
+ raise ValueError(message)
+
+
+def sha256(path):
+ digest = hashlib.sha256()
+ with open(path, "rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def finite_tree(value):
+ if isinstance(value, dict):
+ return all(finite_tree(item) for item in value.values())
+ if isinstance(value, (list, tuple)):
+ return all(finite_tree(item) for item in value)
+ if isinstance(value, (int, float)):
+ return math.isfinite(value)
+ return True
+
+
+def validate(row, path, d4_gate):
+ require(row.get("schema_version") == 1, f"{path}: schema")
+ protocol = row.get("protocol", {})
+ require(protocol.get("name") == "oral_b_td_development_v1",
+ f"{path}: protocol")
+ require(protocol.get("selection_split") == "development",
+ f"{path}: split")
+ require(protocol.get("confirmation_seeds_touched") is False,
+ f"{path}: confirmation leakage")
+ args = row.get("args", {})
+ require(args.get("task_seed") in TASK_SEEDS, f"{path}: task seed")
+ require(args.get("model_seed") == 0, f"{path}: model seed")
+ require(float(args.get("eta")) in ETAS, f"{path}: eta")
+ require(protocol.get("evaluation_task_seed") == args["task_seed"] + 200_000,
+ f"{path}: evaluation seed")
+ require(args.get("d4_gate") == d4_gate, f"{path}: D4 gate path")
+ config = row.get("config", {})
+ expected = {
+ "n_plus": 5, "n_minus": 5, "n_background": 30,
+ "context_dim": 16, "steps_per_episode": 28,
+ "episodes_per_day": 64, "days": 14, "target": 0.8,
+ "inertia": 0.65, "process_noise": 0.12, "context_ar": 0.8,
+ "coupling_scale": 1.0, "predictor_eta": 0.2,
+ "vectorizer_eta": 0.03, "forward_eta": args["eta"],
+ "perturb_sigma": 0.03, "perturb_every": 4,
+ "kappa": 0.0, "feedback": "performance_velocity",
+ }
+ for key, value in expected.items():
+ require(config.get(key) == value, f"{path}: config {key}")
+ source = row.get("provenance", {})
+ require(source.get("git_tracked_dirty") is False, f"{path}: dirty")
+ require(source.get("runner_and_protocol_tracked") is True,
+ f"{path}: untracked")
+ require(len(source.get("git_commit", "")) == 40, f"{path}: commit")
+ require(row.get("finite") is True and finite_tree(row), f"{path}: finite")
+ for name in ("intact", "fixed_vectorizer", "plasticity_lesion",
+ "oracle_role"):
+ warmup = row["warmup"][name]
+ require(warmup["batches"] == 100 and warmup["examples"] == 6400,
+ f"{path}: warmup count {name}")
+ require(warmup["instruction_present"] is False,
+ f"{path}: warmup instruction {name}")
+ require(warmup["role_cursor_scalar_observations"] == 12800,
+ f"{path}: role observations {name}")
+ require(warmup["predictor_max_abs_error"] <= 1e-5,
+ f"{path}: predictor warmup {name}")
+ condition = row["conditions"][name]
+ require(len(condition["daily_success"]) == 14,
+ f"{path}: trajectory {name}")
+
+
+def seed_checks(row):
+ intact = row["conditions"]["intact"]
+ fixed = row["conditions"]["fixed_vectorizer"]
+ lesion = row["conditions"]["plasticity_lesion"]
+ oracle = row["conditions"]["oracle_role"]
+ signatures = row["signatures"]
+ return {
+ "learning_gain_at_least_0p10": intact["learning_gain"] >= 0.10,
+ "final_success_at_least_0p70": intact["final_success"] >= 0.70,
+ "fixed_vectorizer_gap_at_least_0p20": (
+ intact["final_success"] - fixed["final_success"] >= 0.20),
+ "within_0p10_of_oracle_role": (
+ oracle["final_success"] - intact["final_success"] <= 0.10),
+ "sign_inversion_at_least_0p01": (
+ signatures["causal_role_sign_inversion_index"] >= 0.01),
+ "velocity_advantage_at_least_0p05": (
+ signatures["velocity_minus_error_abs_cv_corr"] >= 0.05),
+ "residual_soma_corr_at_most_0p10": (
+ signatures["mean_abs_residual_soma_corr"] <= 0.10),
+ "raw_minus_residual_corr_at_least_0p20": (
+ signatures["raw_minus_residual_abs_soma_corr"] >= 0.20),
+ "plasticity_lesion_removes_half_gain": (
+ lesion["learning_gain"] <= 0.5 * intact["learning_gain"]),
+ "learned_role_cosine_at_least_0p80": (
+ intact["role_cosine_after_training"] >= 0.80),
+ }
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--results", default="results/bci_td_dev")
+ parser.add_argument(
+ "--d4_gate", default="results/kp_dynamic_projection_confirmation_gate.json")
+ parser.add_argument("--out", default="results/bci_td_dev_gate.json")
+ args = parser.parse_args()
+ with open(args.d4_gate) as handle:
+ d4 = json.load(handle)
+ require(d4.get("protocol") ==
+ "kp_dynamic_neutral_projection_confirmation_v1", "D4 protocol")
+ require(d4.get("status") == "passed", "D4 did not pass")
+ require(d4.get("review_score_after") == 7, "D4 score rule")
+ paths = sorted(glob.glob(os.path.join(args.results, "bci_td_v1_*.json")))
+ require(len(paths) == 6, f"expected six R1 records, found {len(paths)}")
+ records = {}
+ commits = set()
+ for path in paths:
+ with open(path) as handle:
+ row = json.load(handle)
+ validate(row, path, args.d4_gate)
+ key = (float(row["args"]["eta"]), row["args"]["task_seed"])
+ require(key not in records, f"duplicate R1 cell {key}")
+ records[key] = row
+ commits.add(row["provenance"]["git_commit"])
+ require(set(records) == {(eta, seed) for eta in ETAS for seed in TASK_SEEDS},
+ "R1 grid drift")
+ require(len(commits) == 1, "R1 source revision drift")
+
+ candidates = []
+ for eta in ETAS:
+ rows = [records[(eta, seed)] for seed in TASK_SEEDS]
+ checks = [seed_checks(row) for row in rows]
+ candidates.append({
+ "eta": eta,
+ "eligible": all(all(values.values()) for values in checks),
+ "seed_checks": checks,
+ "task_seeds": list(TASK_SEEDS),
+ "final_success": [row["conditions"]["intact"]["final_success"]
+ for row in rows],
+ "learning_gain": [row["conditions"]["intact"]["learning_gain"]
+ for row in rows],
+ "sign_inversion": [row["signatures"][
+ "causal_role_sign_inversion_index"] for row in rows],
+ "velocity_advantage": [row["signatures"][
+ "velocity_minus_error_abs_cv_corr"] for row in rows],
+ "role_cosine": [row["conditions"]["intact"][
+ "role_cosine_after_training"] for row in rows],
+ "worst_final_success": min(
+ row["conditions"]["intact"]["final_success"] for row in rows),
+ })
+ eligible = [candidate for candidate in candidates if candidate["eligible"]]
+ selected = None
+ if eligible:
+ eligible.sort(key=lambda value: (-value["worst_final_success"],
+ value["eta"]))
+ selected = {
+ "eta": eligible[0]["eta"],
+ "worst_final_success": eligible[0]["worst_final_success"],
+ }
+ output = {
+ "protocol": "oral_b_td_development_v1",
+ "status": "passed" if selected else "failed_no_eligible_candidate",
+ "complete_grid": True,
+ "confirmation_seeds_touched": False,
+ "selected": selected,
+ "candidates": candidates,
+ "source_commit": next(iter(commits)),
+ "source_sha256": {path: sha256(path) for path in paths},
+ "oral_b_confirmation_opened": selected is not None,
+ "review_score_before": 7,
+ "review_score_after": 7,
+ "score_change_rule": (
+ "R1 is development evidence and cannot change the paper score"),
+ }
+ if os.path.exists(args.out):
+ raise FileExistsError(f"refusing to overwrite {args.out}")
+ with open(args.out, "w") as handle:
+ json.dump(output, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+ print(json.dumps(output, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/experiments/bci_td_development_screen.sh b/experiments/bci_td_development_screen.sh
new file mode 100644
index 0000000..82fbe84
--- /dev/null
+++ b/experiments/bci_td_development_screen.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# D4-gated frozen oral-B R1 screen: 2 rates x task seeds 0..2.
+set -eu
+
+cd "$(dirname "$0")/.."
+PY=/home/yurenh2/miniconda3/envs/ep_pascal/bin/python3
+
+for eta in 0.03 0.1; do
+ for task_seed in 0 1 2; do
+ OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 "$PY" \
+ experiments/bci_td_run.py --eta "$eta" --task-seed "$task_seed"
+ done
+done
+
+"$PY" experiments/analyze_bci_td_development.py
diff --git a/experiments/bci_td_run.py b/experiments/bci_td_run.py
new file mode 100644
index 0000000..195bd22
--- /dev/null
+++ b/experiments/bci_td_run.py
@@ -0,0 +1,274 @@
+#!/usr/bin/env python3
+"""Run one D4-gated temporal-difference oral-B recovery candidate."""
+import argparse
+import hashlib
+import json
+import math
+import os
+import platform
+import resource
+import subprocess
+import sys
+import time
+
+import torch
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from sdil.bci import BCIConfig, BCISDIL, generate_trajectories, run_day
+from sdil.bci_metrics import annotate_day_events, signature_metrics
+
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+PROTOCOL_PATH = os.path.join(ROOT, "ORAL_B_RECOVERY.md")
+CONDITIONS = ("intact", "fixed_vectorizer", "plasticity_lesion", "oracle_role")
+
+
+def sha256(path):
+ digest = hashlib.sha256()
+ with open(path, "rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def provenance():
+ def run(command):
+ return subprocess.run(
+ command, cwd=ROOT, check=True, capture_output=True,
+ text=True).stdout.strip()
+ runner = os.path.relpath(os.path.abspath(__file__), ROOT)
+ protocol = os.path.relpath(PROTOCOL_PATH, ROOT)
+ return {
+ "git_commit": run(["git", "rev-parse", "HEAD"]),
+ "git_tracked_dirty": bool(run(
+ ["git", "status", "--porcelain", "--untracked-files=no"])),
+ "runner_and_protocol_tracked": all(subprocess.run(
+ ["git", "ls-files", "--error-unmatch", path], cwd=ROOT,
+ capture_output=True).returncode == 0
+ for path in (runner, protocol)),
+ "protocol_sha256": sha256(PROTOCOL_PATH),
+ }
+
+
+def finite_tree(value):
+ if isinstance(value, dict):
+ return all(finite_tree(item) for item in value.values())
+ if isinstance(value, (list, tuple)):
+ return all(finite_tree(item) for item in value)
+ if isinstance(value, (int, float)):
+ return math.isfinite(value)
+ return True
+
+
+def build_config(eta):
+ return BCIConfig(
+ n_plus=5, n_minus=5, n_background=30, context_dim=16,
+ steps_per_episode=28, episodes_per_day=64, days=14,
+ target=0.8, inertia=0.65, process_noise=0.12, context_ar=0.8,
+ coupling_scale=1.0, predictor_eta=0.2, vectorizer_eta=0.03,
+ forward_eta=eta, perturb_sigma=0.03, perturb_every=4,
+ kappa=0.0, feedback="performance_velocity")
+
+
+def neutral_warmup(initial, task_seed, model_seed, condition, batches=100):
+ """Fit neutral coupling and, when allowed, the cursor-role vectorizer."""
+ model = initial.clone()
+ generator = torch.Generator(device="cpu").manual_seed(
+ 400_000 + 100 * task_seed + model_seed)
+ active = torch.ones(model.cfg.episodes_per_day, dtype=torch.bool)
+ role_cursor_observations = 0
+ for _ in range(batches):
+ soma = torch.randn(
+ model.cfg.episodes_per_day, model.cfg.n_neurons,
+ generator=generator).tanh()
+ ordinary = model.coupling * soma
+ model.update_predictor(soma, ordinary, active)
+ xi = torch.empty_like(soma).bernoulli_(
+ 0.5, generator=generator).mul_(2).sub_(1)
+ sigma = model.cfg.perturb_sigma
+ # The environment produces two scalar cursor observations. The role
+ # target function cannot access the environment's role map.
+ cursor_plus = (soma + sigma * xi) @ model.role
+ cursor_minus = (soma - sigma * xi) @ model.role
+ role_cursor_observations += 2 * soma.shape[0]
+ target = model.causal_role_targets(cursor_plus, cursor_minus, xi)
+ if condition == "intact" or condition == "plasticity_lesion":
+ model.update_vectorizer(
+ torch.ones(soma.shape[0], 1),
+ torch.zeros_like(soma), target, active)
+ if condition == "oracle_role":
+ # Explicit diagnostic ceiling; never eligible as the selected learner.
+ model.A[:, 0].copy_(model.role)
+ predictor_error = float((model.P - model.coupling).abs().max())
+ role_cosine = float(torch.nn.functional.cosine_similarity(
+ model.A[:, 0], model.role, dim=0).item())
+ return model, {
+ "batches": batches,
+ "examples": batches * model.cfg.episodes_per_day,
+ "instruction_present": False,
+ "role_cursor_scalar_observations": role_cursor_observations,
+ "predictor_max_abs_error": predictor_error,
+ "role_cosine_after_warmup": role_cosine,
+ "rng_seed": 400_000 + 100 * task_seed + model_seed,
+ }
+
+
+def condition_settings(name):
+ return {
+ "intact": {"plasticity_gain": 1.0, "learn_vectorizer": True},
+ "fixed_vectorizer": {
+ "plasticity_gain": 1.0, "learn_vectorizer": False},
+ "plasticity_lesion": {
+ "plasticity_gain": 0.0, "learn_vectorizer": True},
+ "oracle_role": {"plasticity_gain": 1.0, "learn_vectorizer": False},
+ }[name]
+
+
+def train(model, trajectories, condition, collect):
+ settings = condition_settings(condition)
+ daily_success = []
+ events = []
+ global_step = 0
+ start = time.perf_counter()
+ for day in range(model.cfg.days):
+ report = run_day(
+ model, trajectories, day, global_step=global_step,
+ control_gain=0.0, plasticity_gain=settings["plasticity_gain"],
+ learn_vectorizer=settings["learn_vectorizer"],
+ learn_predictor=True, collect=collect)
+ global_step = report["global_step"]
+ daily_success.append(report["success_rate"])
+ if collect:
+ annotate_day_events(
+ report["events"], day, report["success"],
+ episode_offset=day * model.cfg.episodes_per_day)
+ events.extend(report["events"])
+ perturbation_events = sum(
+ 1 for step in range(model.cfg.days * model.cfg.steps_per_episode)
+ if step % model.cfg.perturb_every == 0)
+ conservative_cursor_observations = (
+ 2 * model.cfg.episodes_per_day * perturbation_events
+ if settings["learn_vectorizer"] else 0)
+ return events, {
+ "daily_success": daily_success,
+ "early_success": sum(daily_success[:3]) / 3,
+ "late_success": sum(daily_success[-3:]) / 3,
+ "learning_gain": (
+ sum(daily_success[-3:]) - sum(daily_success[:3])) / 3,
+ "training_wall_s": time.perf_counter() - start,
+ "role_cosine_after_training": float(
+ torch.nn.functional.cosine_similarity(
+ model.A[:, 0], model.role, dim=0).item()),
+ "cost": {
+ "ordinary_state_episode_steps": (
+ model.cfg.days * model.cfg.episodes_per_day
+ * model.cfg.steps_per_episode),
+ "online_role_perturbation_events": perturbation_events,
+ "conservative_online_cursor_scalar_observations": (
+ conservative_cursor_observations),
+ "ordinary_performance_change_observations": (
+ model.cfg.days * model.cfg.episodes_per_day
+ * model.cfg.steps_per_episode),
+ },
+ }
+
+
+def evaluate(model, trajectories, collect):
+ report = run_day(
+ model.clone(), trajectories, 0, control_gain=0.0,
+ plasticity_gain=0.0, learn_vectorizer=False, learn_predictor=False,
+ collect=collect, global_step=0)
+ if collect:
+ annotate_day_events(report["events"], 0, report["success"], 1_000_000)
+ return report
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--d4_gate", default="results/kp_dynamic_projection_confirmation_gate.json")
+ parser.add_argument("--task-seed", type=int, choices=(0, 1, 2), required=True)
+ parser.add_argument("--model-seed", type=int, choices=(0,), default=0)
+ parser.add_argument("--eta", type=float, choices=(0.03, 0.1), required=True)
+ parser.add_argument("--outdir", default="results/bci_td_dev")
+ args = parser.parse_args()
+ with open(args.d4_gate) as handle:
+ d4 = json.load(handle)
+ if (d4.get("protocol") !=
+ "kp_dynamic_neutral_projection_confirmation_v1"
+ or d4.get("status") != "passed"
+ or d4.get("review_score_after") != 7):
+ raise ValueError("oral-B R1 requires the complete audited D4 pass")
+ source = provenance()
+ if source["git_tracked_dirty"] or not source["runner_and_protocol_tracked"]:
+ raise RuntimeError("R1 requires clean tracked source and protocol")
+
+ cfg = build_config(args.eta)
+ trajectories = generate_trajectories(cfg, args.task_seed)
+ evaluation = generate_trajectories(
+ cfg, args.task_seed + 200_000, days=1, episodes=256)
+ initial = BCISDIL(cfg, args.model_seed)
+ conditions = {}
+ trained = {}
+ training_events = None
+ warmups = {}
+ started = time.perf_counter()
+ for name in CONDITIONS:
+ model, warmup = neutral_warmup(
+ initial, args.task_seed, args.model_seed, name)
+ events, report = train(
+ model, trajectories, name, collect=name == "intact")
+ warmups[name] = warmup
+ trained[name] = model
+ conditions[name] = report
+ if name == "intact":
+ training_events = events
+ evaluation_events = None
+ for name in CONDITIONS:
+ report = evaluate(
+ trained[name], evaluation, collect=name == "intact")
+ conditions[name]["final_success"] = report["success_rate"]
+ if name == "intact":
+ evaluation_events = report["events"]
+ signatures = signature_metrics(
+ training_events, evaluation_events, cfg, trained["intact"].role)
+ result = {
+ "schema_version": 1,
+ "protocol": {
+ "name": "oral_b_td_development_v1",
+ "selection_split": "development",
+ "confirmation_seeds_touched": False,
+ "evaluation_task_seed": args.task_seed + 200_000,
+ "d4_gate_source": args.d4_gate,
+ },
+ "args": vars(args), "config": vars(cfg), "provenance": source,
+ "warmup": warmups, "conditions": conditions,
+ "signatures": signatures,
+ "wall_s": time.perf_counter() - started,
+ "peak_rss_mib": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024,
+ "hardware": {
+ "device": "cpu", "platform": platform.platform(),
+ "torch_version": torch.__version__, "threads": torch.get_num_threads(),
+ },
+ }
+ result["finite"] = finite_tree(result)
+ if not result["finite"]:
+ raise RuntimeError("non-finite oral-B R1 record")
+ os.makedirs(args.outdir, exist_ok=True)
+ eta_tag = str(args.eta).replace(".", "p")
+ path = os.path.join(
+ args.outdir, f"bci_td_v1_eta{eta_tag}_t{args.task_seed}_m0.json")
+ if os.path.exists(path):
+ raise FileExistsError(f"refusing to overwrite {path}")
+ with open(path, "w") as handle:
+ json.dump(result, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+ print(json.dumps({
+ "path": path, "finite": result["finite"],
+ "intact_final": conditions["intact"]["final_success"],
+ "sign_inversion": signatures["causal_role_sign_inversion_index"],
+ }, indent=2))
+
+
+if __name__ == "__main__":
+ main()