326 lines
16 KiB
Python
326 lines
16 KiB
Python
"""Pure, unregistered conditioning inventory; no files, brain inputs or runs.
|
|
|
|
build_trial_plan(master) returns plan/seed_manifest/budget. validate_trial_plan
|
|
checks seed authority, alias permissions, and exact deterministic reconstruction
|
|
of the complete inventory, not just totals. A consumer must still bind the exact
|
|
63-bit seed and generator to its generated input receipt; this module cannot
|
|
certify a future consumer that silently truncates an integer.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections import Counter
|
|
import hashlib
|
|
import json
|
|
|
|
import seed_registry
|
|
|
|
|
|
SCHEMA = "phase4an-trial-plan-v1-draft"
|
|
SHUFFLE_VERSION = "sha256-counter-rejection-fisher-yates-v1"
|
|
MODES = ("physiological", "matched_energy")
|
|
DAN_BRANCHES = (("PAM15", "y5B'2a"), ("PPL103", "y2a'1"))
|
|
CUES = ("A", "B")
|
|
ODORS = {"A": "4-ethylphenol", "B": "3-hexanone"}
|
|
ARMS = ("paired", "d_zero", "frozen", "random_reward", "unpaired", "cs_only", "dan_only")
|
|
REVERSAL_ARMS = ("reversal", "no_reversal")
|
|
CONTROL_ALIASES = frozenset(("d_zero", "frozen", "random_reward", "unpaired", "cs_only"))
|
|
PROBE_CHECKPOINTS = (0, 1, 2, 5, 10, 20, 50)
|
|
|
|
|
|
def _require(value, message):
|
|
if not value:
|
|
raise ValueError(message)
|
|
|
|
|
|
def _canonical(value):
|
|
return json.dumps(value, sort_keys=True, separators=(",", ":"),
|
|
ensure_ascii=False, allow_nan=False)
|
|
|
|
|
|
def _sha(value):
|
|
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
|
|
|
|
|
class _Shuffle:
|
|
"""Explicit portable integer algorithm, unrelated to neural input RNG.
|
|
|
|
Each draw hashes canonical UTF-8 JSON [version, full63bitseed, counter].
|
|
Interpret all 256 digest bits as a big-endian unsigned integer; increment
|
|
counter even for a rejection. Reject x >= 2**256 - (2**256 % bound), then
|
|
return x % bound. Fisher-Yates descends i=n-1..1 and swaps with draw(i+1).
|
|
No Python random/NumPy algorithm, modulo bias, or seed search is used.
|
|
"""
|
|
def __init__(self, seed):
|
|
_require(type(seed) is int and 0 <= seed < 2**63, "Full 63-bit shuffle seed required")
|
|
self.seed = seed
|
|
self.counter = 0
|
|
|
|
def below(self, bound):
|
|
_require(type(bound) is int and 0 < bound <= 2**256, "Invalid shuffle bound")
|
|
limit = 2**256 - (2**256 % bound)
|
|
while True:
|
|
raw = bytes.fromhex(_sha([SHUFFLE_VERSION, self.seed, self.counter]))
|
|
self.counter += 1
|
|
value = int.from_bytes(raw, "big")
|
|
if value < limit:
|
|
return value % bound
|
|
|
|
def shuffled(self, values):
|
|
result = list(values)
|
|
for i in range(len(result) - 1, 0, -1):
|
|
j = self.below(i + 1)
|
|
result[i], result[j] = result[j], result[i]
|
|
return result
|
|
|
|
|
|
def _stream_id(master, split, key):
|
|
return split + ":" + _sha([SCHEMA, "stream-id", master, split, list(key)])
|
|
|
|
|
|
def _block_id(mode, dan, reinforced, replicate):
|
|
return f"{mode}__{dan}__reinforce_{reinforced}__rep{replicate:02d}"
|
|
|
|
|
|
def _input_key(block, assay, cue, occurrence, arm):
|
|
return (block, assay, "CS", cue, f"{occurrence:02d}", arm)
|
|
|
|
|
|
def _cs_binding(registry, block, assay, cue, occurrence, arm, base_arm):
|
|
key = _input_key(block, assay, cue, occurrence, arm)
|
|
source_key = _input_key(block, assay, cue, occurrence, base_arm)
|
|
if arm == base_arm:
|
|
seed = registry.add("training", key)
|
|
else:
|
|
seed = registry.alias("training", key, "training", source_key,
|
|
reason="Same block/assay/CS cue/occurrence; named counterfactual control")
|
|
return {"cue": cue, "odor": ODORS[cue], "occurrence": occurrence,
|
|
"stream_id": _stream_id(registry.master, "training", key),
|
|
"source_stream_id": _stream_id(registry.master, "training", source_key),
|
|
"seed": seed, "seed_bits": 63, "seed_conversion": "exact integer; no truncation or addition",
|
|
"role": "CS", "start_tick": 0, "stop_tick_exclusive": 1000}
|
|
|
|
|
|
def _ordered_cues(seed):
|
|
counts = {"A": 0, "B": 0}
|
|
result = []
|
|
for cue in _Shuffle(seed).shuffled(["A"] * 50 + ["B"] * 50):
|
|
result.append((cue, counts[cue]))
|
|
counts[cue] += 1
|
|
return result
|
|
|
|
|
|
def _epoch(registry, block, assay, arm, ordinal, cue, occurrence, base_arm,
|
|
us_ordinal, yoked_cue=None, yoked_occurrence=None):
|
|
delayed = assay == "delayed"
|
|
steps, us_tick = (1800, 1500) if delayed else (1200, 900)
|
|
requested = us_ordinal is not None
|
|
cs = None if cue is None else _cs_binding(registry, block, assay, cue, occurrence, arm, base_arm)
|
|
return {
|
|
"epoch_id": f"{block}/{assay}/{arm}/{ordinal:03d}",
|
|
"epoch_index": ordinal, "steps": steps, "duration_ms": steps // 10,
|
|
"initial_neural_state": "frozen_reset_including_KC_trace_and_both_delay_queues",
|
|
"cs": cs,
|
|
"us": {"requested": requested,
|
|
"inventory_id": None if not requested else f"{block}/{assay}/US/{us_ordinal:02d}",
|
|
"target_binding": None if not requested else block + "/confirmed-DAN-targets",
|
|
"tick": us_tick if requested else None,
|
|
"nominal_slot_tick": us_tick,
|
|
"request_rule": "fixed_single_pulse_all_bound_DAN_targets" if requested else "none",
|
|
"rng_seed": None, "rng_draws": 0},
|
|
"D_command": 1.0 if requested and arm != "d_zero" else 0.0,
|
|
"D_definition": "scheduled artificial scalar, not multiplied by acceptance",
|
|
"update_enabled": arm != "frozen",
|
|
"update_schedule": "epoch_boundary_only",
|
|
"yoked_cue_removed": yoked_cue,
|
|
"yoked_occurrence_removed": yoked_occurrence,
|
|
}
|
|
|
|
|
|
def _suite(registry, block, assay, reinforced):
|
|
base_arm = "reversal" if assay == "reversal" else "paired"
|
|
order_key = (block, assay, "cue-order")
|
|
order_seed = registry.add("design", order_key)
|
|
ordered = _ordered_cues(order_seed)
|
|
designs = [{"purpose": "cue-order", "stream_id": _stream_id(registry.master, "design", order_key),
|
|
"seed": order_seed, "algorithm": SHUFFLE_VERSION}]
|
|
random_sets = None
|
|
placements = None
|
|
if assay != "reversal":
|
|
key = (block, assay, "random-reward-placement")
|
|
seed = registry.add("design", key)
|
|
shuffle = _Shuffle(seed)
|
|
random_sets = {cue: sorted(shuffle.shuffled(range(50))[:25]) for cue in CUES}
|
|
designs.append({"purpose": "random-reward-placement", "stream_id": _stream_id(registry.master, "design", key),
|
|
"seed": seed, "algorithm": SHUFFLE_VERSION})
|
|
key = (block, assay, "unpaired-US-placement")
|
|
seed = registry.add("design", key)
|
|
placements = _Shuffle(seed).shuffled(["CS"] * 100 + ["US"] * 50)
|
|
designs.append({"purpose": "unpaired-US-placement", "stream_id": _stream_id(registry.master, "design", key),
|
|
"seed": seed, "algorithm": SHUFFLE_VERSION})
|
|
|
|
arms = []
|
|
for arm in REVERSAL_ARMS if assay == "reversal" else ARMS:
|
|
events = []
|
|
arm_reinforced = ("B" if reinforced == "A" else "A") if arm == "reversal" else reinforced
|
|
if arm == "unpaired":
|
|
cursor, us_cursor = 0, 0
|
|
for token in placements:
|
|
if token == "CS":
|
|
cue, occurrence = ordered[cursor]
|
|
cursor += 1
|
|
events.append(_epoch(registry, block, assay, arm, len(events), cue, occurrence, base_arm, None))
|
|
else:
|
|
events.append(_epoch(registry, block, assay, arm, len(events), None, None, base_arm, us_cursor))
|
|
us_cursor += 1
|
|
else:
|
|
reward_us_ids = None
|
|
if arm == "random_reward":
|
|
reward_us_ids = {(cue, occurrence): offset + i
|
|
for cue, offset in (("A", 0), ("B", 25))
|
|
for i, occurrence in enumerate(random_sets[cue])}
|
|
for cue, occurrence in ordered:
|
|
if arm == "random_reward":
|
|
us_ordinal = reward_us_ids.get((cue, occurrence))
|
|
elif arm == "cs_only":
|
|
us_ordinal = None
|
|
else:
|
|
us_ordinal = occurrence if cue == arm_reinforced else None
|
|
if arm == "dan_only":
|
|
events.append(_epoch(registry, block, assay, arm, len(events), None, None, base_arm,
|
|
us_ordinal, yoked_cue=cue, yoked_occurrence=occurrence))
|
|
else:
|
|
events.append(_epoch(registry, block, assay, arm, len(events), cue, occurrence, base_arm, us_ordinal))
|
|
arms.append({
|
|
"arm": arm,
|
|
"delta_start": {"kind": "fork", "source": f"{block}/normal/paired/terminal-delta"}
|
|
if assay == "reversal" else {"kind": "zero", "source": None},
|
|
"carry_between_epochs": ["plastic_delta_only"],
|
|
"reinforced_cue": arm_reinforced if arm not in ("random_reward", "unpaired", "cs_only", "dan_only") else None,
|
|
"random_reward_occurrences": random_sets if arm == "random_reward" else None,
|
|
"epochs": events,
|
|
})
|
|
return {"assay": assay, "design_streams": designs, "arms": arms}
|
|
|
|
|
|
def _assemble(master):
|
|
registry = seed_registry.SeedRegistry(master)
|
|
blocks = []
|
|
for mode in MODES:
|
|
for dan, compartment in DAN_BRANCHES:
|
|
for reinforced in CUES:
|
|
for replicate in range(1, 6):
|
|
block = _block_id(mode, dan, reinforced, replicate)
|
|
evaluations = []
|
|
for cue in CUES:
|
|
key = _input_key(block, "evaluation", cue, 0, "base-probe")
|
|
seed = registry.add("evaluation", key)
|
|
evaluations.append({"cue": cue, "odor": ODORS[cue], "role": "CS", "seed": seed,
|
|
"seed_bits": 63, "stream_id": _stream_id(master, "evaluation", key),
|
|
"use": "same saved input across pre/post/controls; never extra training replicate"})
|
|
blocks.append({
|
|
"block_id": block, "mode": mode, "dan_type": dan,
|
|
"compartment_verbatim": compartment, "replicate": replicate,
|
|
"initial_reinforced_cue": reinforced,
|
|
"initial_neutral_cue": "B" if reinforced == "A" else "A",
|
|
"DAN_target_binding": {"binding_id": block + "/confirmed-DAN-targets",
|
|
"source": "sealed H exact confirmed mapping; not materialized by this planner",
|
|
"required_before_execution": True, "body_ids": None},
|
|
"suites": [_suite(registry, block, assay, reinforced)
|
|
for assay in ("normal", "delayed", "reversal")],
|
|
"evaluation_streams": evaluations,
|
|
"evaluation_checkpoints": list(PROBE_CHECKPOINTS),
|
|
"evaluation_policy": "separate future probe inventory required; evaluation exposure not included in epoch budget",
|
|
"retention": {"branches": ["normal/paired", "normal/frozen"],
|
|
"checkpoint_seconds": [0, 1, 10], "idle_seconds_each": 10,
|
|
"state_continuous": True, "probes_fork_and_do_not_advance_idle": True},
|
|
})
|
|
|
|
plan = {
|
|
"schema": SCHEMA, "status": "DRAFT-NOT-REGISTERED", "master": master,
|
|
"execution_authorized": False, "brain_inputs_generated": False,
|
|
"training_executed": False, "shuffle_algorithm": SHUFFLE_VERSION,
|
|
"shuffle_specification": _Shuffle.__doc__.strip(),
|
|
"input_seed_contract": "full63bit integer; no uint32 truncation, offset, modulo or hidden global RNG",
|
|
"consumer_generator_implementation_verified": False,
|
|
"fixed_odors": dict(ODORS), "dt_ms": 0.1,
|
|
"training_replicates_per_cell": 5, "blocks": blocks,
|
|
"alias_policy": "training CS only; same block/assay/cue/occurrence; named controls only",
|
|
"scheduled_US_rng": "none; deterministic pulse; design shuffle randomness is separately registered",
|
|
}
|
|
counts, steps = Counter(), 0
|
|
cs_epochs, us_epochs, effective_updates = 0, 0, 0
|
|
for block in blocks:
|
|
for suite in block["suites"]:
|
|
for arm in suite["arms"]:
|
|
counts[(suite["assay"], arm["arm"])] += len(arm["epochs"])
|
|
for epoch in arm["epochs"]:
|
|
steps += epoch["steps"]
|
|
cs_epochs += epoch["cs"] is not None
|
|
us_epochs += epoch["us"]["requested"]
|
|
effective_updates += epoch["update_enabled"] and epoch["D_command"] != 0
|
|
budget = {
|
|
"blocks": 40, "training_replicates_per_cell": 5,
|
|
"epoch_counts_by_assay_arm": [{"assay": a, "arm": b, "epochs": count}
|
|
for (a, b), count in sorted(counts.items())],
|
|
"training_control_reversal_epochs": sum(counts.values()),
|
|
"CS_epochs": cs_epochs, "US_request_epochs": us_epochs,
|
|
"enabled_nonzero_D_update_opportunities": effective_updates,
|
|
"training_control_reversal_steps": steps,
|
|
"training_control_reversal_biological_seconds": steps // 10000,
|
|
"retention_idle_seconds": 40 * 2 * 10,
|
|
"retention_idle_steps": 40 * 2 * 10 * 10000,
|
|
"minimum_biological_seconds_excluding_evaluation": steps // 10000 + 800,
|
|
"minimum_steps_excluding_evaluation": steps + 8000000,
|
|
"evaluation_epochs": None, "evaluation_biological_seconds": None,
|
|
"evaluation_probe_inventory_complete": False,
|
|
"excludes": ["evaluation probes", "implementation validation", "frozen regressions", "I/O and encoding wall time"],
|
|
"reason_not_total_runtime": "Only training/control/reversal and continuous retention idle are budgeted; never label this complete total exposure",
|
|
}
|
|
_require(budget["training_control_reversal_epochs"] == 68000 and steps == 99600000,
|
|
"Internal protocol inventory arithmetic changed")
|
|
result = {"plan": plan, "seed_manifest": registry.manifest(), "budget": budget}
|
|
result["payload_sha256"] = _sha(result)
|
|
return result
|
|
|
|
|
|
def _validate_alias_permissions(manifest):
|
|
for row in manifest["aliases"]:
|
|
key, source = row["key"], row["source_key"]
|
|
_require(row["split"] == row["source_split"] == "training",
|
|
"Only named training CS controls may alias streams")
|
|
_require(len(key) == len(source) == 6 and key[2] == source[2] == "CS",
|
|
"CS/US/design/evaluation role aliasing is forbidden")
|
|
_require(key[:5] == source[:5], "Alias crosses block/replicate/assay/cue/occurrence")
|
|
if key[1] in ("normal", "delayed"):
|
|
_require(source[5] == "paired" and key[5] in CONTROL_ALIASES,
|
|
"Unknown normal/delayed control alias")
|
|
else:
|
|
_require(key[1] == "reversal" and source[5] == "reversal" and key[5] == "no_reversal",
|
|
"Unknown reversal control alias")
|
|
|
|
|
|
def validate_trial_plan(payload):
|
|
"""Fail closed on incomplete coverage, illegal aliasing, mutation or types.
|
|
|
|
Rebuild the whole fixed 40-block schedule and every RNG derivation. This
|
|
does not trust a count, a caller's declared sha, or a row labeled replicate.
|
|
Canonical comparison distinguishes Boolean/integer/floating-point JSON.
|
|
"""
|
|
_require(type(payload) is dict and set(payload) == {"plan", "seed_manifest", "budget", "payload_sha256"},
|
|
"Invalid complete trial-plan envelope")
|
|
actual = {key: value for key, value in payload.items() if key != "payload_sha256"}
|
|
_require(type(payload["payload_sha256"]) is str and payload["payload_sha256"] == _sha(actual),
|
|
"Actual trial-plan payload SHA mismatch")
|
|
seed_registry.validate_manifest(payload["seed_manifest"])
|
|
_validate_alias_permissions(payload["seed_manifest"])
|
|
_require(payload["plan"]["master"] == payload["seed_manifest"]["master"], "Master identities differ")
|
|
expected = _assemble(payload["plan"]["master"])
|
|
_require(_canonical(payload) == _canonical(expected),
|
|
"Full protocol coverage/input binding/order/type reconstruction mismatch")
|
|
return True
|
|
|
|
|
|
def build_trial_plan(master):
|
|
"""Return a deterministic JSON-serializable DRAFT inventory without I/O."""
|
|
return _assemble(master)
|