646 lines
30 KiB
Python
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()
|