Files
digital-drosophila/models/male-cns-lif/mb-credit-transfer-audit-v1/credit.py
T
2026-09-30 13:58:39 +08:00

172 lines
8.9 KiB
Python

"""Read-only, float32 TemporalEligibility-v2 trace and fixed A/B identities.
This module consumes already observed full-graph spike events. It neither
simulates neurons nor generates a weight delta. A gate is a virtual readout
after the same-tick KC trace update, not evidence of an actual DAN event.
"""
from __future__ import annotations
import math
import numpy as np
N_GLOBAL = 166700
CATEGORY_CODES = {'inactive': 0, 'A_specific': 1, 'B_specific': 2, 'shared': 3}
MAJORITY = 3
REPLICATES = 5
def _require(condition, message):
if not condition:
raise ValueError(message)
def _vector(value, name, dtype):
_require(isinstance(value, np.ndarray) and value.ndim == 1 and value.dtype == np.dtype(dtype),
name + ' must be a one-dimensional ' + dtype + ' ndarray; no implicit dtype conversion')
return value
def _frozen_scalar(value, expected, name):
_require(not isinstance(value, (bool, np.bool_)) and isinstance(value, (int, float, np.number)) and
np.ndim(value) == 0 and math.isfinite(float(value)) and float(value) == expected,
name + ' must retain its frozen value ' + str(expected))
def replay_credit(step, neuron_idx, kc_globals, steps=1000, gate_tick=900,
dt_ms=.1, tau_ms=100, cap=1):
"""Replay the frozen trace without touching the caller's arrays.
``step`` and ``neuron_idx`` are equal-length int32 vectors for the complete
observed graph (or its KC-only subset), ordered uniquely by (tick, global
neuron). Non-KC events are validated then ignored. ``kc_globals`` is sorted
unique int32. The trace starts at +0 and, on every tick including tick zero,
performs separate float32 multiply, add current spikes, then clamp to 1.
No 18-tick shift is applied: the recorded KC emission tick is the trace
event tick. Gate counts include gate_tick; final counts cover [0, steps).
dt/tau/cap are exposed for explicit provenance but may not be changed.
Shorter steps/gates are allowed for synthetic contract tests.
Returns owned trace_gate/trace_final float32[K] and
spike_count_gate/spike_count_total int32[K], plus scalar audit metadata.
Memory is O(events + KC + global-neuron count), never a dense T-by-K trace.
"""
ticks = _vector(step, 'step', 'int32')
nodes = _vector(neuron_idx, 'neuron_idx', 'int32')
kc = _vector(kc_globals, 'kc_globals', 'int32')
_require(type(steps) is int and 0 < steps <= np.iinfo(np.int32).max, 'steps must be a positive int32-range integer')
_require(type(gate_tick) is int and 0 <= gate_tick < steps, 'gate_tick must lie in [0, steps)')
_frozen_scalar(dt_ms, .1, 'dt_ms')
_frozen_scalar(tau_ms, 100., 'tau_ms')
_frozen_scalar(cap, 1., 'cap')
_require(ticks.shape == nodes.shape, 'Event vectors have different lengths')
_require(np.all((ticks >= 0) & (ticks < steps)), 'Event tick outside the observed window')
_require(np.all((nodes >= 0) & (nodes < N_GLOBAL)), 'Event neuron outside the frozen global graph')
_require(len(ticks) < 2 or np.all((ticks[1:] > ticks[:-1]) |
((ticks[1:] == ticks[:-1]) & (nodes[1:] > nodes[:-1]))),
'Events must be sorted and unique by (tick, global neuron); duplicates are not extra spikes')
_require(len(kc) > 0 and kc[0] >= 0 and kc[-1] < N_GLOBAL and np.all(kc[1:] > kc[:-1]),
'kc_globals must be nonempty, sorted, unique, in-range global identities')
lookup = np.full(N_GLOBAL, -1, np.int32)
lookup[kc] = np.arange(len(kc), dtype=np.int32)
local = lookup[nodes]
selected = local >= 0
kc_ticks, kc_local = ticks[selected], local[selected]
counts_total = np.bincount(kc_local, minlength=len(kc)).astype(np.int32)
counts_gate = np.bincount(kc_local[kc_ticks <= gate_tick], minlength=len(kc)).astype(np.int32)
decay = np.float32(math.exp(-float(dt_ms) / float(tau_ms)))
trace = np.zeros(len(kc), np.float32)
trace_gate = None
begin = 0
for tick in range(steps):
# Preserve the source's separate rounding operations; do not substitute
# float64 exponentiation by elapsed time or shift spikes to arrivals.
np.multiply(trace, decay, out=trace)
end = int(np.searchsorted(kc_ticks, tick, side='right'))
if end > begin:
firing = kc_local[begin:end]
trace[firing] = np.add(trace[firing], np.float32(1.0))
np.minimum(trace, np.float32(1.0), out=trace)
if tick == gate_tick:
trace_gate = trace.copy()
begin = end
_require(begin == len(kc_ticks) and trace_gate is not None and np.isfinite(trace).all(), 'Incomplete or nonfinite trace replay')
return {
'trace_gate': trace_gate, 'trace_final': trace.copy(),
'spike_count_gate': counts_gate, 'spike_count_total': counts_total,
'steps': steps, 'gate_tick': gate_tick, 'dt_ms': float(dt_ms), 'tau_ms': float(tau_ms), 'cap': float(cap),
'decay_f32_bits': f'{int(decay.view(np.uint32)):08x}',
'total_input_events': int(len(ticks)), 'total_KC_events': int(len(kc_ticks)),
'KC_events_through_gate': int(counts_gate.sum(dtype=np.int64)),
'gate_includes_same_tick_spikes': True, 'event_shift_steps': 0,
'gate_is_virtual_readout_not_DAN_activity': True,
'weights_modified': False, 'weight_delta_generated': False,
}
def _traces(value, name):
_require(isinstance(value, np.ndarray) and value.ndim == 2 and value.dtype == np.dtype('float32') and
value.shape[0] == REPLICATES and value.shape[1] > 0,
name + ' must have exact shape (5, KC) and float32 dtype')
_require(np.isfinite(value).all() and np.all((value >= 0) & (value <= 1)),
name + ' must contain finite frozen-cap traces in [0, 1]')
return value
def _set_summary(active_a, active_b, category):
intersection = int(np.count_nonzero(active_a & active_b))
union = int(np.count_nonzero(active_a | active_b))
return {
'total': int(len(category)), 'A_active': int(np.count_nonzero(active_a)),
'B_active': int(np.count_nonzero(active_b)), 'intersection': intersection, 'union': union,
'active_set_jaccard': float(intersection / union) if union else 1.0,
'empty_union': union == 0,
'category_counts': {name: int(np.count_nonzero(category == code)) for name, code in CATEGORY_CODES.items()},
}
def pair_identity(traces_a, traces_b, edge_source_local):
"""Classify fixed paired five-seed A/B traces, without installing plasticity.
A KC is A-active/B-active if its gate trace is strictly positive in at
least 3 of 5 corresponding trials. Categories are int8: inactive=0,
A_specific=1, B_specific=2, shared=3. Every candidate edge inherits its
source KC's category in the supplied edge order. Multiple edges sharing
a KC are retained as separate rows, not independent neural observations.
``edge_source_local`` is int32[E] indexing the common KC axis of both
trace matrices. The caller must bind that axis and the five paired seed
rows to the frozen identities; this pure function cannot infer labels.
Jaccard uses COMPLETE active A/B sets, not the disjoint specific sets.
Empty/empty Jaccard is 1 with empty_union=True, never evidence of useful
credit. Compartment stratification and any success gate belong to caller.
"""
a, b = _traces(traces_a, 'traces_a'), _traces(traces_b, 'traces_b')
_require(a.shape == b.shape, 'A and B trace matrices use different KC axes')
source = _vector(edge_source_local, 'edge_source_local', 'int32')
_require(np.all((source >= 0) & (source < a.shape[1])), 'Candidate edge source is not a valid KC-local index')
positive_a = np.count_nonzero(a > np.float32(0), axis=0).astype(np.int32)
positive_b = np.count_nonzero(b > np.float32(0), axis=0).astype(np.int32)
active_a, active_b = positive_a >= MAJORITY, positive_b >= MAJORITY
category = active_a.astype(np.int8) + np.int8(2) * active_b.astype(np.int8)
edge_a, edge_b, edge_category = active_a[source].copy(), active_b[source].copy(), category[source].copy()
return {
'kc_category': category, 'edge_category': edge_category,
'kc_active_a': active_a, 'kc_active_b': active_b,
'edge_active_a': edge_a, 'edge_active_b': edge_b,
'kc_positive_trials_a': positive_a, 'kc_positive_trials_b': positive_b,
'summary': {
'replicates_per_condition': REPLICATES, 'majority_required': MAJORITY,
'positivity_rule': 'float32 trace > 0; no fitted epsilon', 'category_codes': dict(CATEGORY_CODES),
'KC': _set_summary(active_a, active_b, category),
'edges': _set_summary(edge_a, edge_b, edge_category),
'jaccard_scope': 'complete majority-active A and B sets, including shared members',
'empty_union_jaccard_convention': 1.0,
'edge_counts_are_source_degree_weighted': True,
'actual_DAN_credit_claimed': False, 'weights_modified': False, 'weight_delta_generated': False,
},
}