Research-Stack/5-Applications/scripts/snn/generate_snn_vectors.py

332 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Generate deterministic SNN/NII test vectors.
Profiles:
synthetic Legacy/simple synthetic stream.
balanced5 Five-phase bring-up profile with recovery phase.
uc_records Derive observations from UnifiedCompression/RGFlow records.
The balanced5 profile is designed for hardware bring-up:
phase 0: lawful_stable
phase 1: near_miss_boundary
phase 2: reject_controlled
phase 3: reject_corridor
phase 4: recovery_lawful
Target distribution, when adaptive reference mode is available:
lawful: 35-45%
near_miss: 15-25%
reject: 35-45%
The generator can import snn_nii_reference.py and use it as a calibration oracle
so generated vectors are not silently masking FAMM saturation.
"""
from __future__ import annotations
import argparse, copy, importlib.util, json, sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
def clamp(x, lo, hi):
return lo if x < lo else hi if x > hi else x
def load_reference(config_path: str):
here = Path(__file__).resolve().parent
ref_path = here / "snn_nii_reference.py"
if not ref_path.exists():
return None, None
spec = importlib.util.spec_from_file_location("snn_nii_reference", ref_path)
ref = importlib.util.module_from_spec(spec)
sys.modules["snn_nii_reference"] = ref
spec.loader.exec_module(ref) # type: ignore[union-attr]
cfg = json.loads(Path(config_path).read_text(encoding="utf-8"))
return ref, cfg
def event_from_uc_record(obj: dict, i: int) -> dict:
topo = obj.get("topology18", {})
rg = obj.get("rgflow", {})
observed = [
int(topo.get("theta", 0)) * 16,
int(topo.get("phi", 0)) * 32,
int(topo.get("tau", 0)) * 16,
(int(topo.get("C", 0)) - int(topo.get("A", 0))) * 32,
]
observed = [clamp(x, -512, 512) for x in observed]
return {
"i": i,
"source": "uc_records",
"observed": observed,
"coherence": int(float(rg.get("coherence", 0.5)) * 255),
"compression": int(float(rg.get("compression_gain", 0.5)) * 255),
"failure": 64 if rg.get("verdict") == "reject" else 16 if rg.get("verdict") == "near_miss" else 0,
"state18": int(topo.get("addr18", 0)),
}
def legacy_synthetic_event(i: int) -> dict:
phase = (i // 64) % 4
if phase == 0:
observed = [80, 40, 80, 20]
coherence, compression, failure = 210, 180, 0
elif phase == 1:
observed = [80 + (i % 64), 48, 80 - (i % 64), 24]
coherence, compression, failure = 155, 135, 16
elif phase == 2:
observed = [280, -200, 220, -160]
coherence, compression, failure = 105, 90, 64
else:
observed = [160 if i % 2 else 96, 64, 128 if i % 3 else 64, 32]
coherence, compression, failure = 145, 132, 40
return {
"i": i,
"source": "synthetic",
"observed": observed,
"coherence": coherence,
"compression": compression,
"failure": failure,
"state18": (i * 8191) & ((1 << 18) - 1),
}
PHASES = [
("lawful_stable", "lawful"),
("near_miss_boundary", "near_miss"),
("reject_controlled", "reject"),
("reject_corridor", "reject"),
("recovery_lawful", "lawful"),
]
def balanced_candidate_pool(family: str, i: int, phase: int, j: int) -> List[Tuple[List[int], int, int, int]]:
# Small pool on purpose: fast enough for generation, wide enough to expose all verdicts.
pools = {
"lawful": [
([80, 40, 80, 20], 235, 210, 0),
([82, 41, 79, 21], 245, 220, 0),
([76, 38, 84, 18], 225, 205, 4),
([88, 44, 72, 24], 215, 195, 8),
],
"near_miss": [
([116, 52, 96, 28], 165, 150, 18),
([128, 60, 104, 34], 175, 160, 22),
([100, 48, 92, 26], 155, 145, 28),
([140, 70, 120, 40], 180, 170, 36),
([112, 50, 96, 30], 150, 150, 42),
],
"reject": [
([150, -60, 130, -50], 145, 130, 52),
([170, -80, 150, -60], 135, 120, 62),
([190, -100, 160, -80], 125, 110, 72),
([210, -120, 180, -96], 115, 100, 82),
([230, -140, 190, -110], 105, 90, 92),
],
"recovery": [
([120, 60, 100, 30], 190, 175, 12),
([100, 50, 90, 25], 210, 190, 6),
([80, 40, 80, 20], 235, 210, 0),
([76, 38, 84, 18], 245, 220, 0),
],
}
rows = pools[family][:]
offset = (i * 17 + phase * 31 + j * 7) % len(rows)
return rows[offset:] + rows[:offset]
def init_ref_state(ref: Any, cfg: dict):
return (
ref.NIIState.init(cfg["delay_taps"], len(cfg["channels"])),
ref.MSNNState.init(cfg["msnn"]["neurons"]),
ref.FAMMState(),
)
def eval_candidate(ref: Any, cfg: dict, state, event: dict):
nii, msnn, famm = copy.deepcopy(state)
pred, surprise = ref.nii_step(nii, event["observed"], cfg)
msnn_out = ref.msnn_step(msnn, surprise, famm, cfg)
rg = ref.rgflow_step(surprise, msnn_out, event, famm, cfg)
flags = ref.famm_update(famm, rg, cfg)
return (nii, msnn, famm), pred, surprise, msnn_out, rg, flags
def choose_adaptive_event(ref: Any, cfg: dict, state, i: int, phase: int, j: int, counts: dict, n: int):
phase_name, phase_desired = PHASES[phase]
# If distribution is drifting, nudge desired verdict toward the target band.
progress = max(1, i)
ratios = {k: counts.get(k, 0) / progress for k in ["lawful", "near_miss", "reject"]}
if ratios["reject"] < 0.35 and phase in (2, 3):
desired = "reject"
elif ratios["near_miss"] < 0.15 and phase == 1:
desired = "near_miss"
elif ratios["lawful"] < 0.35 and phase in (0, 4):
desired = "lawful"
else:
desired = phase_desired
family = "recovery" if phase == 4 else desired
candidates = []
# Also allow adjacent family fallback; this avoids saturation while preserving target pressure.
families = [family]
if desired == "reject":
families += ["near_miss", "recovery"]
elif desired == "near_miss":
families += ["lawful", "reject"]
elif desired == "lawful":
families += ["near_miss"]
for fam in families:
for obs, coh, comp, fail in balanced_candidate_pool(fam, i, phase, j):
event = {
"i": i,
"source": "synthetic_5phase_balanced",
"profile": "balanced5",
"phase": phase,
"phase_name": phase_name,
"desired_verdict": desired,
"observed": obs[:],
"coherence": coh,
"compression": comp,
"failure": fail,
"state18": (i * 8191) & ((1 << 18) - 1),
}
st2, pred, sur, msnn_out, rg, flags = eval_candidate(ref, cfg, state, event)
candidates.append((event, st2, pred, sur, msnn_out, rg, flags))
best = None
best_score = 10**9
target_center = {"lawful": 0.40, "near_miss": 0.20, "reject": 0.40}
for item in candidates:
event, st2, pred, sur, msnn_out, rg, flags = item
verdict = rg["verdict"]
famm = st2[2]
trial_counts = counts.copy()
trial_counts[verdict] = trial_counts.get(verdict, 0) + 1
denom = i + 1
dist_penalty = sum(abs((trial_counts.get(k, 0) / denom) - target_center[k]) for k in target_center) * 1000
verdict_penalty = 0 if verdict == event["desired_verdict"] else 180
# Saturation is a failure-contract issue, not just a cost. Penalize very heavily.
saturation_penalty = 2000 if flags["any_saturated"] else 0
headroom_penalty = max(0, famm.frustration - 180) * 4 + max(0, famm.torsion - 180) * 4 + max(0, famm.basin - 220)
# Prefer controlled reject and middle-band near_miss, not extremes.
if verdict == "reject":
band_penalty = abs(int(rg["reject_pressure"]) - 180) / 4
elif verdict == "near_miss":
band_penalty = abs(int(rg["reject_pressure"]) - 80) / 4
else:
band_penalty = int(rg["reject_pressure"]) / 4
score = dist_penalty + verdict_penalty + saturation_penalty + headroom_penalty + band_penalty
if score < best_score:
best_score = score
best = item
assert best is not None
event, st2, pred, sur, msnn_out, rg, flags = best
event["expected_verdict"] = rg["verdict"]
event["expected_reject_pressure"] = rg["reject_pressure"]
event["expected_famm_after"] = {
"frustration": st2[2].frustration,
"basin": st2[2].basin,
"torsion": st2[2].torsion,
}
event["expected_famm_saturated"] = bool(flags["any_saturated"])
return event, st2
def balanced5_events(n: int, phase_len: int, ref: Any = None, cfg: dict = None) -> List[dict]:
rows = []
if ref is None or cfg is None:
# Static fallback, still 5 phases and recovery but no expected-verdict fields.
for i in range(n):
phase = (i // phase_len) % 5
j = i % phase_len
name, desired = PHASES[phase]
family = "recovery" if phase == 4 else desired
obs, coh, comp, fail = balanced_candidate_pool(family, i, phase, j)[0]
rows.append({
"i": i,
"source": "synthetic_5phase_balanced_static",
"profile": "balanced5",
"phase": phase,
"phase_name": name,
"desired_verdict": desired,
"observed": obs,
"coherence": coh,
"compression": comp,
"failure": fail,
"state18": (i * 8191) & ((1 << 18) - 1),
})
return rows
state = init_ref_state(ref, cfg)
counts = {"lawful": 0, "near_miss": 0, "reject": 0}
for i in range(n):
phase = (i // phase_len) % 5
j = i % phase_len
event, state = choose_adaptive_event(ref, cfg, state, i, phase, j, counts, n)
counts[event["expected_verdict"]] += 1
rows.append(event)
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="sample_vectors.jsonl")
ap.add_argument("--n", type=int, default=512)
ap.add_argument("--uc-records", default=None)
ap.add_argument("--profile", choices=["synthetic", "balanced5", "uc_records"], default="balanced5")
ap.add_argument("--phase-len", type=int, default=1)
ap.add_argument("--config", default="snn_test_config.json")
ap.add_argument("--no-adaptive", action="store_true")
ap.add_argument("--summary", default=None)
args = ap.parse_args()
rows = []
if args.profile == "uc_records" or args.uc_records:
if not args.uc_records:
raise SystemExit("--uc-records is required for uc_records profile")
with open(args.uc_records, "r", encoding="utf-8") as f:
for line in f:
try:
obj = json.loads(line)
except Exception:
continue
if obj.get("record_type") == "uc_rgflow_chunk" and "topology18" in obj:
rows.append(event_from_uc_record(obj, len(rows)))
if len(rows) >= args.n:
break
elif args.profile == "synthetic":
rows = [legacy_synthetic_event(i) for i in range(args.n)]
else:
ref, cfg = (None, None) if args.no_adaptive else load_reference(args.config)
rows = balanced5_events(args.n, args.phase_len, ref=ref, cfg=cfg)
with open(args.out, "w", encoding="utf-8") as out:
for row in rows:
out.write(json.dumps(row, separators=(",", ":")) + "\n")
if args.summary:
expected = {"lawful": 0, "near_miss": 0, "reject": 0, "unknown": 0}
sat = 0
phase_counts = {name: 0 for name, _ in PHASES}
for row in rows:
expected[row.get("expected_verdict", "unknown")] = expected.get(row.get("expected_verdict", "unknown"), 0) + 1
sat += 1 if row.get("expected_famm_saturated") else 0
phase_counts[row.get("phase_name", "unknown")] = phase_counts.get(row.get("phase_name", "unknown"), 0) + 1
summary = {
"profile": args.profile,
"n": len(rows),
"phase_len": args.phase_len,
"expected_verdicts": expected,
"expected_ratios": {k: (v / max(1, len(rows))) for k, v in expected.items()},
"expected_saturated_steps": sat,
"phase_counts": phase_counts,
"target": {"lawful": "35-45%", "near_miss": "15-25%", "reject": "35-45%"},
}
Path(args.summary).write_text(json.dumps(summary, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()