Files

646 lines
30 KiB
Python

#!/usr/bin/env python3
"""Auditable Phase 4A whole-connectome LIF baseline for MaleCNS v1.0."""
from __future__ import annotations
import argparse
import hashlib
import json
import logging
import math
import os
import platform
import random
import time
from collections import Counter
from pathlib import Path
from typing import Any
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
import torch
import yaml
LOG = logging.getLogger("phase4a")
def sha256(path: Path, chunk: int = 8 << 20) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
while b := f.read(chunk):
h.update(b)
return h.hexdigest()
def write_json(path: Path, obj: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(obj, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def setup_logging(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.FileHandler(path, encoding="utf-8"), logging.StreamHandler()],
force=True,
)
def load_config(path: Path) -> dict[str, Any]:
cfg = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(cfg, dict):
raise ValueError("configuration root must be a mapping")
return cfg
def require_exact_inputs(cfg: dict[str, Any]) -> dict[str, Path]:
root = Path(cfg["paths"]["connectome_root"]).resolve()
allowed = Path("/data/digital-drosophila/datasets/male-cns-v1.0/processed/neuron-only").resolve()
if root != allowed:
raise RuntimeError(f"connectome_root must be exactly {allowed}, got {root}")
paths = {
"indptr": root / "gpu/csr-indptr.int64.npy",
"indices": root / "gpu/csr-indices.int32.npy",
"weights": root / "gpu/csr-weights.int64.npy",
"metadata": root / "neuron-index/unified-metadata.parquet",
"csr_metadata": root / "gpu/csr-metadata.json",
}
missing = [str(p) for p in paths.values() if not p.is_file()]
if missing:
raise FileNotFoundError(f"missing Phase 3B inputs: {missing}")
return paths
def classify_nt(value: Any, cfg: dict[str, Any]) -> tuple[str, int]:
sign_cfg = cfg["sign_policy"]
if value is None or (isinstance(value, float) and math.isnan(value)):
return "UNKNOWN", int(sign_cfg["unknown_sign"])
key = str(value).strip().lower()
if not key:
return "UNKNOWN", int(sign_cfg["unknown_sign"])
if key == "unclear":
return "UNCLEAR", int(sign_cfg["unclear_sign"])
mapping = sign_cfg["neurotransmitter_sign"]
if key not in mapping:
if sign_cfg.get("unmapped_policy") == "error":
raise RuntimeError(f"unmapped neurotransmitter value: {value!r}")
return f"UNMAPPED:{key}", int(sign_cfg["unmapped_sign"])
sign = int(mapping[key])
if sign not in (-1, 0, 1):
raise ValueError(f"invalid sign for {key}: {sign}")
return key, sign
def transform_weights(raw: np.ndarray, transform: str, scale: float) -> np.ndarray:
x = raw.astype(np.float64, copy=False)
if transform == "linear":
y = x
elif transform == "sqrt":
y = np.sqrt(x)
elif transform == "log1p":
y = np.log1p(x)
else:
raise ValueError(f"unsupported weight transform: {transform}")
return (float(scale) * y).astype(np.float32)
def prepare_derived(cfg: dict[str, Any], inputs: dict[str, Path], exp: Path) -> dict[str, Any]:
expected = cfg["expected_graph"]
n = int(expected["neurons"])
nnz = int(expected["directed_edges"])
expected_sum = int(expected["synaptic_contact_weight_sum"])
indptr = np.load(inputs["indptr"], mmap_mode="r")
indices = np.load(inputs["indices"], mmap_mode="r")
raw = np.load(inputs["weights"], mmap_mode="r")
if indptr.dtype != np.int64 or indices.dtype != np.int32 or raw.dtype != np.int64:
raise RuntimeError(f"unexpected CSR dtypes: {indptr.dtype}, {indices.dtype}, {raw.dtype}")
if indptr.shape != (n + 1,) or indices.shape != (nnz,) or raw.shape != (nnz,):
raise RuntimeError(f"unexpected CSR shapes: {indptr.shape}, {indices.shape}, {raw.shape}")
if int(indptr[-1]) != nnz or int(raw.sum(dtype=np.int64)) != expected_sum:
raise RuntimeError("Phase 3B graph invariant failed")
if int(indices.min()) < 0 or int(indices.max()) >= n or int(raw.min()) <= 0:
raise RuntimeError("CSR index or raw-weight range invalid")
table = pq.read_table(inputs["metadata"], columns=["neuron_idx", "body_id", "annotation_type", "neurotransmitter"])
idx = table["neuron_idx"].to_numpy(zero_copy_only=False)
if len(table) != n or idx.dtype != np.int32 or not np.array_equal(idx, np.arange(n, dtype=np.int32)):
raise RuntimeError("metadata neuron_idx is not the canonical contiguous int32 mapping")
nt_values = table["neurotransmitter"].to_pylist()
classes: list[str] = []
signs = np.empty(n, dtype=np.int8)
for i, value in enumerate(nt_values):
cls, sign = classify_nt(value, cfg)
classes.append(cls)
signs[i] = sign
derived = exp / "outputs/derived"
derived.mkdir(parents=True, exist_ok=True)
sign_path = derived / "synaptic-sign.int8.npy"
np.save(sign_path, signs, allow_pickle=False)
transform = str(cfg["weights"]["transform"])
scale = float(cfg["weights"]["scale"])
effective_path = derived / "effective-weights.float32.npy"
effective = np.lib.format.open_memmap(effective_path, mode="w+", dtype=np.float32, shape=(nnz,))
chunk = int(cfg["weights"].get("chunk_edges", 2_000_000))
for start in range(0, nnz, chunk):
stop = min(nnz, start + chunk)
values = transform_weights(raw[start:stop], transform, scale)
src = np.searchsorted(indptr, np.arange(start, stop, dtype=np.int64), side="right") - 1
effective[start:stop] = values * signs[src].astype(np.float32, copy=False)
effective.flush()
del effective
nt_counts = Counter(classes)
raw_nt_counts = Counter("UNKNOWN" if v is None else str(v).strip().lower() or "UNKNOWN" for v in nt_values)
sign_counts = {str(k): int(v) for k, v in sorted(Counter(signs.tolist()).items())}
result = {
"neurons": n,
"directed_edges": nnz,
"raw_synaptic_contact_weight_sum": expected_sum,
"raw_weight_path": str(inputs["weights"]),
"raw_weight_sha256": sha256(inputs["weights"]),
"raw_weight_modified": False,
"effective_weight_path": str(effective_path),
"effective_weight_sha256": sha256(effective_path),
"effective_weight_transform": transform,
"effective_weight_scale": scale,
"neurotransmitter_source_field": "neurotransmitter",
"neurotransmitter_counts": dict(sorted(raw_nt_counts.items())),
"classification_counts": dict(sorted(nt_counts.items())),
"sign_counts": sign_counts,
"unknown_metadata_count": int(nt_counts.get("UNKNOWN", 0)),
"unclear_metadata_count": int(nt_counts.get("UNCLEAR", 0)),
"histamine_count": int(nt_counts.get("histamine", 0)),
"histamine_configured_sign": int(cfg["sign_policy"]["neurotransmitter_sign"]["histamine"]),
}
write_json(derived / "sign-and-weight-audit.json", result)
return result
def expand_rows(indptr: torch.Tensor, sources: torch.Tensor) -> torch.Tensor:
if sources.numel() == 0:
return torch.empty(0, dtype=torch.int64, device=indptr.device)
starts = indptr[sources]
counts = indptr[sources + 1] - starts
cumulative = torch.cumsum(counts, 0)
total = int(cumulative[-1].item()) if cumulative.numel() else 0
if total == 0:
return torch.empty(0, dtype=torch.int64, device=indptr.device)
seq = torch.arange(total, dtype=torch.int64, device=indptr.device)
row_pos = torch.searchsorted(cumulative, seq, right=True)
previous = cumulative - counts
return starts[row_pos] + seq - previous[row_pos]
def stimulus_descriptor(cfg: dict[str, Any], table: pa.Table, test: dict[str, Any]) -> dict[str, Any]:
stim_name = test["stimulation"]
spec = cfg["stimulations"][stim_name]
n = len(table)
if spec["kind"] == "single_neuron_pulse":
targets = np.array([int(spec["neuron_idx"])], dtype=np.int64)
elif spec["kind"] == "known_type_pulse":
wanted = str(spec["type_name"])
values = table["annotation_type"].to_pylist()
targets = np.array([i for i, value in enumerate(values) if value == wanted], dtype=np.int64)
max_neurons = int(spec.get("max_neurons", len(targets)))
targets = targets[:max_neurons]
if targets.size == 0:
raise RuntimeError(f"known type not found: {wanted}")
elif spec["kind"] == "sparse_poisson":
rng = np.random.default_rng(int(test["seed"]))
count = max(1, round(n * float(spec["target_fraction"])))
targets = np.sort(rng.choice(n, size=count, replace=False)).astype(np.int64)
else:
raise ValueError(f"unsupported stimulation kind: {spec['kind']}")
return {"name": stim_name, "spec": spec, "targets": targets}
def save_events(exp: Path, run_name: str, times: np.ndarray, neurons: np.ndarray, duration_ms: float, window_ms: float) -> None:
out = exp / "outputs/spikes"
out.mkdir(parents=True, exist_ok=True)
event_table = pa.table({
"timestamp_ms": pa.array(times, type=pa.float64()),
"neuron_idx": pa.array(neurons, type=pa.int32()),
})
pq.write_table(event_table, out / f"{run_name}.events.parquet", compression="zstd")
starts = np.arange(0.0, duration_ms, window_ms, dtype=np.float64)
counts, _ = np.histogram(times, bins=np.append(starts, duration_ms))
rates = counts.astype(np.float64) / ((np.minimum(starts + window_ms, duration_ms) - starts) / 1000.0)
rate_table = pa.table({
"window_start_ms": starts,
"window_end_ms": np.minimum(starts + window_ms, duration_ms),
"spike_count": counts.astype(np.int64),
"population_spikes_per_second": rates,
})
pq.write_table(rate_table, out / f"{run_name}.window-rates.parquet", compression="zstd")
def run_gpu_smoke(
cfg: dict[str, Any], inputs: dict[str, Path], exp: Path, test: dict[str, Any],
graph: dict[str, torch.Tensor], metadata: pa.Table, self_loop_weight: torch.Tensor,
) -> dict[str, Any]:
device = graph["indptr"].device
n = int(cfg["expected_graph"]["neurons"])
dt = float(cfg["model"]["dt_ms"])
duration = float(test["duration_ms"])
steps = int(round(duration / dt))
if not math.isclose(steps * dt, duration, abs_tol=1e-9):
raise ValueError("duration must be divisible by dt")
delay_steps = int(round(float(cfg["model"]["synaptic_delay_ms"]) / dt))
queue_len = delay_steps + 1
rest = float(cfg["model"]["resting_potential_mv"])
reset = float(cfg["model"]["reset_potential_mv"])
threshold = float(cfg["model"]["threshold_mv"])
alpha = dt / float(cfg["model"]["membrane_tau_ms"])
syn_decay = math.exp(-dt / float(cfg["model"]["synaptic_tau_ms"]))
refractory_steps = int(round(float(cfg["model"]["refractory_ms"]) / dt))
stim = stimulus_descriptor(cfg, metadata, test)
spec = stim["spec"]
targets_cpu = stim["targets"]
targets = torch.as_tensor(targets_cpu, dtype=torch.int64, device=device)
generator = torch.Generator(device=device)
generator.manual_seed(int(test["seed"]))
v = torch.full((n,), rest, dtype=torch.float32, device=device)
syn = torch.zeros(n, dtype=torch.float32, device=device)
refractory = torch.zeros(n, dtype=torch.int32, device=device)
queue = torch.zeros((queue_len, n), dtype=torch.float32, device=device)
event_t: list[np.ndarray] = []
event_n: list[np.ndarray] = []
peak_step_spikes = 0
self_loop_events = 0
self_loop_effective_sum = 0.0
numerical_ok = True
memory_samples: list[int] = []
torch.cuda.synchronize(device)
torch.cuda.reset_peak_memory_stats(device)
persistent_bytes = torch.cuda.memory_allocated(device)
started = time.perf_counter()
for step in range(steps):
slot = step % queue_len
syn.mul_(syn_decay).add_(queue[slot])
queue[slot].zero_()
ext = torch.zeros(n, dtype=torch.float32, device=device)
kind = spec["kind"]
t_ms = step * dt
if kind in ("single_neuron_pulse", "known_type_pulse"):
if float(spec["start_ms"]) <= t_ms < float(spec["start_ms"]) + float(spec["duration_ms"]):
ext[targets] = float(spec["amplitude_mv_equivalent"])
elif kind == "sparse_poisson":
probability = float(spec["rate_hz"]) * dt / 1000.0
chosen = torch.rand(targets.numel(), generator=generator, device=device) < probability
if bool(chosen.any().item()):
ext[targets[chosen]] = float(spec["event_amplitude_mv_equivalent"])
available = refractory == 0
v[available] += alpha * (rest - v[available] + syn[available] + ext[available])
refractory.clamp_min_(1).sub_(1)
spikes = available & (v >= threshold)
sources = torch.nonzero(spikes, as_tuple=False).flatten()
count = int(sources.numel())
if count:
peak_step_spikes = max(peak_step_spikes, count)
src_np = sources.detach().cpu().numpy().astype(np.int32, copy=False)
event_t.append(np.full(count, (step + 1) * dt, dtype=np.float64))
event_n.append(src_np.copy())
v[sources] = reset
refractory[sources] = refractory_steps
loop_values = self_loop_weight[sources]
loop_active = loop_values != 0
self_loop_events += int(loop_active.sum().item())
self_loop_effective_sum += float(loop_values.sum().item())
edge_ids = expand_rows(graph["indptr"], sources)
if edge_ids.numel():
posts = graph["indices"][edge_ids].to(torch.int64)
arrivals = graph["effective"][edge_ids]
future = (step + delay_steps) % queue_len
queue[future].scatter_add_(0, posts, arrivals)
if step % int(cfg["validation"]["finite_check_interval_steps"]) == 0 or step == steps - 1:
finite = torch.isfinite(v).all() & torch.isfinite(syn).all() & torch.isfinite(queue).all()
if not bool(finite.item()):
numerical_ok = False
break
memory_samples.append(torch.cuda.memory_allocated(device))
torch.cuda.synchronize(device)
wall = time.perf_counter() - started
peak_vram = torch.cuda.max_memory_allocated(device)
peak_reserved_vram = torch.cuda.max_memory_reserved(device)
final_allocated = torch.cuda.memory_allocated(device)
times = np.concatenate(event_t) if event_t else np.empty(0, dtype=np.float64)
neurons = np.concatenate(event_n) if event_n else np.empty(0, dtype=np.int32)
save_events(exp, test["name"], times, neurons, duration, float(cfg["output"]["rate_window_ms"]))
total_spikes = int(len(times))
duration_s = duration / 1000.0
active = int(np.unique(neurons).size)
synchronous_fraction = peak_step_spikes / n
observed_growth = int(max(memory_samples, default=final_allocated) - min(memory_samples, default=final_allocated))
result = {
"name": test["name"],
"stimulation": stim["name"],
"stimulation_kind": spec["kind"],
"stimulated_neuron_count": int(len(targets_cpu)),
"stimulated_neuron_indices": targets_cpu.tolist() if len(targets_cpu) <= 64 else targets_cpu[:64].tolist(),
"stimulated_indices_truncated": bool(len(targets_cpu) > 64),
"duration_ms": duration,
"steps": steps,
"wall_clock_seconds": wall,
"realtime_factor_wall_over_biological": wall / duration_s,
"steps_per_second": steps / wall,
"total_spikes": total_spikes,
"spikes_per_biological_second": total_spikes / duration_s,
"mean_firing_rate_hz": total_spikes / (n * duration_s),
"active_neuron_count": active,
"peak_spikes_in_one_step": peak_step_spikes,
"peak_synchronous_fraction": synchronous_fraction,
"all_network_silent": total_spikes == 0,
"explosive_synchrony": synchronous_fraction >= float(cfg["validation"]["explosive_step_fraction"]),
"nan_or_inf": not numerical_ok,
"peak_vram_bytes": int(peak_vram),
"peak_reserved_vram_bytes": int(peak_reserved_vram),
"persistent_model_vram_bytes": int(persistent_bytes),
"final_allocated_vram_bytes": int(final_allocated),
"observed_allocated_growth_bytes": observed_growth,
"memory_leak_suspected": observed_growth > int(cfg["validation"]["memory_growth_tolerance_bytes"]),
"self_loop_transmission_events": self_loop_events,
"self_loop_effective_input_sum": self_loop_effective_sum,
"event_file": str(exp / f"outputs/spikes/{test['name']}.events.parquet"),
}
del v, syn, refractory, queue
torch.cuda.empty_cache()
return result
def induced_subgraph(indptr: np.ndarray, indices: np.ndarray, effective: np.ndarray, n: int, size: int, seed: int) -> dict[str, np.ndarray]:
rng = np.random.default_rng(seed)
nodes = np.sort(rng.choice(n, size=size, replace=False)).astype(np.int64)
inverse = np.full(n, -1, dtype=np.int32)
inverse[nodes] = np.arange(size, dtype=np.int32)
rows: list[int] = []
cols: list[int] = []
vals: list[float] = []
for local_src, global_src in enumerate(nodes):
start, stop = int(indptr[global_src]), int(indptr[global_src + 1])
local_targets = inverse[indices[start:stop]]
keep = local_targets >= 0
if keep.any():
count = int(keep.sum())
rows.extend([local_src] * count)
cols.extend(local_targets[keep].tolist())
vals.extend(effective[start:stop][keep].astype(np.float64).tolist())
return {
"nodes": nodes,
"rows": np.asarray(rows, dtype=np.int64),
"cols": np.asarray(cols, dtype=np.int64),
"weights": np.asarray(vals, dtype=np.float64),
}
def reference_numpy(cfg: dict[str, Any], sub: dict[str, np.ndarray], ext: np.ndarray) -> tuple[np.ndarray, np.ndarray, list[tuple[int, int]]]:
size = len(sub["nodes"])
steps = ext.shape[0]
m = cfg["model"]
dt = float(m["dt_ms"])
rest, reset, threshold = float(m["resting_potential_mv"]), float(m["reset_potential_mv"]), float(m["threshold_mv"])
alpha = dt / float(m["membrane_tau_ms"])
decay = math.exp(-dt / float(m["synaptic_tau_ms"]))
refractory_steps = int(round(float(m["refractory_ms"]) / dt))
delay_steps = int(round(float(m["synaptic_delay_ms"]) / dt))
queue = np.zeros((delay_steps + 1, size), dtype=np.float64)
v = np.full(size, rest, dtype=np.float64)
syn = np.zeros(size, dtype=np.float64)
ref = np.zeros(size, dtype=np.int32)
events: list[tuple[int, int]] = []
for step in range(steps):
slot = step % len(queue)
syn *= decay
syn += queue[slot]
queue[slot].fill(0)
available = ref == 0
v[available] += alpha * (rest - v[available] + syn[available] + ext[step, available])
ref = np.maximum(ref - 1, 0)
sources = np.flatnonzero(available & (v >= threshold))
if len(sources):
for source in sources:
events.append((step, int(source)))
v[sources] = reset
ref[sources] = refractory_steps
active_edges = np.isin(sub["rows"], sources)
np.add.at(queue[(step + delay_steps) % len(queue)], sub["cols"][active_edges], sub["weights"][active_edges])
return v, syn, events
def reference_torch(cfg: dict[str, Any], sub: dict[str, np.ndarray], ext: np.ndarray, device: torch.device) -> tuple[np.ndarray, np.ndarray, list[tuple[int, int]]]:
size = len(sub["nodes"])
steps = ext.shape[0]
m = cfg["model"]
dt = float(m["dt_ms"])
rest, reset, threshold = float(m["resting_potential_mv"]), float(m["reset_potential_mv"]), float(m["threshold_mv"])
alpha = dt / float(m["membrane_tau_ms"])
decay = math.exp(-dt / float(m["synaptic_tau_ms"]))
refractory_steps = int(round(float(m["refractory_ms"]) / dt))
delay_steps = int(round(float(m["synaptic_delay_ms"]) / dt))
queue = torch.zeros((delay_steps + 1, size), dtype=torch.float64, device=device)
v = torch.full((size,), rest, dtype=torch.float64, device=device)
syn = torch.zeros(size, dtype=torch.float64, device=device)
ref = torch.zeros(size, dtype=torch.int32, device=device)
rows = torch.as_tensor(sub["rows"], dtype=torch.int64, device=device)
cols = torch.as_tensor(sub["cols"], dtype=torch.int64, device=device)
weights = torch.as_tensor(sub["weights"], dtype=torch.float64, device=device)
ext_t = torch.as_tensor(ext, dtype=torch.float64, device=device)
events: list[tuple[int, int]] = []
for step in range(steps):
slot = step % len(queue)
syn.mul_(decay).add_(queue[slot])
queue[slot].zero_()
available = ref == 0
v[available] += alpha * (rest - v[available] + syn[available] + ext_t[step, available])
ref.clamp_min_(1).sub_(1)
sources = torch.nonzero(available & (v >= threshold), as_tuple=False).flatten()
if sources.numel():
for source in sources.cpu().tolist():
events.append((step, int(source)))
v[sources] = reset
ref[sources] = refractory_steps
active_edges = torch.isin(rows, sources)
queue[(step + delay_steps) % len(queue)].scatter_add_(0, cols[active_edges], weights[active_edges])
torch.cuda.synchronize(device)
return v.cpu().numpy(), syn.cpu().numpy(), events
def run_reference(cfg: dict[str, Any], inputs: dict[str, Path], exp: Path, effective_path: Path, device: torch.device) -> dict[str, Any]:
spec = cfg["cpu_gpu_reference"]
n = int(cfg["expected_graph"]["neurons"])
indptr = np.load(inputs["indptr"], mmap_mode="r")
indices = np.load(inputs["indices"], mmap_mode="r")
effective = np.load(effective_path, mmap_mode="r")
sub = induced_subgraph(indptr, indices, effective, n, int(spec["subgraph_neurons"]), int(spec["seed"]))
steps = int(spec["steps"])
size = len(sub["nodes"])
ext = np.zeros((steps, size), dtype=np.float64)
start = int(spec["pulse_start_step"])
stop = min(steps, start + int(spec["pulse_steps"]))
ext[start:stop, : int(spec["pulse_neurons"])] = float(spec["pulse_amplitude_mv_equivalent"])
v_cpu, syn_cpu, events_cpu = reference_numpy(cfg, sub, ext)
v_gpu, syn_gpu, events_gpu = reference_torch(cfg, sub, ext, device)
v_error = float(np.max(np.abs(v_cpu - v_gpu)))
syn_error = float(np.max(np.abs(syn_cpu - syn_gpu)))
tolerance = float(spec["absolute_tolerance"])
result = {
"seed": int(spec["seed"]),
"selection": "uniform random without replacement, then induced edges",
"subgraph_neurons": size,
"induced_edges": int(len(sub["rows"])),
"steps": steps,
"cpu_event_count": len(events_cpu),
"gpu_event_count": len(events_gpu),
"events_exact_match": events_cpu == events_gpu,
"max_abs_voltage_difference": v_error,
"max_abs_synaptic_state_difference": syn_error,
"absolute_tolerance": tolerance,
"numerical_match": v_error <= tolerance and syn_error <= tolerance,
"passed": events_cpu == events_gpu and v_error <= tolerance and syn_error <= tolerance,
"selected_global_neuron_indices": sub["nodes"].tolist(),
}
write_json(exp / "benchmark/cpu-gpu-reference.json", result)
return result
def build_self_loop_vector(inputs: dict[str, Path], effective_path: Path, n: int, device: torch.device) -> tuple[torch.Tensor, int]:
indptr = np.load(inputs["indptr"], mmap_mode="r")
indices = np.load(inputs["indices"], mmap_mode="r")
effective = np.load(effective_path, mmap_mode="r")
loops = np.zeros(n, dtype=np.float32)
count = 0
for src in range(n):
start, stop = int(indptr[src]), int(indptr[src + 1])
hit = np.flatnonzero(indices[start:stop] == src)
if len(hit):
count += len(hit)
loops[src] = float(effective[start + int(hit[0])])
return torch.as_tensor(loops, dtype=torch.float32, device=device), count
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True, type=Path)
args = parser.parse_args()
cfg = load_config(args.config)
exp = Path(cfg["paths"]["experiment_root"]).resolve()
for sub in ("config", "benchmark", "logs", "outputs", "audit"):
(exp / sub).mkdir(parents=True, exist_ok=True)
setup_logging(exp / "logs/phase4a.log")
LOG.info("Phase 4A start; configuration=%s", args.config)
random.seed(int(cfg["reproducibility"]["seed"]))
np.random.seed(int(cfg["reproducibility"]["seed"]))
torch.manual_seed(int(cfg["reproducibility"]["seed"]))
inputs = require_exact_inputs(cfg)
input_hashes_before = {name: sha256(path) for name, path in inputs.items()}
derived_audit = prepare_derived(cfg, inputs, exp)
expected_gpu_name = str(cfg["device"]["required_name_substring"])
if not torch.cuda.is_available():
raise RuntimeError("CUDA is unavailable")
device = torch.device("cuda:0")
props = torch.cuda.get_device_properties(device)
if expected_gpu_name not in props.name:
raise RuntimeError(f"required GPU containing {expected_gpu_name!r}, got {props.name!r}")
if torch.cuda.device_count() != 1:
raise RuntimeError("launcher must expose exactly one GPU; mixed multi-GPU is forbidden")
n = int(cfg["expected_graph"]["neurons"])
effective_path = Path(derived_audit["effective_weight_path"])
graph = {
"indptr": torch.as_tensor(np.load(inputs["indptr"]), dtype=torch.int64, device=device),
"indices": torch.as_tensor(np.load(inputs["indices"]), dtype=torch.int32, device=device),
"effective": torch.as_tensor(np.load(effective_path), dtype=torch.float32, device=device),
}
metadata = pq.read_table(inputs["metadata"], columns=["neuron_idx", "annotation_type", "neurotransmitter"])
self_loop_weight, self_loop_count = build_self_loop_vector(inputs, effective_path, n, device)
if self_loop_count != int(cfg["expected_graph"]["self_loops"]):
raise RuntimeError(f"self-loop invariant failed: {self_loop_count}")
reference = run_reference(cfg, inputs, exp, effective_path, device)
if not reference["passed"]:
raise RuntimeError("CPU/GPU reference comparison failed")
smoke_results = []
for test in cfg["smoke_tests"]:
LOG.info("running %s", test["name"])
result = run_gpu_smoke(cfg, inputs, exp, test, graph, metadata, self_loop_weight)
write_json(exp / f"benchmark/{test['name']}.json", result)
smoke_results.append(result)
LOG.info("completed %s: wall=%.6fs spikes=%d", test["name"], result["wall_clock_seconds"], result["total_spikes"])
input_hashes_after = {name: sha256(path) for name, path in inputs.items()}
if input_hashes_before != input_hashes_after:
raise RuntimeError("an input file changed during Phase 4A")
peak = max(r["peak_vram_bytes"] for r in smoke_results)
peak_reserved = max(r["peak_reserved_vram_bytes"] for r in smoke_results)
summary = {
"status": "PASS",
"model_loaded_neurons": n,
"model_loaded_edges": int(cfg["expected_graph"]["directed_edges"]),
"raw_synaptic_contact_weight_sum": int(cfg["expected_graph"]["synaptic_contact_weight_sum"]),
"device": {
"name": props.name,
"total_vram_bytes": int(props.total_memory),
"torch_visible_device_count": torch.cuda.device_count(),
"mixed_multi_gpu_used": False,
},
"peak_vram_bytes_across_smoke_tests": int(peak),
"peak_reserved_vram_bytes_across_smoke_tests": int(peak_reserved),
"derived_audit": derived_audit,
"cpu_gpu_reference": reference,
"smoke_tests": smoke_results,
"numerical_instability_detected": any(r["nan_or_inf"] for r in smoke_results),
"any_explosive_synchrony": any(r["explosive_synchrony"] for r in smoke_results),
"any_silent_run": any(r["all_network_silent"] for r in smoke_results),
"any_memory_leak_suspected": any(r["memory_leak_suspected"] for r in smoke_results),
"self_loops_loaded": self_loop_count,
"isolated_neurons_retained": int(cfg["expected_graph"]["isolated_neurons"]),
"input_hashes_unchanged": True,
"limitations": [
"Dynamics parameters are a configurable FlyWire-inspired baseline, not MaleCNS single-cell measurements.",
"Synthetic benchmark drive amplitudes are test inputs, not biological calibration.",
"No training, plasticity, RL, game, web service, visual input, or 3D frontend is included.",
],
}
write_json(exp / "benchmark/summary.json", summary)
# Seal the log before hashing outputs so the manifest remains verifiable.
LOG.info("PHASE4A_PASS")
for handler in logging.getLogger().handlers:
handler.flush()
audit_controls = {exp / "audit/manifest.json", exp / "audit/checksums.sha256"}
output_files = sorted(p for p in exp.rglob("*") if p.is_file() and p not in audit_controls)
manifest = {
"phase": "4A",
"created_unix_time": time.time(),
"configuration": str(args.config.resolve()),
"configuration_sha256": sha256(args.config),
"code": str(Path(__file__).resolve()),
"code_sha256": sha256(Path(__file__).resolve()),
"python": platform.python_version(),
"torch": torch.__version__,
"numpy": np.__version__,
"pyarrow": pa.__version__,
"input_files": {name: {"path": str(inputs[name]), "sha256": digest} for name, digest in input_hashes_after.items()},
"output_files": [{"path": str(p), "bytes": p.stat().st_size, "sha256": sha256(p)} for p in output_files],
}
manifest_path = exp / "audit/manifest.json"
write_json(manifest_path, manifest)
checksum_targets = output_files + [manifest_path]
checksum_lines = [f"{sha256(path)} {path.relative_to(exp)}" for path in checksum_targets]
(exp / "audit/checksums.sha256").write_text("\n".join(checksum_lines) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()