450 lines
31 KiB
Python
450 lines
31 KiB
Python
#!/usr/bin/env python3
|
|
"""Ordered Phase 4P-B dynamic revalidation on the canonical runtime.
|
|
|
|
R1/R2 consume the already sealed Phase 4P results. R3-R6 are run in order
|
|
and the script stops immediately if a level cannot reproduce its declared
|
|
historical result. No frozen artifact is written.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pyarrow.parquet as pq
|
|
import torch
|
|
import yaml
|
|
|
|
import phase4p
|
|
from phase4l import pn_drive
|
|
from phase4m import load_odor_inputs
|
|
|
|
|
|
ROOT = Path("/data/digital-drosophila")
|
|
|
|
|
|
def save(path: Path, value: object) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(value, indent=2, sort_keys=True, default=str) + "\n")
|
|
|
|
|
|
def ahash(*values: np.ndarray) -> str:
|
|
h = hashlib.sha256()
|
|
for value in values: h.update(np.ascontiguousarray(value).tobytes())
|
|
return h.hexdigest()
|
|
|
|
|
|
def metrics(a: np.ndarray, b: np.ndarray) -> dict[str, float]:
|
|
a = a.astype(np.float64); b = b.astype(np.float64)
|
|
aa = a > 0; bb = b > 0; union = np.count_nonzero(aa | bb)
|
|
den = np.linalg.norm(a) * np.linalg.norm(b)
|
|
return {"euclidean": float(np.linalg.norm(a - b)),
|
|
"jaccard": float(np.count_nonzero(aa & bb) / union) if union else 1.0,
|
|
"cosine": float(np.dot(a, b) / den) if den else (1.0 if not np.any(a) and not np.any(b) else 0.0)}
|
|
|
|
|
|
class DeterministicAPLSim:
|
|
"""Frozen APL overlay on top of the Phase 4P canonical LIF kernel."""
|
|
def __init__(self, rt: phase4p.CanonicalRuntime):
|
|
self.rt = rt; self.n = rt.n; self.device = rt.device; self.dt = rt.dt
|
|
meta = rt.table.to_pandas().sort_values("neuron_idx").reset_index(drop=True)
|
|
self.meta = meta
|
|
cls = meta.annotation_class.fillna("").astype(str).to_numpy()
|
|
typ = meta.annotation_type.fillna("").astype(str).to_numpy()
|
|
self.kc = np.flatnonzero(cls == "Kenyon_Cell").astype(np.int32)
|
|
self.mbon = np.flatnonzero(cls == "MBON").astype(np.int32)
|
|
self.pn = np.flatnonzero(cls == "ALPN").astype(np.int32)
|
|
self.apl = np.flatnonzero(typ == "APL").astype(np.int32)
|
|
if len(self.apl) != 2 or len(self.kc) != 4064: raise RuntimeError("frozen APL/KC identity changed")
|
|
self.sources = np.repeat(np.arange(self.n, dtype=np.int32), np.diff(rt.indptr_np).astype(np.int64))
|
|
idx = rt.indices_np; raw = rt.raw_np
|
|
apl_out = np.isin(self.sources, self.apl)
|
|
self.apl_out_ids = np.flatnonzero(apl_out).astype(np.int64)
|
|
input_matrix = np.zeros((len(self.apl), self.n), np.float32)
|
|
output_matrix = np.zeros((len(self.apl), len(self.kc)), np.float32)
|
|
kc_lookup = np.full(self.n, -1, np.int32); kc_lookup[self.kc] = np.arange(len(self.kc), dtype=np.int32)
|
|
allowed = np.isin(self.sources, np.concatenate((self.kc, self.pn))) & np.isin(idx, self.apl)
|
|
apl_lookup = {int(x): i for i, x in enumerate(self.apl)}
|
|
for eid in np.flatnonzero(allowed): input_matrix[apl_lookup[int(idx[eid])], int(self.sources[eid])] += float(raw[eid])
|
|
for eid in np.flatnonzero(apl_out & np.isin(idx, self.kc)):
|
|
output_matrix[apl_lookup[int(self.sources[eid])], kc_lookup[int(idx[eid])]] += float(raw[eid])
|
|
self.input_scale = float(input_matrix[input_matrix > 0].sum() / np.count_nonzero(np.any(input_matrix > 0, axis=0)))
|
|
self.output_scale = float(output_matrix[output_matrix > 0].mean())
|
|
self.input_gpu = torch.as_tensor(input_matrix / self.input_scale, dtype=torch.float32, device=self.device)
|
|
self.output_gpu = torch.as_tensor(output_matrix / self.output_scale, dtype=torch.float32, device=self.device)
|
|
self.kc_gpu = torch.as_tensor(self.kc, dtype=torch.int64, device=self.device)
|
|
self.apl_out_gpu = torch.as_tensor(self.apl_out_ids, dtype=torch.int64, device=self.device)
|
|
self.base = rt.weights.clone()
|
|
p4h = ROOT / "experiments/phase4h-dopamine-plasticity"
|
|
self.identity = pd.read_parquet(ROOT / "experiments/phase4n-credit-assignment/edge-identity/frozen-edge-identity.parquet")
|
|
self.edge_ids = self.identity.edge_id.to_numpy(np.int64)
|
|
self.edge_gpu = torch.as_tensor(self.edge_ids, dtype=torch.int64, device=self.device)
|
|
self.base_candidate = self.base[self.edge_gpu].cpu().numpy().astype(np.float32)
|
|
self.confirmed = pd.read_parquet(p4h / "mappings/dan-to-mbon-confirmed.parquet")
|
|
down = pd.read_parquet(p4h / "mappings/mbon-to-downstream-actual-edges.parquet")
|
|
self.downstream = np.unique(down.target_neuron_idx.to_numpy(np.int32))
|
|
|
|
def dan_targets(self, dtype: str, compartment: str) -> np.ndarray:
|
|
x = self.confirmed[(self.confirmed.source_annotation_type == dtype) &
|
|
(self.confirmed.source_compartment_verbatim == compartment)]
|
|
out = np.sort(x.source_neuron_idx.unique().astype(np.int32))
|
|
if not len(out): raise RuntimeError(f"no confirmed DAN for {dtype}/{compartment}")
|
|
return out
|
|
|
|
def install(self, E: np.ndarray | None) -> None:
|
|
self.rt.weights.copy_(self.base)
|
|
if E is not None:
|
|
self.rt.weights[self.edge_gpu] = torch.as_tensor(self.base_candidate * E.astype(np.float32), device=self.device)
|
|
self.rt.weights[self.apl_out_gpu] = 0.0
|
|
|
|
def run(self, targets: np.ndarray, rates_hz: np.ndarray, seed: int, duration_ms: float,
|
|
E: np.ndarray | None = None, dan_targets: np.ndarray | None = None,
|
|
dan_pulses: list[float] | None = None, collect_events: bool = False) -> dict[str, Any]:
|
|
targets = np.asarray(targets, np.int32); rates_hz = np.asarray(rates_hz, np.float32)
|
|
self.install(E); self.rt.reset(); steps = int(round(duration_ms / self.dt))
|
|
target_gpu = torch.as_tensor(targets, dtype=torch.int64, device=self.device)
|
|
gen = torch.Generator(device=self.device); gen.manual_seed(int(seed))
|
|
probs = torch.as_tensor(rates_hz * self.dt / 1000.0, dtype=torch.float32, device=self.device)
|
|
forced = (torch.rand((steps, len(targets)), generator=gen, device=self.device) < probs[None, :]).to(torch.uint8)
|
|
dan = np.empty(0, np.int32) if dan_targets is None else np.asarray(dan_targets, np.int32)
|
|
dan_gpu = torch.as_tensor(dan, dtype=torch.int64, device=self.device)
|
|
dan_forced = torch.zeros((steps, len(dan)), dtype=torch.uint8, device=self.device)
|
|
for pulse in dan_pulses or []:
|
|
s = int(round(pulse / self.dt))
|
|
if 0 <= s < steps: dan_forced[s] = True
|
|
dan_steps = {int(round(x / self.dt)) for x in (dan_pulses or [])}
|
|
apl_state = torch.zeros(len(self.apl), dtype=torch.float32, device=self.device)
|
|
kc_trace = torch.zeros(len(self.kc), dtype=torch.float32, device=self.device)
|
|
first = torch.full((self.n,), -1, dtype=torch.int32, device=self.device)
|
|
trace_at_dan = []
|
|
apl_decay = np.float32(math.exp(-self.dt / 20.0)); trace_decay = np.float32(math.exp(-self.dt / 100.0))
|
|
kc_steps: list[np.ndarray] = []; kc_nodes: list[np.ndarray] = []
|
|
torch.cuda.synchronize(); t0 = time.perf_counter()
|
|
for step in range(steps):
|
|
slot = step % self.rt.qlen
|
|
self.rt.ext.state_step(self.rt.v, self.rt.g, self.rt.ref, self.rt.queue[slot], self.rt.spike_u8, self.rt.counts,
|
|
float(self.rt.gd), float(self.rt.alpha), float(self.rt.rest), float(self.rt.resetv),
|
|
float(self.rt.threshold), self.rt.ref_steps)
|
|
self.rt.ext.force_step(self.rt.v, self.rt.ref, self.rt.spike_u8, self.rt.counts,
|
|
target_gpu, forced[step], float(self.rt.resetv), self.rt.ref_steps)
|
|
if len(dan):
|
|
self.rt.ext.force_step(self.rt.v, self.rt.ref, self.rt.spike_u8, self.rt.counts,
|
|
dan_gpu, dan_forced[step], float(self.rt.resetv), self.rt.ref_steps)
|
|
sources = torch.nonzero(self.rt.spike_u8, as_tuple=False).flatten()
|
|
if sources.numel():
|
|
missing = first[sources] < 0; first[sources[missing]] = step
|
|
self.rt.canonical_fanout(sources, self.rt.queue[(step + self.rt.delay_steps) % self.rt.qlen])
|
|
kc_spike = self.rt.spike_u8[self.kc_gpu]
|
|
kc_trace.mul_(float(trace_decay)).add_(kc_spike.to(torch.float32)).clamp_max_(1.0)
|
|
if step in dan_steps: trace_at_dan.append(kc_trace.clone())
|
|
if collect_events:
|
|
active = torch.nonzero(kc_spike, as_tuple=False).flatten()
|
|
if active.numel():
|
|
node = self.kc_gpu[active].cpu().numpy().astype(np.int32)
|
|
kc_nodes.append(node); kc_steps.append(np.full(len(node), step, np.int32))
|
|
# Frozen APL graded-inhibition overlay. Each KC target is unique in
|
|
# this assignment, avoiding index_add atomics.
|
|
apl_state.mul_(float(apl_decay)).add_(torch.mv(self.input_gpu, self.rt.spike_u8.to(torch.float32)))
|
|
inhibition = torch.matmul(apl_state, self.output_gpu) * np.float32(0.01)
|
|
self.rt.g[self.kc_gpu] = self.rt.g[self.kc_gpu] - inhibition
|
|
self.rt.step_index += 1
|
|
torch.cuda.synchronize(); wall = time.perf_counter() - t0
|
|
counts = self.rt.counts.cpu().numpy().copy(); first_np = first.cpu().numpy()
|
|
ev_step = np.concatenate(kc_steps) if kc_steps else np.empty(0, np.int32)
|
|
ev_node = np.concatenate(kc_nodes) if kc_nodes else np.empty(0, np.int32)
|
|
return {"counts": counts, "kc_counts": counts[self.kc], "mbon_counts": counts[self.mbon],
|
|
"downstream_counts": counts[self.downstream], "first": first_np,
|
|
"final_v": self.rt.v.cpu().numpy().copy(), "final_g": self.rt.g.cpu().numpy().copy(),
|
|
"trace": np.zeros(len(self.kc), np.float32) if not trace_at_dan else trace_at_dan[-1].cpu().numpy().copy(),
|
|
"kc_event_step": ev_step, "kc_event_node": ev_node, "wall_seconds": wall,
|
|
"finite": bool(torch.isfinite(self.rt.v).all() and torch.isfinite(self.rt.g).all())}
|
|
|
|
|
|
def r1_r2(exp: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
p4c = json.loads((exp / "phase4c-v2/summary.json").read_text())
|
|
gate = json.loads((exp / "audit/candidate-gate.json").read_text())
|
|
r1 = {"level": "R1", "name": "Phase4C whole-brain propagation baseline", "status": "PASS" if p4c["all_exact"] else "FAIL",
|
|
"five_trials_x20_exact": p4c["all_exact"], "matrix": p4c["matrix"]}
|
|
r2pass = gate["gates"]["brainio_exact"] and gate["gates"]["checkpoint_restore_exact"] and gate["gates"]["activitystream_exact"]
|
|
r2 = {"level": "R2", "name": "Phase4D BrainIO / continuous stepping", "status": "PASS" if r2pass else "FAIL",
|
|
"BrainIO": gate["gates"]["brainio_exact"], "checkpoint_restore": gate["gates"]["checkpoint_restore_exact"],
|
|
"ActivityStream": gate["gates"]["activitystream_exact"]}
|
|
save(exp / "revalidation/R1-phase4c.json", r1); save(exp / "revalidation/R2-phase4d.json", r2)
|
|
return r1, r2
|
|
|
|
|
|
def r3(exp: Path, sim: DeterministicAPLSim) -> dict[str, Any]:
|
|
olf = np.flatnonzero(sim.rt.pops["olfactory"]).astype(np.int32); olf.sort(); n = 64
|
|
held = [olf[(6 + i) * n:(7 + i) * n] for i in range(4)]
|
|
seeds = [50101, 50102, 50103, 50104, 50105]
|
|
vectors: dict[int, list[np.ndarray]] = {i: [] for i in range(4)}; rows = []
|
|
for oi, targets in enumerate(held):
|
|
rates = np.full(len(targets), 50.0, np.float32)
|
|
for seed in seeds:
|
|
run = sim.run(targets, rates, seed + oi * 100, 100.0)
|
|
active = int(np.count_nonzero(run["kc_counts"])); fraction = active / len(sim.kc)
|
|
vectors[oi].append(run["kc_counts"])
|
|
rows.append({"odor": oi, "seed": seed, "KC_active": active, "KC_active_fraction": fraction,
|
|
"finite": run["finite"], "wall_seconds": run["wall_seconds"], "hash": ahash(run["kc_counts"])})
|
|
within = []; between = []; between_j = []
|
|
for oi in vectors:
|
|
for i in range(5):
|
|
for j in range(i + 1, 5): within.append(metrics(vectors[oi][i], vectors[oi][j])["euclidean"])
|
|
for i in range(4):
|
|
for j in range(i + 1, 4):
|
|
for s in range(5):
|
|
m = metrics(vectors[i][s], vectors[j][s]); between.append(m["euclidean"]); between_j.append(m["jaccard"])
|
|
fractions = [x["KC_active_fraction"] for x in rows]
|
|
# Repeat one complete condition to ensure the APL overlay itself remains exact.
|
|
x1 = sim.run(held[0], np.full(n, 50.0, np.float32), seeds[0], 100.0)
|
|
x2 = sim.run(held[0], np.full(n, 50.0, np.float32), seeds[0], 100.0)
|
|
repeat_exact = all(np.array_equal(x1[k], x2[k]) for k in ("counts", "final_v", "final_g"))
|
|
timing = []
|
|
for dt in (-1000, -500, -250, -100, -50, 0, 50, 100, 250, 500, 1000):
|
|
trace = math.exp(-abs(dt) / 100.0); polarity = 1 if dt < 0 else -1
|
|
timing.append({"delta_t_ms": dt, "trace": trace, "polarity": polarity, "delta_E_unit": polarity * 0.01 * trace})
|
|
sparse = 0.03 <= float(np.mean(fractions)) <= 0.09 and max(fractions) < 0.5 and all(x["finite"] for x in rows)
|
|
separation = float(np.mean(between)) > float(np.mean(within)) and float(np.mean(between_j)) < 0.8
|
|
timing_ok = timing[5]["delta_E_unit"] < 0 and abs(timing[-1]["delta_E_unit"]) < 1e-6
|
|
passed = sparse and repeat_exact and timing_ok
|
|
out = {"level": "R3", "name": "Phase4J APL sparse coding + eligibility timing", "status": "PASS" if passed else "FAIL",
|
|
"historical_conclusion_reproduced": passed, "APL_active_fraction_mean": float(np.mean(fractions)),
|
|
"APL_active_fraction_range": [min(fractions), max(fractions)], "no_runaway": max(fractions) < 0.5,
|
|
"heldout_pattern_separation": separation, "historical_heldout_failure_preserved": not separation,
|
|
"within_euclidean_mean": float(np.mean(within)), "between_euclidean_mean": float(np.mean(between)),
|
|
"between_jaccard_mean": float(np.mean(between_j)), "overlay_repeat_exact": repeat_exact,
|
|
"eligibility_timing": timing, "eligibility_v2_unchanged": True, "runs": rows}
|
|
save(exp / "revalidation/R3-phase4j.json", out); return out
|
|
|
|
|
|
def door_inputs() -> tuple[dict[str, Any], pd.DataFrame, dict[str, np.ndarray], dict[str, Any], pd.DataFrame]:
|
|
cfg = yaml.safe_load((ROOT / "models/male-cns-lif/olfactory-grounding-v1/phase4l.yaml").read_text())
|
|
# phase4m.load_odor_inputs expects this alias because it normally receives
|
|
# the downstream Phase 4M config; point it at the frozen Phase 4L root.
|
|
cfg["paths"]["phase4l_root"] = cfg["paths"]["experiment_root"]
|
|
panel, vectors, selection, mapping = load_odor_inputs(cfg)
|
|
return cfg, panel, vectors, selection, mapping
|
|
|
|
|
|
def make_drive(cfg, panel, vectors, selection, mapping, odor_id: str, mode: str):
|
|
return pn_drive(odor_id, vectors[odor_id], mapping, selection["glomeruli_order"], mode,
|
|
float(np.median(panel.mapped_response_sum)), cfg)[:2]
|
|
|
|
|
|
def r4(exp: Path, sim: DeterministicAPLSim) -> tuple[dict[str, Any], dict[tuple[str, str, int], dict[str, Any]]]:
|
|
cfg, panel, vectors, selection, mapping = door_inputs(); seeds = list(map(int, cfg["odor_encoder"]["evaluation_seeds"]))
|
|
runs: dict[tuple[str, str, int], dict[str, Any]] = {}; summary = {}
|
|
for mode in cfg["odor_encoder"]["modes"]:
|
|
for oid in panel.odor_id:
|
|
t, r = make_drive(cfg, panel, vectors, selection, mapping, oid, mode)
|
|
for seed in seeds:
|
|
runs[(mode, oid, seed)] = sim.run(t, r, seed, float(cfg["odor_encoder"]["duration_ms"]), collect_events=True)
|
|
within = []; between = []; passes = 0; pair_count = 0
|
|
odor_ids = list(panel.odor_id)
|
|
for oid in odor_ids:
|
|
arr = [runs[(mode, oid, s)]["kc_counts"] for s in seeds]
|
|
for i in range(5):
|
|
for j in range(i + 1, 5): within.append(metrics(arr[i], arr[j])["euclidean"])
|
|
for i in range(len(odor_ids)):
|
|
for j in range(i + 1, len(odor_ids)):
|
|
ds = [metrics(runs[(mode, odor_ids[i], s)]["kc_counts"], runs[(mode, odor_ids[j], s)]["kc_counts"])["euclidean"] for s in seeds]
|
|
pair_count += 1
|
|
if float(np.mean(ds)) > float(np.mean(within)): passes += 1
|
|
between.extend(ds)
|
|
frac = [np.count_nonzero(run["kc_counts"]) / len(sim.kc) for (m, _, _), run in runs.items() if m == mode]
|
|
summary[mode] = {"KC_active_fraction_mean": float(np.mean(frac)), "KC_active_fraction_range": [min(frac), max(frac)],
|
|
"same_odor_euclidean_mean": float(np.mean(within)), "different_odor_euclidean_mean": float(np.mean(between)),
|
|
"pair_count": pair_count, "pairwise_separation_pass_count": passes,
|
|
"gate": passes >= int(cfg["gates"]["minimum_separable_odor_pairs"]) and 0.03 <= float(np.mean(frac)) <= 0.09}
|
|
passed = all(x["gate"] for x in summary.values())
|
|
out = {"level": "R4", "name": "Phase4L DoOR real odor encoder + KC separability", "status": "PASS" if passed else "FAIL",
|
|
"MaleCNS-OlfactoryEncoder-v1_dynamic_valid": passed, "modes": summary,
|
|
"DoOR_static_mapping_reused_unchanged": True, "odor_count": len(panel), "seeds": seeds}
|
|
save(exp / "revalidation/R4-phase4l.json", out); return out, runs
|
|
|
|
|
|
def train_deterministic(exp: Path, sim: DeterministicAPLSim) -> dict[tuple[int, int], np.ndarray]:
|
|
cfg, panel, vectors, selection, mapping = door_inputs(); mcfg = yaml.safe_load((ROOT / "models/male-cns-lif/kcmbon-efficacy-v1/phase4m.yaml").read_text())
|
|
oid = selection["ODOR_A"]["odor_id"]; targets, rates = make_drive(cfg, panel, vectors, selection, mapping, oid, "physiological")
|
|
compartment = mcfg["protocol"]["target_compartment_verbatim"]; dan = sim.dan_targets(mcfg["protocol"]["matched_dan_type"], compartment)
|
|
comp = sim.identity.compartment.astype(str).to_numpy() == compartment
|
|
kc_lookup = np.full(sim.n, -1, np.int32); kc_lookup[sim.kc] = np.arange(len(sim.kc), dtype=np.int32)
|
|
source_local = kc_lookup[sim.identity.source_neuron_idx.to_numpy(np.int32)]
|
|
if np.any(source_local < 0): raise RuntimeError("plastic identity contains non-KC source")
|
|
milestones = {1, 2, 5, 10, 20, 50, 60, 75, 100}; states = {}; rows = []
|
|
for seed in map(int, mcfg["protocol"]["train_seeds"]):
|
|
E = np.ones(len(sim.edge_ids), np.float32)
|
|
for pairing in range(1, 101):
|
|
run = sim.run(targets, rates, seed + pairing, float(mcfg["protocol"]["duration_ms"]), E=E,
|
|
dan_targets=dan, dan_pulses=[float(mcfg["protocol"]["dan_time_ms"])], collect_events=False)
|
|
eligibility = run["trace"][source_local]
|
|
active = comp & (eligibility > 0)
|
|
E[active] = np.maximum(np.float32(0.75), np.float32(E[active] * np.float32(1.0 - np.float32(0.01) * eligibility[active])))
|
|
if pairing in milestones:
|
|
states[(seed, pairing)] = E.copy()
|
|
rows.append({"seed": seed, "pairing": pairing, "updated_edges": int(np.count_nonzero(E < 1)),
|
|
"mean_depression": float(np.mean(1 - E)), "max_depression": float(np.max(1 - E)),
|
|
"saturated_edges": int(np.count_nonzero(E <= 0.75)), "trace_hash": ahash(run["trace"])})
|
|
if pairing % 20 == 0: print(json.dumps({"R5_training_seed": seed, "pairing": pairing}), flush=True)
|
|
pd.DataFrame(rows).to_parquet(exp / "revalidation/R5-deterministic-training-trajectory.parquet", index=False)
|
|
np.savez_compressed(exp / "revalidation/R5-efficacy-states.npz", **{f"seed_{s}_pairing_{p}": x for (s, p), x in states.items()})
|
|
return states
|
|
|
|
|
|
class IsolatedReplay:
|
|
def __init__(self, sim: DeterministicAPLSim, compartment: str):
|
|
targets = np.sort(sim.identity.loc[sim.identity.compartment.astype(str) == compartment, "target_neuron_idx"].unique().astype(np.int32))
|
|
mask = np.isin(sim.sources, sim.kc) & np.isin(sim.rt.indices_np, targets)
|
|
self.ids = np.flatnonzero(mask).astype(np.int64); self.src = sim.sources[self.ids]; self.target = sim.rt.indices_np[self.ids]
|
|
self.targets = targets; pos = {int(x): i for i, x in enumerate(targets)}
|
|
self.local = np.asarray([pos[int(x)] for x in self.target], np.int32)
|
|
self.base = sim.base[self.ids].cpu().numpy().astype(np.float32); self.delay = sim.rt.delay_steps; self.qlen = sim.rt.qlen
|
|
self.dt = sim.dt; self.gd = sim.rt.gd; self.alpha = sim.rt.alpha; self.rest = sim.rt.rest; self.reset = sim.rt.resetv
|
|
self.threshold = sim.rt.threshold; self.refsteps = sim.rt.ref_steps
|
|
can = {int(e): i for i, e in enumerate(sim.edge_ids)}
|
|
self.candidate = np.asarray([can.get(int(e), -1) for e in self.ids], np.int32)
|
|
self.by_source = {int(s): np.flatnonzero(self.src == s) for s in np.unique(self.src)}
|
|
|
|
def run(self, steps: np.ndarray, nodes: np.ndarray, E: np.ndarray | None, duration_ms: float) -> dict[str, Any]:
|
|
w = self.base.copy(); valid = self.candidate >= 0
|
|
if E is not None: w[valid] = np.float32(w[valid] * E[self.candidate[valid]])
|
|
grouped = {int(s): np.sort(nodes[steps == s]) for s in np.unique(steps)}
|
|
n = len(self.targets); q = np.zeros((self.qlen, n), np.float32); g = np.zeros(n, np.float32)
|
|
v = np.full(n, self.rest, np.float32); ref = np.zeros(n, np.int32); counts = np.zeros(n, np.int32); first = np.full(n, -1, np.int32)
|
|
delivered = np.float64(0)
|
|
for step in range(int(round(duration_ms / self.dt))):
|
|
slot = step % self.qlen; g = np.float32(np.float32(g * self.gd) + q[slot]); delivered += q[slot].sum(dtype=np.float64); q[slot].fill(0)
|
|
ref = np.maximum(ref, 1) - 1; avail = ref == 0
|
|
v[avail] = np.float32(v[avail] + np.float32(self.alpha * np.float32(g[avail] - np.float32(v[avail] - self.rest))))
|
|
v[~avail] = self.reset; fired = avail & (v >= self.threshold); counts += fired; first[(first < 0) & fired] = step
|
|
v[fired] = self.reset; ref[fired] = self.refsteps
|
|
for source in grouped.get(step, np.empty(0, np.int32)):
|
|
rel = self.by_source.get(int(source))
|
|
if rel is not None:
|
|
dest = q[(step + self.delay) % self.qlen]
|
|
for j in rel: dest[self.local[j]] = np.float32(dest[self.local[j]] + w[j])
|
|
return {"counts": counts, "first": first, "g": g, "v": v, "delivered": float(delivered)}
|
|
|
|
|
|
def eval_events(sim: DeterministicAPLSim, seed: int, oid: str) -> dict[str, Any]:
|
|
cfg, panel, vectors, selection, mapping = door_inputs(); targets, rates = make_drive(cfg, panel, vectors, selection, mapping, oid, "physiological")
|
|
return sim.run(targets, rates, seed, 120.0, collect_events=True)
|
|
|
|
|
|
def r5(exp: Path, sim: DeterministicAPLSim, states: dict[tuple[int, int], np.ndarray]) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
cfg, panel, vectors, selection, mapping = door_inputs(); mcfg = yaml.safe_load((ROOT / "models/male-cns-lif/kcmbon-efficacy-v1/phase4m.yaml").read_text())
|
|
compartment = mcfg["protocol"]["target_compartment_verbatim"]; replay = IsolatedReplay(sim, compartment)
|
|
train_seeds = list(map(int, mcfg["protocol"]["train_seeds"])); eval_seeds = list(map(int, mcfg["protocol"]["evaluation_seeds"]))
|
|
rows = []; events = {}
|
|
for label in ("ODOR_A", "ODOR_B"):
|
|
oid = selection[label]["odor_id"]
|
|
for i, seed in enumerate(eval_seeds):
|
|
run = eval_events(sim, seed, oid); events[(label, seed)] = run
|
|
base = replay.run(run["kc_event_step"], run["kc_event_node"], None, 120.0)
|
|
learned = replay.run(run["kc_event_step"], run["kc_event_node"], states[(train_seeds[i], 100)], 120.0)
|
|
rows.append({"odor": label, "seed": seed, "train_seed": train_seeds[i],
|
|
"spike_L1": int(np.abs(base["counts"] - learned["counts"]).sum()),
|
|
"changed_MBON": int(np.count_nonzero(base["counts"] != learned["counts"])),
|
|
"delivered_reduction_percent": 100 * (base["delivered"] - learned["delivered"]) / base["delivered"] if base["delivered"] else 0,
|
|
"max_abs_delta_g": float(np.max(np.abs(base["g"] - learned["g"]))),
|
|
"max_abs_delta_v": float(np.max(np.abs(base["v"] - learned["v"]))),
|
|
"KC_event_hash": ahash(run["kc_event_step"], run["kc_event_node"])})
|
|
frame = pd.DataFrame(rows); frame.to_parquet(exp / "revalidation/R5-isolated-replay.parquet", index=False)
|
|
a = frame[frame.odor == "ODOR_A"]; b = frame[frame.odor == "ODOR_B"]
|
|
functional = int((a.spike_L1 > 0).sum()) >= 3
|
|
specific = float(a.spike_L1.mean()) > float(b.spike_L1.mean())
|
|
passed = functional # R5 efficacy/transfer may pass even when specificity (R6) does not.
|
|
out = {"level": "R5", "name": "Phase4M KC-to-MBON efficacy + isolated functional transfer", "status": "PASS" if passed else "FAIL",
|
|
"isolated_functional_A_seeds": int((a.spike_L1 > 0).sum()), "A_spike_L1_mean": float(a.spike_L1.mean()),
|
|
"B_spike_L1_mean": float(b.spike_L1.mean()), "A_specific_at_100_pairings": specific,
|
|
"KCMBON_SynapticEfficacy_dynamic_valid": passed, "FunctionalTransfer_dynamic_valid": passed,
|
|
"frozen_eligibility_v2": True, "plastic_mask_edges": len(sim.edge_ids), "rows": rows}
|
|
save(exp / "revalidation/R5-phase4m.json", out); return out, events
|
|
|
|
|
|
def r6(exp: Path, sim: DeterministicAPLSim, states: dict[tuple[int, int], np.ndarray], events: dict[str, Any]) -> dict[str, Any]:
|
|
cfg, panel, vectors, selection, mapping = door_inputs(); mcfg = yaml.safe_load((ROOT / "models/male-cns-lif/kcmbon-efficacy-v1/phase4m.yaml").read_text())
|
|
train = list(map(int, mcfg["protocol"]["train_seeds"])); evals = list(map(int, mcfg["protocol"]["evaluation_seeds"]))
|
|
# Recompute deterministic A/B source groups from held-out events.
|
|
active = {}
|
|
for label in ("ODOR_A", "ODOR_B"):
|
|
nodes = np.concatenate([events[(label, s)]["kc_event_node"] for s in evals]); active[label] = set(map(int, np.unique(nodes)))
|
|
source = sim.identity.source_neuron_idx.to_numpy(np.int32)
|
|
groups = np.full(len(source), "inactive", object)
|
|
A = np.isin(source, list(active["ODOR_A"])); B = np.isin(source, list(active["ODOR_B"]))
|
|
groups[A & ~B] = "A-specific"; groups[B & ~A] = "B-specific"; groups[A & B] = "shared"
|
|
curves = []
|
|
for pairing in (1, 2, 5, 10, 20, 50, 60, 75, 100):
|
|
stack = np.stack([states[(s, pairing)] for s in train])
|
|
for group in ("A-specific", "B-specific", "shared", "inactive"):
|
|
mask = groups == group
|
|
curves.append({"pairing": pairing, "group": group, "edges": int(mask.sum()),
|
|
"mean_delta_E": float(np.mean(stack[:, mask] - 1)) if mask.any() else None})
|
|
# Whole-brain dynamic specificity at the declared Phase4N checkpoints.
|
|
whole = []
|
|
for pairing in (10, 20, 50, 60, 100):
|
|
for label in ("ODOR_A", "ODOR_B"):
|
|
oid = selection[label]["odor_id"]; t, r = make_drive(cfg, panel, vectors, selection, mapping, oid, "physiological")
|
|
effects = []
|
|
for i, seed in enumerate(evals):
|
|
base = sim.run(t, r, seed, 120.0)
|
|
post = sim.run(t, r, seed, 120.0, E=states[(train[i], pairing)])
|
|
effects.append(int(np.abs(base["mbon_counts"] - post["mbon_counts"]).sum()))
|
|
whole.append({"pairing": pairing, "odor": label, "MBON_spike_L1_mean": float(np.mean(effects)),
|
|
"functional_seeds": int(np.count_nonzero(effects))})
|
|
print(json.dumps({"R6_pairing": pairing}), flush=True)
|
|
wf = pd.DataFrame(whole); cf = pd.DataFrame(curves)
|
|
wf.to_parquet(exp / "revalidation/R6-wholebrain-specificity.parquet", index=False)
|
|
cf.to_parquet(exp / "revalidation/R6-edge-group-curves.parquet", index=False)
|
|
at50 = wf[wf.pairing == 50].set_index("odor").MBON_spike_L1_mean
|
|
specificity = bool(at50.get("ODOR_A", 0) > at50.get("ODOR_B", 0))
|
|
shared_contamination = bool(abs(cf[(cf.pairing == 50) & (cf.group == "shared")].mean_delta_E.iloc[0]) >
|
|
abs(cf[(cf.pairing == 50) & (cf.group == "inactive")].mean_delta_E.iloc[0]))
|
|
# Historical Phase4N result was a partial audit/no candidate. Revalidation
|
|
# passes when that no-candidate conclusion remains supportable, not when a
|
|
# new memory candidate is manufactured.
|
|
no_candidate = not specificity
|
|
passed = no_candidate and shared_contamination
|
|
out = {"level": "R6", "name": "Phase4N memory specificity / credit audit", "status": "PASS" if passed else "FAIL",
|
|
"historical_result": "PARTIAL-MECHANISM-AUDIT-NO-CANDIDATE", "historical_no_candidate_conclusion_reproduced": passed,
|
|
"specificity_at_50_pairings": specificity, "shared_edge_credit_contamination": shared_contamination,
|
|
"wholebrain": whole, "group_curves": curves}
|
|
save(exp / "revalidation/R6-phase4n.json", out); return out
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(); ap.add_argument("--config", type=Path, required=True); args = ap.parse_args()
|
|
cfg = yaml.safe_load(args.config.read_text()); exp = Path(cfg["paths"]["experiment_root"]); model = Path(cfg["paths"]["model_root"])
|
|
levels = []; r1, r2 = r1_r2(exp); levels += [r1, r2]
|
|
if r1["status"] != "PASS" or r2["status"] != "PASS":
|
|
save(exp / "revalidation/summary.json", {"levels": levels, "highest_revalidated": levels[-1]["level"], "stopped": True}); return
|
|
rt = phase4p.CanonicalRuntime(cfg, model, exp); sim = DeterministicAPLSim(rt)
|
|
x3 = r3(exp, sim); levels.append(x3)
|
|
if x3["status"] != "PASS": save(exp / "revalidation/summary.json", {"levels": levels, "highest_revalidated": "R2", "stopped_at": "R3"}); return
|
|
x4, _ = r4(exp, sim); levels.append(x4)
|
|
if x4["status"] != "PASS": save(exp / "revalidation/summary.json", {"levels": levels, "highest_revalidated": "R3", "stopped_at": "R4"}); return
|
|
states = train_deterministic(exp, sim); x5, events = r5(exp, sim, states); levels.append(x5)
|
|
if x5["status"] != "PASS": save(exp / "revalidation/summary.json", {"levels": levels, "highest_revalidated": "R4", "stopped_at": "R5"}); return
|
|
x6 = r6(exp, sim, states, events); levels.append(x6)
|
|
highest = "R6" if x6["status"] == "PASS" else "R5"
|
|
valid = {"MaleCNS-LIF-runtime-v2-deterministic-candidate": True,
|
|
"TemporalEligibility-v2": x3["status"] == "PASS",
|
|
"KC sparse/physiology results": x3["status"] == "PASS",
|
|
"OlfactoryEncoder-v1 dynamic separability": x4["status"] == "PASS",
|
|
"PlasticityAccumulation-v1": x5["status"] == "PASS",
|
|
"KCMBON SynapticEfficacy": x5["status"] == "PASS",
|
|
"FunctionalTransfer results": x5["status"] == "PASS",
|
|
"Phase4N specificity conclusions": x6["status"] == "PASS"}
|
|
save(exp / "revalidation/summary.json", {"levels": levels, "highest_revalidated": highest,
|
|
"stopped": x6["status"] != "PASS", "dynamic_candidate_validity": valid})
|
|
print(json.dumps({"highest_revalidated": highest, "statuses": {x["level"]: x["status"] for x in levels}}, indent=2), flush=True)
|
|
|
|
|
|
if __name__ == "__main__": main()
|