diff options
| author | YurenHao0426 <Blackhao0426@gmail.com> | 2026-07-22 19:59:28 -0500 |
|---|---|---|
| committer | YurenHao0426 <Blackhao0426@gmail.com> | 2026-07-22 19:59:28 -0500 |
| commit | 2b8b6168a486778251746f50247e917a124e6630 (patch) | |
| tree | 3ad443fce56b22966272144fd663d603d193c5c8 | |
| parent | 3c19f11acf3ebe290df439a3cb0558bdab0b3522 (diff) | |
protocol: freeze D4-gated oral B recovery
| -rw-r--r-- | ORAL_B_RECOVERY.md | 59 | ||||
| -rw-r--r-- | experiments/analyze_bci_td_development.py | 201 | ||||
| -rw-r--r-- | experiments/bci_td_development_screen.sh | 15 | ||||
| -rw-r--r-- | experiments/bci_td_run.py | 274 |
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() |
