#!/usr/bin/env python3 """Timing-compatible incoming KC drive paths; not unique causal-spike proofs.""" from __future__ import annotations import argparse import json from collections import defaultdict from pathlib import Path import numpy as np import pandas as pd from phase4h_audit import write_json from phase4n import load_config def main(): ap=argparse.ArgumentParser();ap.add_argument("--config",type=Path,required=True);args=ap.parse_args() ncfg,mcfg,cfg,_=load_config(args.config);exp=Path(ncfg["experiment_root"]) identity=pd.read_parquet(exp/"edge-identity/frozen-edge-identity.parquet") bnodes=np.unique(identity.loc[identity.identity_group=="B-specific","source_neuron_idx"].to_numpy(np.int32)) root=Path(cfg["paths"]["connectome_root"]) indptr=np.load(root/"gpu/csr-indptr.int64.npy",mmap_mode="r") indices=np.load(root/"gpu/csr-indices.int32.npy",mmap_mode="r") raw=np.load(root/"gpu/csr-weights.int64.npy",mmap_mode="r") signs=np.load("/data/digital-drosophila/experiments/phase4a-baseline/outputs/derived/synaptic-sign.int8.npy",mmap_mode="r") meta=pd.read_parquet(root/"neuron-index/unified-metadata.parquet",columns=["neuron_idx","body_id","annotation_class","annotation_type"]) meta=meta.sort_values("neuron_idx").reset_index(drop=True) if len(signs)!=len(meta) or len(indptr)!=len(meta)+1:raise RuntimeError("frozen graph/schema mismatch") dan=pd.read_parquet(Path(cfg["paths"]["phase4h_root"])/"mappings/dan-to-mbon-confirmed.parquet",columns=["source_neuron_idx"]) danset=set(map(int,dan.source_neuron_idx.unique())) edge_ids=np.flatnonzero(np.isin(np.asarray(indices),bnodes)).astype(np.int64) targets=np.asarray(indices[edge_ids],np.int32) sources=(np.searchsorted(indptr,edge_ids,side="right")-1).astype(np.int32) weights=np.asarray(raw[edge_ids],np.float32)*np.asarray(signs[sources],np.float32)*np.float32(cfg["model"]["w_syn_mv_per_anatomical_synapse"]) by_target={int(n):np.flatnonzero(targets==n) for n in bnodes} classes=meta.annotation_class.fillna("").astype(str).to_numpy() def source_class(n): cls=classes[n] if cls in ("ALPN","olfactory"):return "PN/olfactory" if cls=="Kenyon_Cell":return "KC recurrent" if cls=="MBON":return "MBON feedback" if n in danset:return "DAN" if "APL" in str(meta.annotation_type.iat[n]):return "APL anatomical" return "other CNS" rows=[];pairings=[10,20,50,60,75,100] dt=float(cfg["model"]["dt_ms"]);delay=int(round(float(cfg["model"]["synaptic_delay_ms"])/dt)) tau=float(cfg["model"]["synaptic_tau_ms"]);window=int(round(5*tau/dt)) for seed in ncfg["train_seeds"]: for pair in pairings: file=exp/"whole-brain"/f"spike-events-seed-{seed}-pairings-{pair}.parquet" events=pd.read_parquet(file) steps_by_node={int(n):g.step.to_numpy(np.int32) for n,g in events.groupby("neuron_idx")} for target in bnodes: times=steps_by_node.get(int(target)) if times is None or not len(times):continue t=int(times[0]) if t>int(round(float(mcfg["protocol"]["dan_time_ms"])/dt)):continue cat_totals=defaultdict(float);rank=[] for position in by_target[int(target)]: src=int(sources[position]);w=float(weights[position]); if w<=0:continue source_steps=steps_by_node.get(src) if source_steps is None:continue arrived=source_steps+delay active=arrived[(arrived<=t)&(arrived>=t-window)] if not len(active):continue contribution=float(np.sum(w*np.exp(-(t-active)*dt/tau))) category=source_class(src);cat_totals[category]+=contribution latest=int(active[-1]) rank.append((contribution,int(edge_ids[position]),src,latest,category,w)) rank.sort(reverse=True) top=rank[0] if rank else None rows.append({"seed":seed,"pairing":pair,"B_specific_KC_neuron_idx":int(target),"B_specific_KC_body_id":int(meta.body_id.iat[target]), "first_activation_step":t,"first_activation_ms":t*dt,"candidate_predecessor_neuron_idx":None if top is None else top[2], "candidate_predecessor_body_id":None if top is None else int(meta.body_id.iat[top[2]]), "actual_edge_id":None if top is None else top[1],"arrival_ms":None if top is None else top[3]*dt, "candidate_predecessor_population":None if top is None else top[4], "candidate_predecessor_type":None if top is None else str(meta.annotation_type.iat[top[2]]), "anatomical_effective_weight_mv":None if top is None else top[5], "decayed_positive_input_at_spike_mv":None if top is None else top[0], "positive_input_by_source_class_json":json.dumps(dict(cat_totals),sort_keys=True), "APL_graded_inhibition_accounted":False,"unique_causal_predecessor_proven":False, "reason":"Multiple delayed inputs and APL graded inhibition converge; strongest positive timed edge is not a counterfactual causal proof."}) table=pd.DataFrame(rows);table.to_parquet(exp/"whole-brain/B-specific-timed-input-candidates.parquet",index=False) updates=pd.read_parquet(exp/"local-eligibility/event-level-updates.parquet") updates=updates[(updates.identity_group=="B-specific")&updates.pairing.isin(pairings)].copy() linked=updates.merge(table,left_on=["seed","pairing","source_neuron_idx"], right_on=["seed","pairing","B_specific_KC_neuron_idx"],how="left",validate="many_to_one") linked.to_parquet(exp/"whole-brain/B-specific-credit-timed-input.parquet",index=False) distribution=table.candidate_predecessor_population.fillna("none").value_counts().to_dict() if len(table) else {} write_json(exp/"whole-brain/B-specific-timed-input-summary.json",{"audited_B_specific_KC":int(len(bnodes)), "rows":int(len(table)),"source_class_of_strongest_timing_compatible_positive_input":distribution, "linked_B_specific_plastic_update_rows":int(len(linked)), "linked_updates_with_timed_input":int(linked.actual_edge_id.notna().sum()), "causal_predecessor_unambiguously_identified":False, "APL_graded_inhibition_in_source_decomposition":False, "five_synaptic_tau_lookback_ms":5*tau,"exact_frozen_edge_and_delay_used":True, "not_interpretable_as_causal_source_percentages":True}) print(json.dumps({"B_nodes":len(bnodes),"rows":len(table),"timed_positive_source_distribution":distribution}),flush=True) if __name__=="__main__":main()