495 lines
31 KiB
Python
495 lines
31 KiB
Python
"""Pure Phase 4AN proposal metrics; no file I/O, simulation or fitted decoder.
|
|
|
|
The caller must bind complete anatomical population membership (not a plastic
|
|
mask subset), finalized execution receipts, input identities and seed lineages
|
|
to its preregistration. These functions validate arrays/declared coverage; they
|
|
cannot establish provenance by trusting a caller's Boolean or SHA string.
|
|
All gates below are NEW DRAFT hypotheses, not a historical learning result.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import math
|
|
from collections.abc import Mapping
|
|
|
|
import numpy as np
|
|
|
|
|
|
SCHEMA = 'phase4an-metrics-draft-v1'
|
|
CUES = ('4-ethylphenol', '3-hexanone')
|
|
MODES = ('physiological', 'matched_energy')
|
|
DAN_TYPES = ('PAM15', 'PPL103')
|
|
CONTROLS = ('D=0', 'frozen', 'random reward', 'unpaired', 'CS-only', 'DAN-only')
|
|
ARMS = ('paired',) + CONTROLS
|
|
POPULATIONS = ('MBON', 'legacyDownstream', 'nonMBONDownstream', 'MBON01', 'MBON12')
|
|
SEPARATION_POPULATIONS = ('MBON', 'legacyDownstream', 'nonMBONDownstream')
|
|
POPULATION_SEMANTICS = {
|
|
'MBON': 'complete caller-bound anatomical MBON population',
|
|
'legacyDownstream': 'complete frozen H downstream membership, including MBONs; never mask-filtered',
|
|
'nonMBONDownstream': 'exact legacyDownstream minus MBON; NEW hypothesis readout, not the old H population',
|
|
'MBON01': 'complete caller-bound MBON01 type, not plastic-mask targets only',
|
|
'MBON12': 'complete caller-bound MBON12 type, not plastic-mask targets only',
|
|
}
|
|
STATE_KEYS = frozenset(('step', 'neuron_idx', 'counts', 'v', 'g', 'ref'))
|
|
|
|
|
|
def require(condition, message):
|
|
if not condition:
|
|
raise ValueError(message)
|
|
|
|
|
|
def _hash(value):
|
|
return hashlib.sha256(np.ascontiguousarray(value, dtype=value.dtype.newbyteorder('<')).tobytes()).hexdigest()
|
|
|
|
|
|
def _json_hash(value):
|
|
raw = json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode('utf-8')
|
|
return hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
def _sha(value):
|
|
return type(value) is str and len(value) == 64 and all(c in '0123456789abcdef' for c in value)
|
|
|
|
|
|
def _token(value, name):
|
|
require(type(value) is str and bool(value) and value.strip() == value, 'Invalid ' + name)
|
|
return value
|
|
|
|
|
|
def _vector(value, dtype, name, size=None):
|
|
require(isinstance(value, np.ndarray) and value.dtype == np.dtype(dtype) and value.ndim == 1,
|
|
name + ' requires an exact ' + dtype + ' vector')
|
|
require(size is None or value.shape == (size,), name + ' shape mismatch')
|
|
return value
|
|
|
|
|
|
def _ids(value, name):
|
|
_vector(value, 'int32', name)
|
|
require(len(value) > 0 and np.all((value >= 0) & (value < 166700)) and np.all(value[1:] > value[:-1]),
|
|
name + ' must be nonempty sorted unique global neuron indices')
|
|
return value
|
|
|
|
|
|
def _positions(nodes, values, name):
|
|
position = np.searchsorted(nodes, values)
|
|
require(np.all(position < len(nodes)), name + ' contains absent neurons')
|
|
require(np.array_equal(nodes[position], values), name + ' contains absent neurons')
|
|
return position
|
|
|
|
|
|
def validate_populations(nodes, populations):
|
|
"""Validate full caller-bound type lists; never derive them from an edge mask."""
|
|
_ids(nodes, 'nodes')
|
|
require(isinstance(populations, Mapping) and set(populations) == set(POPULATIONS),
|
|
'Require exactly MBON/legacyDownstream/nonMBONDownstream/MBON01/MBON12 memberships')
|
|
for name in POPULATIONS:
|
|
_positions(nodes, _ids(populations[name], name), name)
|
|
for name in ('MBON01', 'MBON12'):
|
|
_positions(populations['MBON'], populations[name], name + ' outside MBON')
|
|
require(not len(np.intersect1d(populations['MBON01'], populations['MBON12'])), 'Type populations overlap')
|
|
_positions(populations['legacyDownstream'], populations['MBON'], 'MBON outside legacyDownstream')
|
|
require(np.array_equal(populations['nonMBONDownstream'],
|
|
np.setdiff1d(populations['legacyDownstream'], populations['MBON'])),
|
|
'nonMBONDownstream must equal the complete legacyDownstream minus MBON')
|
|
payload = {name: populations[name].tolist() for name in POPULATIONS}
|
|
return dict(indices=payload, membership_sha256=_json_hash(payload),
|
|
population_semantics=dict(POPULATION_SEMANTICS),
|
|
complete_anatomical_membership_authority='caller preregistration; never inferred from mask')
|
|
|
|
|
|
def validate_state(state, *, nodes, steps, dt_ms=.1):
|
|
"""Check every original state element and all sparse events without mutation."""
|
|
_ids(nodes, 'nodes')
|
|
require(type(steps) is int and 0 < steps <= np.iinfo(np.int32).max, 'Invalid steps')
|
|
require(type(dt_ms) in (int, float) and math.isfinite(dt_ms) and dt_ms > 0, 'Invalid dt_ms')
|
|
require(isinstance(state, Mapping) and set(state) == STATE_KEYS, 'State must contain exactly six runtime keys')
|
|
n = len(nodes)
|
|
for key, dtype in (('counts', 'int32'), ('ref', 'int32'), ('v', 'float64'), ('g', 'float64')):
|
|
_vector(state[key], dtype, key, n)
|
|
require(np.isfinite(state[key]).all(), 'Nonfinite state: ' + key)
|
|
require(np.all((state['counts'] >= 0) & (state['counts'] <= steps)), 'Invalid counts')
|
|
require(np.all((state['ref'] >= 0) & (state['ref'] <= 22)), 'Invalid refractory state')
|
|
ticks = _vector(state['step'], 'int32', 'step')
|
|
events = _vector(state['neuron_idx'], 'int32', 'neuron_idx', len(ticks))
|
|
require(np.all((ticks >= 0) & (ticks < steps)), 'Event tick outside trial')
|
|
require(len(ticks) < 2 or np.all((ticks[1:] > ticks[:-1]) |
|
|
((ticks[1:] == ticks[:-1]) & (events[1:] > events[:-1]))),
|
|
'Events must be sorted unique by tick/global neuron')
|
|
local = _positions(nodes, events, 'events')
|
|
require(np.array_equal(np.bincount(local, minlength=n), state['counts']), 'Sparse events/counts mismatch')
|
|
first = np.full(n, -1, dtype=np.int32)
|
|
if len(ticks):
|
|
first.fill(steps)
|
|
np.minimum.at(first, local, ticks)
|
|
first[first == steps] = -1
|
|
return dict(first_tick=first, local_events=local,
|
|
hashes={key: _hash(state[key]) for key in sorted(STATE_KEYS)})
|
|
|
|
|
|
def _first_ms(first, dt_ms):
|
|
present = first[first >= 0]
|
|
return float(int(present.min()) * dt_ms) if len(present) else None
|
|
|
|
|
|
def _latencies(first, dt_ms):
|
|
return [None if int(tick) < 0 else float(int(tick) * dt_ms) for tick in first]
|
|
|
|
|
|
def _population_change(pre, post, checked_pre, checked_post, nodes, local, dt_ms, vectors):
|
|
cp, cq = pre['counts'][local].astype(np.int64), post['counts'][local].astype(np.int64)
|
|
fp, fq = checked_pre['first_tick'][local], checked_post['first_tick'][local]
|
|
lp, lq = _first_ms(fp, dt_ms), _first_ms(fq, dt_ms)
|
|
both = (fp >= 0) & (fq >= 0)
|
|
ep = np.isin(checked_pre['local_events'], local)
|
|
eq = np.isin(checked_post['local_events'], local)
|
|
events_same = (np.array_equal(pre['step'][ep], post['step'][eq]) and
|
|
np.array_equal(pre['neuron_idx'][ep], post['neuron_idx'][eq]))
|
|
try:
|
|
with np.errstate(over='raise', invalid='raise'):
|
|
dv, dg = post['v'][local] - pre['v'][local], post['g'][local] - pre['g'][local]
|
|
vmax, gmax = float(np.max(np.abs(dv))), float(np.max(np.abs(dg)))
|
|
except FloatingPointError as error:
|
|
raise ValueError('Nonfinite derived final-state difference') from error
|
|
result = dict(neurons=len(local), pre_total_spikes=int(cp.sum()), post_total_spikes=int(cq.sum()),
|
|
spike_count_l1=int(np.abs(cq - cp).sum()), spike_count_changed_neurons=int(np.count_nonzero(cp != cq)),
|
|
events_changed=not events_same, pre_first_latency_ms=lp, post_first_latency_ms=lq,
|
|
first_latency_delta_ms=None if lp is None or lq is None else float(lq - lp),
|
|
defined_first_latency_changed=bool(lp is not None and lq is not None and lp != lq),
|
|
first_latency_both_defined_neurons=int(both.sum()),
|
|
first_latency_changed_among_both_defined_neurons=int(np.count_nonzero(both & (fp != fq))),
|
|
first_latency_became_defined_neurons=int(np.count_nonzero((fp < 0) & (fq >= 0))),
|
|
first_latency_became_undefined_neurons=int(np.count_nonzero((fp >= 0) & (fq < 0))),
|
|
final_delta_v_max_abs=vmax, final_delta_g_max_abs=gmax,
|
|
final_ref_changed_neurons=int(np.count_nonzero(pre['ref'][local] != post['ref'][local])),
|
|
final_state_units='SI volts; refractory integer ticks',
|
|
max_abs_scope='maximum across population neurons at terminal tick; NOT a temporal peak')
|
|
if vectors:
|
|
result.update(neuron_idx=nodes[local].tolist(), pre_counts=cp.tolist(), post_counts=cq.tolist(),
|
|
pre_first_latency_ms_per_neuron=_latencies(fp, dt_ms),
|
|
post_first_latency_ms_per_neuron=_latencies(fq, dt_ms))
|
|
for key in ('v', 'g', 'ref'):
|
|
result['pre_final_' + key] = pre[key][local].tolist()
|
|
result['post_final_' + key] = post[key][local].tolist()
|
|
return result
|
|
|
|
|
|
def functional_summary(pre, post, *, nodes, populations, steps, dt_ms=.1):
|
|
"""Full-state validation plus wholebrain and anatomical-group differences.
|
|
|
|
The population's earliest event defines the legacy-style first latency.
|
|
Per-neuron first events and missingness transitions are additional diagnostics.
|
|
A missing latency is None, never zero or evidence of a defined latency shift.
|
|
"""
|
|
membership = validate_populations(nodes, populations)
|
|
a = validate_state(pre, nodes=nodes, steps=steps, dt_ms=dt_ms)
|
|
b = validate_state(post, nodes=nodes, steps=steps, dt_ms=dt_ms)
|
|
groups = {name: _population_change(pre, post, a, b, nodes,
|
|
_positions(nodes, populations[name], name), dt_ms, True) for name in POPULATIONS}
|
|
return dict(schema=SCHEMA, full_six_key_state_validated=True, steps=steps, dt_ms=float(dt_ms),
|
|
nodes=len(nodes), nodes_raw_sha256=_hash(nodes), membership=membership,
|
|
pre_state_raw_sha256=a['hashes'], post_state_raw_sha256=b['hashes'],
|
|
wholebrain=_population_change(pre, post, a, b, nodes, np.arange(len(nodes)), dt_ms, False),
|
|
populations=groups, MBON_final_delta_g_max_abs=groups['MBON']['final_delta_g_max_abs'],
|
|
MBON_final_delta_g_units='SI volts', MBON_final_delta_g_scope='terminal across-neuron maximum, not time peak')
|
|
|
|
|
|
def arm_metrics(pre_by_cue, post_by_cue, *, conditioned_cue, nodes, populations, steps=1200, dt_ms=.1):
|
|
"""New fixed, unscaled type-count contrast; neither decoder nor feedback."""
|
|
require(conditioned_cue in CUES, 'Unknown conditioned odor label')
|
|
require(isinstance(pre_by_cue, Mapping) and isinstance(post_by_cue, Mapping) and
|
|
set(pre_by_cue) == set(post_by_cue) == set(CUES), 'Both original odor labels are required')
|
|
neutral = next(cue for cue in CUES if cue != conditioned_cue)
|
|
summaries = {cue: functional_summary(pre_by_cue[cue], post_by_cue[cue], nodes=nodes,
|
|
populations=populations, steps=steps, dt_ms=dt_ms) for cue in CUES}
|
|
b, means = {}, {}
|
|
for phase in ('pre', 'post'):
|
|
b[phase], means[phase] = {}, {}
|
|
for cue in CUES:
|
|
groups = summaries[cue]['populations']
|
|
means[phase][cue] = {name: float(np.mean(groups[name][phase + '_counts'])) for name in ('MBON01', 'MBON12')}
|
|
b[phase][cue] = means[phase][cue]['MBON12'] - means[phase][cue]['MBON01']
|
|
raw_s = (b['post'][conditioned_cue] - b['post'][neutral]) - (b['pre'][conditioned_cue] - b['pre'][neutral])
|
|
separation = {}
|
|
for name in SEPARATION_POPULATIONS:
|
|
values = {}
|
|
for phase in ('pre', 'post'):
|
|
first = np.asarray(summaries[CUES[0]]['populations'][name][phase + '_counts'], dtype=np.float64)
|
|
second = np.asarray(summaries[CUES[1]]['populations'][name][phase + '_counts'], dtype=np.float64)
|
|
values[phase] = float(np.linalg.norm(first - second))
|
|
separation[name] = dict(values, gain=values['post'] - values['pre'],
|
|
vector='all bound member spike counts', population_semantics=POPULATION_SEMANTICS[name])
|
|
return dict(schema=SCHEMA, analysis_status='DRAFT_NEW_HYPOTHESIS', conditioned_cue=conditioned_cue,
|
|
neutral_cue=neutral, steps=steps, dt_ms=float(dt_ms), rawB=b, rawS=float(raw_s),
|
|
type_mean_counts=means, B_definition='mean full MBON12 counts - mean full MBON01 counts',
|
|
B_is_decoder=False, S_orientation='conditioned minus neutral; post minus own pre',
|
|
separation=separation, cues=summaries,
|
|
historical_implementation_equivalence_claimed=False, learning_pass_claimed=False)
|
|
|
|
|
|
def _finite_tree(value):
|
|
if isinstance(value, Mapping):
|
|
for item in value.values():
|
|
_finite_tree(item)
|
|
elif isinstance(value, (list, tuple)):
|
|
for item in value:
|
|
_finite_tree(item)
|
|
elif isinstance(value, (float, np.floating)):
|
|
require(math.isfinite(float(value)), 'Nonfinite metric value')
|
|
|
|
|
|
def _bound_metric(metric, cue):
|
|
require(isinstance(metric, Mapping) and metric.get('schema') == SCHEMA and
|
|
metric.get('analysis_status') == 'DRAFT_NEW_HYPOTHESIS', 'Unexpected metric schema')
|
|
_finite_tree(metric)
|
|
require(metric['conditioned_cue'] == cue and metric['neutral_cue'] == next(c for c in CUES if c != cue),
|
|
'Cue role mismatch')
|
|
require(metric['steps'] == 1200 and metric['dt_ms'] == .1, 'Evaluation must use the fixed 120 ms probe')
|
|
# Derive gate quantities from recorded raw vectors, not a supplied gate Boolean.
|
|
for odor in CUES:
|
|
summary = metric['cues'][odor]
|
|
require(summary.get('full_six_key_state_validated') is True and summary['steps'] == 1200 and
|
|
summary['dt_ms'] == .1 and _sha(summary['nodes_raw_sha256']), 'Unvalidated cue state')
|
|
membership = summary['membership']['indices']
|
|
require(set(membership) == set(POPULATIONS) and summary['membership']['membership_sha256'] == _json_hash(membership),
|
|
'Population membership digest mismatch')
|
|
require(summary['membership'].get('population_semantics') == POPULATION_SEMANTICS,
|
|
'Population semantics must distinguish old H membership from the new non-MBON readout')
|
|
for key in ('pre_state_raw_sha256', 'post_state_raw_sha256'):
|
|
require(set(summary[key]) == STATE_KEYS and all(_sha(v) for v in summary[key].values()), 'Missing state digest')
|
|
for name in POPULATIONS:
|
|
group = summary['populations'][name]
|
|
ids, count = group['neuron_idx'], group['neurons']
|
|
require(type(count) is int and count > 0 and len(ids) == count and ids == membership[name] and
|
|
all(type(v) is int and 0 <= v < 166700 for v in ids) and ids == sorted(set(ids)),
|
|
'Empty or mismatched anatomical population')
|
|
for phase in ('pre', 'post'):
|
|
counts = group[phase + '_counts']
|
|
latency = group[phase + '_first_latency_ms_per_neuron']
|
|
require(len(counts) == len(latency) == count and
|
|
all(type(v) is int and 0 <= v <= 1200 for v in counts), 'Invalid count/latency vector')
|
|
for c, value in zip(counts, latency):
|
|
require((c == 0 and value is None) or (c > 0 and type(value) in (int, float) and
|
|
0 <= value < 120 and value == round(value / .1) * .1), 'Missing/nonphysical first latency')
|
|
present = [v for v in latency if v is not None]
|
|
require(group[phase + '_first_latency_ms'] == (min(present) if present else None),
|
|
'Population first latency differs from raw cells')
|
|
for key in ('v', 'g', 'ref'):
|
|
values = group[phase + '_final_' + key]
|
|
require(len(values) == count and all(type(v) in (int, float) and math.isfinite(v) for v in values),
|
|
'Invalid final-state vector')
|
|
if key == 'ref':
|
|
require(all(type(v) is int and 0 <= v <= 22 for v in values), 'Invalid final ref')
|
|
l1 = sum(abs(a - b) for a, b in zip(group['pre_counts'], group['post_counts']))
|
|
lp, lq = group['pre_first_latency_ms'], group['post_first_latency_ms']
|
|
changed = lp is not None and lq is not None and lp != lq
|
|
require(type(group['spike_count_l1']) is int and group['spike_count_l1'] == l1 and
|
|
group['defined_first_latency_changed'] is changed and
|
|
group['first_latency_delta_ms'] == (None if lp is None or lq is None else lq - lp),
|
|
'Functional predicate differs from raw counts/defined latency')
|
|
try:
|
|
with np.errstate(over='raise', invalid='raise'):
|
|
for key in ('v', 'g'):
|
|
difference = np.asarray(group['post_final_' + key]) - np.asarray(group['pre_final_' + key])
|
|
require(group['final_delta_' + key + '_max_abs'] == float(np.max(np.abs(difference))),
|
|
'Final-state maximum differs from raw terminal vectors')
|
|
except FloatingPointError as error:
|
|
raise ValueError('Nonfinite derived terminal metric') from error
|
|
require(not set(membership['MBON01']).intersection(membership['MBON12']), 'Type population overlap')
|
|
require(set(membership['MBON']) <= set(membership['legacyDownstream']),
|
|
'Legacy downstream must retain the complete MBON population')
|
|
require(membership['nonMBONDownstream'] == sorted(set(membership['legacyDownstream']) - set(membership['MBON'])),
|
|
'nonMBONDownstream differs from exact legacyDownstream minus MBON')
|
|
for name, parent in (('MBON01', 'MBON'), ('MBON12', 'MBON'),
|
|
('MBON', 'legacyDownstream'), ('nonMBONDownstream', 'legacyDownstream')):
|
|
require(set(membership[name]) <= set(membership[parent]), 'Population is not a subset of its bound parent')
|
|
lookup = {v: i for i, v in enumerate(membership[parent])}
|
|
for phase in ('pre', 'post'):
|
|
for field in ('counts', 'first_latency_ms_per_neuron', 'final_v', 'final_g', 'final_ref'):
|
|
key = phase + '_' + field
|
|
require(summary['populations'][name][key] == [
|
|
summary['populations'][parent][key][lookup[v]] for v in membership[name]],
|
|
'Overlapping population vectors disagree with the bound parent')
|
|
for phase in ('pre', 'post'):
|
|
for odor in CUES:
|
|
groups = metric['cues'][odor]['populations']
|
|
means = []
|
|
for name in ('MBON12', 'MBON01'):
|
|
counts = groups[name][phase + '_counts']
|
|
require(len(counts) > 0 and all(type(v) is int and 0 <= v <= 1200 for v in counts),
|
|
'Empty or invalid type count vector')
|
|
means.append(sum(counts) / len(counts))
|
|
require(metric['type_mean_counts'][phase][odor][name] == means[-1], 'Type mean differs from raw counts')
|
|
require(metric['rawB'][phase][odor] == means[0] - means[1], 'Raw B disagrees with full type vectors')
|
|
neutral = metric['neutral_cue']
|
|
calculated = (metric['rawB']['post'][cue] - metric['rawB']['post'][neutral]) - (
|
|
metric['rawB']['pre'][cue] - metric['rawB']['pre'][neutral])
|
|
require(type(metric['rawS']) in (int, float) and metric['rawS'] == calculated, 'Raw S disagrees with signed components')
|
|
for name in SEPARATION_POPULATIONS:
|
|
calculated = {}
|
|
for phase in ('pre', 'post'):
|
|
a = metric['cues'][CUES[0]]['populations'][name][phase + '_counts']
|
|
b = metric['cues'][CUES[1]]['populations'][name][phase + '_counts']
|
|
require(metric['cues'][CUES[0]]['membership'] == metric['cues'][CUES[1]]['membership'],
|
|
'A/B population mismatch')
|
|
calculated[phase] = float(np.linalg.norm(np.asarray(a, dtype=np.float64) - np.asarray(b, dtype=np.float64)))
|
|
require(metric['separation'][name]['pre'] == calculated['pre'] and
|
|
metric['separation'][name]['post'] == calculated['post'] and
|
|
metric['separation'][name]['gain'] == calculated['post'] - calculated['pre'],
|
|
'Separation gain differs from raw count vectors')
|
|
require(metric['separation'][name].get('population_semantics') == POPULATION_SEMANTICS[name],
|
|
'Separation population semantics mismatch')
|
|
return metric
|
|
|
|
|
|
def _execution(arm, name, assay, used_receipts):
|
|
require(isinstance(arm, Mapping) and set(arm) == {'metrics', 'execution'}, 'Arm needs metrics and actual execution evidence')
|
|
e = arm['execution']
|
|
required = {'complete', 'recording_verified', 'receipt_sha256', 'assay', 'epoch_duration_ms',
|
|
'cs_duration_ms', 'us_tick', 'completed_epochs', 'reinforcement_opportunities', 'terminal_pairings'}
|
|
require(isinstance(e, Mapping) and set(e) == required, 'Incomplete execution evidence')
|
|
require(e['complete'] is True and e['recording_verified'] is True and _sha(e['receipt_sha256']),
|
|
'All arms require an actual verified completed execution receipt')
|
|
require(e['receipt_sha256'] not in used_receipts, 'Execution receipt reused across arms/replicates')
|
|
used_receipts.add(e['receipt_sha256'])
|
|
require(e['assay'] == assay and e['epoch_duration_ms'] == (120. if assay == 'normal' else 180.) and
|
|
e['cs_duration_ms'] == 100. and type(e['us_tick']) is int and
|
|
e['us_tick'] == (900 if assay == 'normal' else 1500), 'Normal/delayed control timing mismatch')
|
|
require(type(e['completed_epochs']) is int and e['completed_epochs'] == (150 if name == 'unpaired' else 100),
|
|
'Incomplete arm epoch inventory')
|
|
require(type(e['reinforcement_opportunities']) is int and e['reinforcement_opportunities'] == (0 if name == 'CS-only' else 50),
|
|
'Wrong arm US opportunity inventory')
|
|
require(type(e['terminal_pairings']) is int and e['terminal_pairings'] == 50, 'Only fixed terminal count 50 may gate')
|
|
|
|
|
|
def evaluate_acquisition(replicates, *, assay, mode, dan_type, conditioned_cue, registered_training_streams):
|
|
"""Strict new normal/delayed 3-of-5 SAME-training-replicate conjunction.
|
|
|
|
Each row has replicate_id, replicate_unit='independent_training',
|
|
training_stream_ids (all independent source IDs, not aliases),
|
|
evaluation_stream_ids, assay/mode/dan_type/conditioned_cue, and seven arms.
|
|
Arm = {metrics: arm_metrics(...), execution: complete receipt declaration}.
|
|
registered_training_streams maps exactly five predeclared replicate IDs to
|
|
their source-ID lists. Aliased controls stay within a row; evaluation cannot
|
|
become new training replication. This pure function does NOT verify a file
|
|
receipt/seed derivation; the coordinator must independently do so.
|
|
"""
|
|
require(assay in ('normal', 'delayed') and mode in MODES and dan_type in DAN_TYPES and conditioned_cue in CUES,
|
|
'Unknown raw assay/mode/DAN/odor label')
|
|
require(isinstance(registered_training_streams, Mapping) and len(registered_training_streams) == 5,
|
|
'Require five predeclared independent training replicates')
|
|
registered, training_all = {}, set()
|
|
for identity, sources in registered_training_streams.items():
|
|
_token(identity, 'training replicate identity')
|
|
require(isinstance(sources, (tuple, list)) and len(sources) > 0, 'Missing actual training source identities')
|
|
values = tuple(_token(v, 'training source identity') for v in sources)
|
|
require(len(set(values)) == len(values) and not training_all.intersection(values),
|
|
'Training source reused across independent replicates')
|
|
training_all.update(values)
|
|
registered[identity] = values
|
|
require(isinstance(replicates, (tuple, list)) and len(replicates) == 5, 'Exactly five training rows required; do not count evaluation draws')
|
|
used_ids, used_eval, receipts, reports = set(), set(), set(), []
|
|
for row in replicates:
|
|
keys = {'replicate_id', 'replicate_unit', 'training_stream_ids', 'evaluation_stream_ids',
|
|
'assay', 'mode', 'dan_type', 'conditioned_cue', 'arms'}
|
|
require(isinstance(row, Mapping) and set(row) == keys, 'Invalid replicate schema')
|
|
identity = row['replicate_id']
|
|
require(identity in registered and identity not in used_ids and row['replicate_unit'] == 'independent_training',
|
|
'Unknown/duplicate training identity or evaluation pseudo-replication')
|
|
used_ids.add(identity)
|
|
require(isinstance(row['training_stream_ids'], (list, tuple)) and
|
|
tuple(row['training_stream_ids']) == registered[identity], 'Training lineage does not match preregistration')
|
|
require((row['assay'], row['mode'], row['dan_type'], row['conditioned_cue']) == (assay, mode, dan_type, conditioned_cue),
|
|
'Cannot pool modes, DAN branches, cue assignments or timing suites')
|
|
ev = row['evaluation_stream_ids']
|
|
require(isinstance(ev, (tuple, list)) and len(ev) > 0, 'Missing independent evaluation identity')
|
|
ev = tuple(_token(v, 'evaluation identity') for v in ev)
|
|
require(len(set(ev)) == len(ev) and not training_all.intersection(ev) and not used_eval.intersection(ev),
|
|
'Evaluation sources overlap training or another training replicate')
|
|
used_eval.update(ev)
|
|
require(isinstance(row['arms'], Mapping) and set(row['arms']) == set(ARMS), 'All six actual controls and paired arm are mandatory')
|
|
metrics = {}
|
|
for name in ARMS:
|
|
_execution(row['arms'][name], name, assay, receipts)
|
|
metrics[name] = _bound_metric(row['arms'][name]['metrics'], conditioned_cue)
|
|
baseline = metrics['paired']
|
|
# State baseline and populations must really be matched for each control.
|
|
for metric in metrics.values():
|
|
for cue in CUES:
|
|
a, b = baseline['cues'][cue], metric['cues'][cue]
|
|
require(a['membership'] == b['membership'] and a['nodes_raw_sha256'] == b['nodes_raw_sha256'] and
|
|
a['pre_state_raw_sha256'] == b['pre_state_raw_sha256'], 'Control population or pre-baseline mismatch')
|
|
s = baseline['rawS']
|
|
control_s = {name: metrics[name]['rawS'] for name in CONTROLS}
|
|
direction = s > 0 if dan_type == 'PAM15' else s < 0
|
|
beyond = {name: (s > value if dan_type == 'PAM15' else s < value) for name, value in control_s.items()}
|
|
neutral = baseline['neutral_cue']
|
|
c = baseline['cues'][conditioned_cue]['populations']['MBON']
|
|
n = baseline['cues'][neutral]['populations']['MBON']
|
|
functional = c['spike_count_l1'] > 0 or c['defined_first_latency_changed'] is True
|
|
predicates = dict(raw_signed_direction=bool(direction), beyond_each_actual_control=all(beyond.values()),
|
|
conditioned_final_g_exceeds_neutral=c['final_delta_g_max_abs'] > n['final_delta_g_max_abs'],
|
|
conditioned_count_or_defined_population_first_latency_change=bool(functional),
|
|
all_MBON_AB_euclidean_gain_positive=baseline['separation']['MBON']['gain'] > 0,
|
|
legacyDownstream_AB_euclidean_gain_positive=baseline['separation']['legacyDownstream']['gain'] > 0,
|
|
nonMBONDownstream_AB_euclidean_gain_positive=baseline['separation']['nonMBONDownstream']['gain'] > 0)
|
|
reports.append(dict(replicate_id=identity, rawS=s, raw_control_S=control_s,
|
|
population_separation_gains={name: baseline['separation'][name]['gain'] for name in SEPARATION_POPULATIONS},
|
|
beyond_controls= beyond, predicates=predicates, conjunction_pass=all(predicates.values()),
|
|
legacy_audit=dict(L_like_functional_with_explicit_missing_latency=bool(
|
|
predicates['conditioned_final_g_exceeds_neutral'] and functional),
|
|
H_like_MBON_positive_gain=predicates['all_MBON_AB_euclidean_gain_positive'],
|
|
H_like_downstream_population='legacyDownstream',
|
|
H_like_downstream_positive_gain=predicates['legacyDownstream_AB_euclidean_gain_positive'],
|
|
original_implementation_equivalence_claimed=False),
|
|
new_hypothesis_audit=dict(nonMBONDownstream_positive_gain=
|
|
predicates['nonMBONDownstream_AB_euclidean_gain_positive'],
|
|
inherited_from_old_H=False, either_downstream_may_rescue_other=False)))
|
|
reports.sort(key=lambda r: r['replicate_id'])
|
|
successful = sum(r['conjunction_pass'] for r in reports)
|
|
return dict(schema=SCHEMA, status='DRAFT-PROPOSAL-GATE-PASS' if successful >= 3 else 'DRAFT-PROPOSAL-GATE-FAIL',
|
|
proposal_gate_pass=successful >= 3, scientific_learning_pass_claimed=False, candidate_generated=False,
|
|
assay=assay, mode=mode, dan_type=dan_type, conditioned_cue=conditioned_cue,
|
|
required_training_replicates=5, successful_same_replicate_conjunctions=successful, required_majority=3,
|
|
actual_control_receipts=len(receipts) - 5, evaluation_draws_count_as_replicates=False,
|
|
execution_receipt_authority='caller must verify finalized receipts and seed registry; pure metrics do not read files',
|
|
historical_full_H_mean_control_rule_computed=False, replicates=reports)
|
|
|
|
|
|
def legacy_h_mean_control_audit(protocol_mbon_gains):
|
|
"""Old H scalar predicate only, kept separate from the new signed gate.
|
|
|
|
Input uses the ORIGINAL five protocol labels, each with five already-bound
|
|
training-replicate MBON Euclidean gains. The caller must explicitly define
|
|
any AN-to-old-protocol mapping; this function never picks a DAN/control or
|
|
pools AN cells. It is not the entire old H gate or a reproduced old result.
|
|
"""
|
|
names = ('appetitive-conditioning', 'aversive-conditioning', 'd-zero-control',
|
|
'random-reward-control', 'frozen-brain-control')
|
|
require(isinstance(protocol_mbon_gains, Mapping) and set(protocol_mbon_gains) == set(names),
|
|
'Legacy audit requires exactly the five original H protocol labels')
|
|
means = {}
|
|
for name in names:
|
|
values = protocol_mbon_gains[name]
|
|
require(isinstance(values, (list, tuple)) and len(values) == 5 and
|
|
all(type(v) in (int, float) and math.isfinite(v) for v in values),
|
|
'Legacy audit needs five finite, separately bound training-replicate gains')
|
|
means[name] = float(np.mean(values))
|
|
require(math.isfinite(means[name]), 'Nonfinite legacy mean')
|
|
floor, ceiling = min(means[names[0]], means[names[1]]), max(means[name] for name in names[2:])
|
|
return dict(schema=SCHEMA, audit='legacy_H_mean_control_predicate_only', protocol_means=means,
|
|
condition_floor=floor, control_ceiling=ceiling, predicate_pass=floor > ceiling,
|
|
source_reference='frozen phase4h.py:412-414', full_old_H_gate_computed=False,
|
|
scientific_learning_pass_claimed=False, AN_to_legacy_protocol_mapping_authority='caller; no automatic pooling')
|
|
|
|
|
|
def unavailable_assay(assay):
|
|
"""Do not turn a terminal delta or fresh reset into a retention/reversal PASS."""
|
|
require(assay in ('reversal', 'retention'), 'Unknown unavailable assay')
|
|
missing = {'reversal': ['bound acquisition/reversal/no-reversal lineage',
|
|
'original pre baseline and original cue orientation', 'terminal reversal count and both cue event outputs'],
|
|
'retention': ['validated continuous-state checkpoint/queue/RNG schema',
|
|
'bound same idle trajectory at 1 s and 10 s', 'matched frozen continuation and non-feedback probes']}
|
|
return dict(schema=SCHEMA, assay=assay, status='NOT-IMPLEMENTED', proposal_gate_pass=None,
|
|
scientific_learning_pass_claimed=False, candidate_generated=False, missing=missing[assay])
|