UNI hierarchical stack builder - implementation plan
How to read this page
Three ways to read this page. Precise is the document itself, exactly as it is written in the repository. Plain and Clear were written for this website to help you meet that document — they are about it. They are not it, and they are not evidence.
A laboratory built around the bacterial flagellar motor. It holds a deterministic reduced model of the motor, analysis of recorded single-motor events, and a cross-study parity programme. Alongside those sit the scientific gates the work has to clear, and independent audits of both the model and the repository around it. The framing throughout is hierarchical active inference.
It is for a reader with a scientific interest, and especially for one who has come to check whether a model fit has quietly become a claim about biology. The laboratory's central discipline is a labelling one: every visible layer carries exactly one class — recorded observation, structural reconstruction, reduced model, or physical teaching analogue — and those classes may not be blended. Behavioural observations of one species are held apart from structural work on another, so that nothing on the page can read as a single measured specimen.
Start with the Living Science Walkthrough, which sets out those classes and the truth contract they belong to. Then the scientific and mathematical contract, then the parity gates, which state what would have to hold before a parity claim could stand.
What it is not: a claim of biological parity. The walkthrough is explicit that the release does not turn a model fit into a biological identity claim, and full biological parity is recorded as false and printed as false. Passing this repository's software tests is necessary here and is not the same thing as agreement with a living motor.
Your browser cannot switch reading levels, so the document itself is shown.
Precise — the source document
This is the document. Rendered from the repository at the commit above, with nothing rewritten for the web. A gate re-renders it on every deploy and fails the build if a single byte differs.
0. Scope statement (read first)
What this builder is. A general, CPU-only, deterministic construction and falsification instrument for hierarchical discrete active-inference models. A stack is an ordered set of levels ell = 0..L; each level owns a categorical hidden state x_ell, an outcome o_ell, policies pi_ell, and the parameter block {A, B, C, D, E, gamma, Pi}. The engine minimises a per-level variational free energy F bottom-up, scores expected free energy G and acts top-down, and passes typed messages across each seam. The flagellar-motor loop that already exists in lib/uni-motor.js becomes the first fully-instantiated level (L0), migrated by wrapping, not rewriting. The instrument is usable by a child through a typed-brick interface and by a researcher through the same artifact at a higher disclosure level.
What this builder is not.
- It is not a claim that the L0–L12 human ladder exists. Exactly one level is built. Levels L1–L12 ship as NOT_BUILT: no parameters, no sockets, no numbers, no contribution to any total. Adding them to a stack must leave the built levels' trace hash bit-identical.
- It is not a general active-inference solver. Policies are single-step in v1 (
Gis scored over one transition). Multi-step policy trees are explicitly out of scope and are named as an open question. - It does not define a hierarchical joint
p(x_0..x_L, o). Downward messages modulate a child's parameters; they are not a generative prior over the child's state. Consequently cross-level aggregation ofFis forbidden by the engine — there is nosumFacross levels, because there is no quantity such a sum would bound. - It does not establish prospectivity in the browser. The browser may emit
TIMING_UNVERIFIEDorPENDINGonly.PROSPECTIVEis minted exclusively by the existing git-DAG oracle intests/semantic/prospectivity-provenance.test.mjs. - It carries no usability evidence. The child path is a hypothesis with a stated falsifier, recorded
NOT_ESTABLISHEDin the gate ledger. - It changes no existing science.
lib/uni-motor.js,lib/duration-models.js,lib/source-first-passage.jsare not edited in Phases 1–9. The 11 catalog entries migrate under a byte-equality gate.
Verification status of this document. The design body below was produced by a design-and-judge pass that executed no test, gate or build; all PASS/FAIL statuses it cites are read from committed report JSON, not from a fresh run, and anything the designers could not corroborate is marked NOT VERIFIED inline.
Four load-bearing premises were subsequently confirmed by direct execution during the Phase-E audit (see docs/audit/PHASE-E-WORKBENCH-AUDIT.md) and may be relied on:
| Premise | Status | Evidence |
|---|---|---|
--cyan, --gold, --void, --failure are used but never declared in app/globals.css |
CONFIRMED | 21 var() references; zero --name: declarations; :root declares 16 other tokens |
transitionPrior and policyTerms are module-private in lib/uni-motor.js |
CONFIRMED | absent from the module's export list |
The shipped G is KL + 2·ambiguity + effort, not KL + ambiguity + effort (lib/uni-motor.js:328) |
CONFIRMED | independent rederivation; on the real model G is inflated 60.7–62.5 %, and G(RUN) − G(TUMBLE) is inflated 6.5× (0.030642 → 0.004678), so the error changes action selection |
X01_SOURCE_INTEGRITY renders PASS with 0 of 12 declared artifacts present on disk |
CONFIRMED | experiments/upstream-cache/ does not exist; all 12 verified fields are frozen true literals |
The third premise has a direct consequence for Phase 2 acceptance criterion 4 and for the EFE_UNIMOTOR_LEGACY_V1 brick: the legacy brick must be registered as a deliberately preserved non-canonical form with its discrepancy printed on the card, not as an alternative convention. EFE_RISK_AMBIGUITY is the canonical brick. Migration parity for the legacy brick must be asserted against the current shipped output so that correcting lib/uni-motor.js:328 is a visible, gated, separately-reviewed change rather than a silent drift.
1. Architecture
Four layers, strictly one-directional in dependency.
app/stack-builder/*.tsx presentation only; no arithmetic, no RNG, no accumulator
| reads frozen StackState records
lib/stack/scheduler.js ticks, phases, mailbox, trace hashing
|
lib/stack/level.js bus.js experiment.js scoring.js
|
lib/stack/{numeric,hash,rng,truth,ports,expr,bricks}.js pure kernel, zero deps
Governing decisions, each traceable to a judged flaw:
- Kernel first, but demoable at Phase 4. Correctness lives where numbers are made and is falsified by
node --testbefore pixels exist. To avoid four invisible phases, Phase 4 ships a headless ASCII CLI that prints the ladder with liveF/G. - The type system is the truth contract. Ports carry
{kind, dim, unit, truthClass}.NatsandJoulesare distinct kinds with distinct geometry and no conversion function anywhere. Truth-class is never a settable field; it is a join over inputs, andOBSERVEDhas exactly one mint point. - One product, three disclosure levels. A single
detail: "play" | "lab" | "audit"prop, identical DOM. Correction to the lego-first design, per the truth-safety judge: truth class, species label, NOT-BUILT status and unit suffix render at every level, in child words if necessary. Only symbols, hashes and the gate ledger are progressive. - Run is a method on the Experiment for scored results;
tickis a method on the Stack for exploration. This repairs the science-first flaw where a child's only reachable verdict wasINCONCLUSIVE. Exploration is free, reversible and immediate; only scored results require the falsification apparatus. - Prospectivity is delegated, never minted locally. The holdout is unreachable before sealing (a throwing Proxy); the seal is a content hash; the ordering witness is git.
2. Data model
All artifacts are JSON, LF-only, key-sorted on write, and content-addressed by SHA-256 over the canonical serialisation with the hash field itself removed. Numbers use plain number[] (dims ≤ 32), deep-freezable, matching house style.
2.1 Port type
// lib/stack/types.d.ts
export type PortKind =
| "Simplex" | "Matrix" | "MatrixStack" | "Scalar" | "Obs" | "Act"
| "Nats" | "Joules" | "MsgUp" | "MsgDown" | "Inert";
export type PortShape =
| "round" | "cross" | "cross-stack" | "square" | "hex" | "tri"
| "round-notch" | "square-notch" | "up" | "down" | "none";
export type TruthClass =
| "OBSERVED" | "MODELED" | "DERIVED" | "INFERRED"
| "FROZEN_PREDICTION" | "NOT_BUILT";
export interface PortType {
kind: PortKind;
shape: PortShape; // derived, frozen per kind
dim: number | null;
unit: string; // "prob" | "nat" | "J" | "s" | "uM" | "rpm" | "pN.nm" | "1"
constraint: "column-stochastic" | "probability" | "nonneg" | "positive" | null;
truthClass: TruthClass;
}
Truth-class ordering, weakest first: NOT_BUILT < FROZEN_PREDICTION < INFERRED < DERIVED < MODELED < OBSERVED. joinTruth(...) returns the weakest input class. There is exactly one rule; the science-first design carried two contradictory ones and this resolves it: any operation over inputs returns the weakest input class, so ln(o) for OBSERVED o is OBSERVED, and the moment a MODELED parameter participates the result is MODELED. Provenance narrowing ("derived from observation") is expressed by sourceBinding, not by demoting the class.
2.2 Level
{
"schema": "uni.stack.level/1.0.0",
"id": "L0.motor",
"index": 0, // ell; MUST equal array position for role LEVEL
"role": "LEVEL", // "LEVEL" | "WORLD" | "ANALYSIS"
"title": "Flagellar motor switch",
"playTitle": "The tiny motor",
"built": true,
"truthClass": "MODELED", // computed = weakest over seated bricks; not user-settable
"tauTicks": 1, // integer >= 1
"eventTrigger": null, // null | { "kind": "XI_EXCEEDS", "threshold": 2.5 }
"stateLabels": ["falling", "flat", "rising"],
"obsLabels": ["falling", "flat", "rising"],
"policyLabels": ["RUN", "TUMBLE"],
"sockets": {
"SEE": { "brickId": "OBS_WORLD_SENSOR", "params": {}, "enabled": true },
"THINK": { "brickId": "BAYES_CATEGORICAL", "params": {}, "enabled": true },
"EXPECT": { "brickId": "GENERATIVE_AB", "params": { "A": [[/*3x3*/]], "B": [[[/*2x3x3*/]]] }, "enabled": true },
"WANT": { "brickId": "PREFERENCE_CDE", "params": { "C": [-2.81,-1.43,-0.36], "D": [0.15,0.35,0.50], "E": [0.5,0.5] }, "enabled": true },
"SURE": { "brickId": "PRECISION_FIXED", "params": { "gamma": 4, "Pi": [1,1,1] }, "enabled": true },
"DO": { "brickId": "EFE_UNIMOTOR_LEGACY_V1","params": { "effort": [0.02,0.07] }, "enabled": true }
},
"upMap": { "mode": "PRIMITIVE", "primitive": "up_q_eps_pi_F" },
"downMap": { "mode": "PRIMITIVE", "primitive": "down_C_from_parent_q" },
"parentId": null,
"childIds": [],
"sourceBinding": {
"kind": "NONE", // "NONE" | "FROZEN_REPORT" | "SOURCE_PINNED"
"path": null, "sha256": null, "field": null, "protocolId": null, "citation": null
},
"domain": { "species": null, "scale": "single motor",
"validity": "TENTATIVE" },
"refutation": {
"authored": false,
"falsifier": "Held-out mean log score does not exceed the coin-flip adversary beyond its 95% paired interval.",
"againstArm": "ADVERSARY_COIN_FLIP",
"threshold": { "metric": "logScoreNatsPerEvent", "direction": "GREATER", "value": 0 }
},
"notes": "",
"contentHash": "sha256:..."
}
Validator rules with named error codes:
| Code | Rule |
|---|---|
LEVEL_INDEX_MISMATCH |
levels[i].index !== i for role: "LEVEL" |
NOT_BUILT_CARRIES_NUMBERS |
built:false and any socket non-null or any numeric param present |
NOT_BUILT_TRUTHCLASS |
built:false and truthClass !== "NOT_BUILT" |
OBSERVED_WITHOUT_SOURCE_PIN |
truthClass === "OBSERVED" and sourceBinding.kind !== "SOURCE_PINNED" or citation === null |
NOT_COLUMN_STOCHASTIC |
any column of A or of any B[pi] off simplex by > 1e-12 |
DIM_MISMATCH |
any param shape disagreeing with stateLabels/obsLabels/policyLabels |
GAMMA_NONPOSITIVE, PRECISION_NONPOSITIVE, TAU_NOT_POSITIVE_INTEGER |
as named |
MISSING_REQUIRED_SOCKET |
built:true with a null required socket |
Note the single EXPECT socket carries both A and B as one GENERATIVE_AB brick. This repairs the lego-first schema flaw where A and B were separate bricks competing for one socket.
2.3 Stack
{
"schema": "uni.stack.stack/1.0.0",
"stackId": "S_MOTOR_V1",
"title": "One motor deciding",
"playTitle": "The swimming bacterium",
"levels": [ /* Level[], index-ascending; role ANALYSIS last */ ],
"seams": [
{ "lowerId": "L0.motor", "upperId": "L1.dwell",
"upPrimitive": "up_q_eps_pi_F",
"downPrimitive": "down_C_from_parent_q" }
],
"worldBinding": { "kind": "SYNTHETIC_WORLD", "module": "lib/uni-motor.js", "symbol": "stepWorld" },
"observationBinding": {
"module": "lib/uni-motor.js", "symbol": "observeWorld",
"permittedFields": ["ligandUm","receptorActivity","motorSpeedRpm","rotation","loadPnNm","pmfMv"]
},
"seed": 20260721,
"dtS": 0.05,
"replayGuarantee": "ENGINE_ONLY", // "FULL" for FROZEN_EVIDENCE stacks
"parents": ["sha256:..."],
"createdAtUtc": "2026-07-21T09:00:00Z", // EXCLUDED from stackHash
"authorNote": "", // EXCLUDED from stackHash
"stackHash": "sha256:..."
}
seams is per-seam, not one global coupling pair — this repairs the lego-first dimensional impossibility where a single {up, down} had to type-check against heterogeneous levels.
permittedFields is the machine-readable Markov boundary; the runtime deletes every other key and freezes the record, so trueGradient, stators, methylation can never reach an agent.
2.4 Messages — the exact seam contract
export interface UpPayload { // ell -> ell+1
q: number[]; // approximate posterior over x_ell, simplex
eps: number[]; // o - A q, outcome-space residual, unit "prob"
Pi: number[]; // sensory precision actually in force, positive
xi: number[]; // Pi .* eps
F: number; // unit "nat"; per-level only, never summed across levels
complexity: number; // KL[q||prior], nat
accuracy: number; // E_q[ln p(o|x)], nat
uncertainty: number; // H[q], nat
viability: number | null; // [0,1] or null when no homeostatic brick is seated
}
export interface DownPayload { // ell+1 -> ell
C: number[] | null; // log preferences, nat, unnormalised
D: number[] | null;
E: number[] | null;
gamma: number | null;
Pi: number[] | null;
APrior: number[][] | null;
BPrior: number[][][] | null;
gate: number[] | null; // inhibition in [0,1] per state
}
export interface Message {
schema: "uni.stack.message/1.0.0";
direction: "UP" | "DOWN";
fromLevelId: string; toLevelId: string;
fromIndex: number; toIndex: number;
postedTick: number; deliverTick: number; // ALWAYS postedTick + 1
kind: "PAYLOAD" | "ABSENT";
payload: UpPayload | DownPayload | null; // null iff kind === "ABSENT"
truthClass: TruthClass;
receipt: { producedBy: string; nodeHash: string; payloadHash: string };
}
Three hard rules, each with a mutation cell:
direction === "UP"requiresfromIndex < toIndex;direction === "DOWN"requiresfromIndex > toIndex.post()throwsMESSAGE_DIRECTION_VIOLATIONotherwise.deliverTick === postedTick + 1in both directions. The one-tick latch is what makes level iteration order provably irrelevant. It is a declared modelling choice and is rendered in the level card's MATH pane: "messages arrive one tick later; this level is responding to what its neighbour saw last tick."- Down-message persistence.
runState.effective[ell]holds the last receivedDownPayloadand persists until replaced. This repairs the science-first flaw where atau=8child discarded its parent's message on 7 of 8 ticks. Nodeparametersare never mutated in place, so a saved stack always replays identically.
eps and xi are declared honestly as predictive-coding diagnostics with no variational status in a categorical model. They are carried in the up-payload because they are the most legible seam quantity, and the level card labels them diagnostic, not part of F. This is a preserved disagreement with the pure variational formulation, not a silent import.
2.5 Run, Variant, Experiment
// experiments/results/stack/<runId>.run.json
{
"schema": "uni.stack.run/1.0.0",
"runId": "sha256:...", // sha256(stackHash + "|" + seed + "|" + ticks + "|" + armId)
"stackHash": "sha256:...", "seed": 20260721, "ticks": 2000,
"engineVersion": "1.0.0", "nodeVersion": "v25.x", "replayGuarantee": "ENGINE_ONLY",
"partial": false, // true if any stepLevel() was used -> unscorable
"prospectivity": "TIMING_UNVERIFIED", // browser may ONLY emit this or "PENDING"
"sealHash": "sha256:...",
"perLevel": [
{ "index": 0, "levelId": "L0.motor", "inert": false,
"firedTicks": [0,1,2], "F": [0.431,0.402], "G": [[2.71,2.93]] }
],
"notRunLevels": [ { "index": 6, "levelId": "L6.perception", "reason": "NOT_BUILT" } ],
"traceHash": "sha256:...",
"adverseFindings": [] // append-only; never shortened
}
// experiments/stacks/<slug>/variants/<variantId>.json
{
"schema": "uni.stack.variant/1.0.0",
"variantId": "v_random_action", "label": "Just guess",
"role": "TREATMENT" | "CONTROL" | "ABLATION" | "ADVERSARY" | "NEGATIVE_CONTROL" | "CHEATING_CONTROL" | "BASELINE_PARENT",
"parentHash": "sha256:...", "childHash": "sha256:...",
"ops": [ { "op": "set", "path": "/levels/0/sockets/DO/brickId", "value": "ACTION_RANDOM" } ]
}
path is a restricted pointer: /levels/<i>/sockets/<SOCKET>/params/<paramId>, /levels/<i>/sockets/<SOCKET>/brickId, /levels/<i>/sockets/<SOCKET>/enabled, /levels/<i>/tauTicks, /seed. Anything else — notably /__proto__, /constructor, /levels/<i>/truthClass — is rejected by applyOps with ILLEGAL_OP_PATH. Truth class is not reachable by any op.
// experiments/designs/<EXP_ID>.experiment.json
{
"schema": "uni.stack.experiment/1.0.0",
"experimentId": "E_MOTOR_POLICY_ABLATION_V1",
"question": "Does expected-free-energy policy selection beat a coin flip on held-out dwell prediction?",
"prereg": {
"claim": "...", "falsifier": "...", "authored": true,
"primaryRule": "meanHeldoutLogScoreNatsPerEvent",
"secondaryRule": "CRPS",
"decisionThreshold": { "direction": "GREATER", "value": 0, "onInterval": "LOWER_95" },
"stoppingRule": "fixed 2000 ticks; no peeking; no re-seeding",
"committedAgainstCommit": null
},
"arms": [ /* {armId, role, variantId} */ ],
"holdout": { "source": "experiments/results/observed-experiment-report.json",
"partition": "holdout", "unitField": "motorId" },
"seeds": [1,2,3,4,5,6,7,8,9,10],
"variantOf": null,
"contentHash": "sha256:..."
}
3. Engine API
All files pure ESM, no dependencies, .d.ts sibling for each, house style of lib/source-first-passage.js.
lib/stack/hash.js
export function canonicalJson(value): string // keys sorted, LF, toPrecision(17), -0 -> 0, throws on NaN/Infinity
export function sha256Hex(bytesOrString): string // hand-written, synchronous, Node === browser
export function contentHash(value): string // "sha256:" + sha256Hex(canonicalJson(stripHash(value)))
Differential gate, per the truth-safety judge: every truth guarantee in this design reduces to this one function, so tests/stack/hash-differential.test.mjs compares sha256Hex against node:crypto over 20 000 seeded random byte strings of length 0–4096 (covering the 55/56/64-byte padding boundaries), not merely NIST fixed vectors.
lib/stack/numeric.js
export const EPSILON = 1e-12;
export function normalize(v): number[]
export function softmax(v): number[]
export function entropy(p): number
export function klDivergence(p, q): number
export function crossEntropy(p, logQ): number
export function matVec(M, v): number[]
export function isColumnStochastic(M, tol = 1e-12): boolean
These re-export or delegate to lib/uni-motor.js's already-tested normalize (:20), softmax (:26), entropy (:31), klDivergence (:38) wherever semantics match, so the kernel cannot drift from the pinned implementations.
Removed from the design: the hand-rolled fp.js (exp/ln minimax polynomial). Two judges independently flagged it: it costs 2–3 days, adds a new numerical-correctness surface whose failure silently corrupts every F and G, and buys a cross-engine claim that is void anyway because lib/uni-motor.js:139-198 uses Math.exp/Math.sin. The replay guarantee is scoped to a pinned Node version and engine build, declared in Run.replayGuarantee and printed in the UI. This is a deliberate downgrade of a claim, recorded here as such.
lib/stack/rng.js
export function u32(seed, streamId, counter): number // splitmix32 over (seed ^ hash32(streamId)) + counter
export function unitFloat(seed, streamId, counter): number
export function sampleCategorical(p, seed, streamId, counter): number
Counter-based and stateless: a pure function of its arguments, never of call order. This is what makes the scheduler-permutation determinism test a real falsifier rather than a tautology.
lib/stack/truth.js
export const TRUTH_ORDER = ["NOT_BUILT","FROZEN_PREDICTION","INFERRED","DERIVED","MODELED","OBSERVED"];
export function joinTruth(...classes): TruthClass // returns the WEAKEST
export function bindObserved(value, sourceBinding, resolver): { value, truthClass: "OBSERVED", sourceBinding }
export function isObservedMintable(sourceBinding): boolean
bindObserved is the only mint point for OBSERVED. It requires:
sha256Hex(canonicalJson(value)) === sourceBinding.sha256;sourceBinding.citation !== null;resolver.classify(sourceBinding.path) === "FROZEN_REPORT_DIR".
resolver is injected: under Node it is a filesystem resolver rooted at experiments/results/; in the browser it is a manifest resolver over SOURCE_PIN_MANIFEST, a build-time-generated frozen allowlist of {path, sha256, field} triples emitted by lib/stack/source-pins.js. This closes the kernel-first hole where the directory constraint had no browser meaning. A pin absent from the manifest throws SOURCE_PIN_NOT_IN_MANIFEST.
No exported function anywhere in lib/stack/ accepts a truthClass argument for computed state. That absence is itself gated.
lib/stack/ports.js
export function canConnect(plug: PortType, socket: PortType): { ok: boolean, code?: string, childMessage?: string }
export function portsRegistry(): PortType[] // enumerable, for the exhaustive gate
Four checks: kind equal; dim equal; unit equal; joinTruth(plug, socket) must not raise the socket's class. Codes: KIND_MISMATCH, DIM_MISMATCH, UNIT_MISMATCH, TRUTH_UPGRADE_REFUSED, LEVEL_ORDER, CYCLE.
The anti-cast gate is not a name grep (the lego-first version was defeatable by renaming). It is exhaustive over the brick registry: for every registered brick and every (input, output) pair, assert no signature maps a Joules-kinded input to a Nats-kinded output or vice versa. Adding a brick with such a signature turns the gate red regardless of what it is called.
lib/stack/level.js
export function validateLevel(spec): Violation[]
export function freeEnergyAt(q, prior, logLikelihood): { F, complexity, accuracy }
export function fPass(spec, prevLevelState, observation, effectiveDown, ctx): FResult
export function gPass(spec, qNow, effectiveDown, ctx): GResult
F pass.
prior = (tick === 0) ? D : matVec(B[pi*], q_prev)
logLik[x] = SUM_o obsWeight[o] * ln A[o][x] // obsWeight = the observed outcome vector
q* ∝ prior .* exp(logLik) // exact categorical minimiser
F = KL[q* || prior] - E_{q*}[logLik] // = complexity - accuracy, nats
oPred = A q* ; eps = obsWeight - oPred ; xi = Pi .* eps
Correction to kernel-first, per the mathematical-correctness judge: Pi is removed from the likelihood. Raising A to the power Pi produces an unnormalised tempered pseudo-likelihood, so the reported F would no longer bound -ln p(o) and the identity test would be circular. In v1, Pi is carried in the up-message, used to weight xi for display and for the up map, and used by PRECISION_GATED_SENSE (a SEE-socket brick that mixes the observation toward uniform before it enters the likelihood, which is a normalised and declarable operation). Pi does not enter F. This is stated in the level card's MATH pane.
G pass.
per policy pi:
qX = matVec(B[pi], q*)
qO = matVec(A, qX)
risk = crossEntropy(qO, C) - entropy(qO) // = KL[qO || softmax(C)]
ambiguity = SUM_x qX[x] * entropy(A[:,x])
G(pi) = risk + ambiguity // gScoring "RISK_AMBIGUITY"
G(pi) = risk + 2*ambiguity + effort[pi] // gScoring "UNIMOTOR_LEGACY_V1"
qPi = softmax(ln E - gamma * G)
Both scorings ship as named constants. UNIMOTOR_LEGACY_V1 reproduces lib/uni-motor.js:318-328, where informationGain = entropy(qOutcome) - ambiguity is subtracted from a form already containing ambiguity, giving KL + 2*ambiguity + effort. It is the default for the migrated L0 and is pinned bit-for-bit. A gate asserts the two scorings differ. The level card shows a chip: G scoring: UNIMOTOR_LEGACY_V1 — ambiguity weight 2 (lib/uni-motor.js:326-328). Migrating the default is a separate, prospectively-predicted change, not a side effect of building this.
horizon does not appear in the schema. Single-step policies are the declared v1 semantics; there is no field implying a depth the engine does not implement.
lib/stack/bus.js
export function post(bus, message): Message[] // throws MESSAGE_DIRECTION_VIOLATION
export function deliver(bus, tick): { up: Record<string, Message>, down: Record<string, Message> }
export function absentMessage(direction, fromId, toId, tick, reason): Message
lib/stack/scheduler.js
export function createRunState(stack, { seed, ticks }): RunState
export function activeLevels(stack, runState, tick): number[]
export function tick(runState, stack, env): RunState // returns a NEW frozen state
export function runTicks(runState, stack, env, n): RunState
export function stepLevel(runState, stack, levelId): RunState // sets runState.partial = true
export function resetRun(stack, seed): RunState
export function traceHash(runState): string
export function replay(run): { ok: boolean, traceHash: string }
Tick semantics, exactly:
env.world = worldBinding.step(world, lastAction, controls, dtS)— only when aWORLDnode exists.o_0 = observationBinding.observe(...); keys outsidepermittedFieldsdeleted; recordObject.freezed.L = activeLevels(...), computed againsttickbefore any pass runs. A level is active iffbuilt === trueand (tick % tauTicks === 0or itseventTriggerfired last tick).- F phase, ascending
elloverL:fPass(spec, prev, obs, runState.effective[ell], ctx); thenpost(up)withdeliverTick = tick + 1. - G phase, descending
elloverL:gPass(...); thenpost(down)withdeliverTick = tick + 1; recordrunState.actions[ell]. - Mailbox swap: messages with
deliverTick === tick + 1become readable next tick.runState.effective[ell]is updated only when a down-message arrives, and otherwise persists. runState.tick += 1; every returned state and every nested array is frozen.
Order independence. Because all reads are from the previous tick's mailbox and the RNG is counter-keyed, permuting the iteration order over active levels cannot change traceHash. This is gated with 20 seeded permutations.
Run controls, one function each — the UI has no other way to advance state:
| Control | Call | Scorable? |
|---|---|---|
| Step (one tick) | tick(...) |
yes |
| Step this level | stepLevel(..., levelId) |
no — sets partial: true |
| Run 10 / 100 / 1000 | runTicks(..., n) |
yes |
| Run one level for N of its own periods | runTicks on a derived stack with other tauTicks set to Infinity — a real stack with its own hash, never a hidden mode |
yes |
| Reset | resetRun(stack, seed) — regenerated, not restored, which is itself a determinism check |
n/a |
| Run all arms | runExperiment(...) |
the only path producing a verdict |
scoreRun throws PARTIAL_RUN_CANNOT_BE_SCORED on any run with partial: true. Hand-stepping is exploration and can never become evidence.
NOT-BUILT participation. A built:false level is never in activeLevels, has all-null parameters by validation, and emits {kind:"ABSENT"} in both directions. A built level whose seam input arrives ABSENT enters status STARVED: all numeric fields null, absentReason: "UPSTREAM_ABSENT:<id>", and it does not fall back to its own D. This repairs the lego-first behaviour where a level downstream of a hole still published F = 0.4213 nat. Every reducer throws NULL_COERCION_REFUSED on a null. There is no sumF across levels at all (see §0).
lib/stack/experiment.js, lib/stack/scoring.js
// experiment.js
export function validateExperiment(exp): Violation[] // refuses missing CONTROL / ADVERSARY / NEGATIVE_CONTROL / CHEATING_CONTROL
export function sealExperiment(exp, holdoutHandle): SealedExperiment
export function runExperiment(sealed, env): RunRecord
export function makeHoldoutProxy(evidence): Proxy // every get throws HOLDOUT_SEALED until sealed
export function applyOps(stack, ops): Stack // restricted pointer whitelist
export function verifyVariant(parent, variant): { ok, reason }
// scoring.js
export function logScore(prediction, outcome, scaleSeconds): number // nats/event; requires Jacobian
export function crps(survivalFn, outcome, grid): number
export function pairedDelta(armA, armB, unitIds, { resamples, seed }): { mean, interval95, nUnits }
export function assertUnitIsNotFrame(unitField): void // throws UNIT_IS_FRAME on /frame|tick|time|index|row/i
export function recoverParameters(stack, trueParams, seeds): RecoveryReport
export function verdict(prereg, primary, secondary, recovery, refutation): Verdict
verdict() emits both rules plus a first-class rulesAgree; the UI may print the word "better" only when they agree. It downgrades to INCONCLUSIVE with an explicit reason on: PARAMETER_NOT_RECOVERABLE (95% coverage < 0.80), REFUTATION_NOT_AUTHORED, PARTIAL_RUN, CHEATING_CONTROL_DID_NOT_WIN.
4. Math authoring
Three mechanisms. No eval, no new Function, no dynamic import(), no string setTimeout — enforced by a source scan over lib/stack/** in the test suite.
4.1 Typed parameter forms (default; the only way A/B/C/D/E/gamma/Pi are edited)
Editing is projection-based, so an invalid state is unreachable rather than merely rejected:
// lib/stack/edit.js
export function setSimplexEntry(vector, index, value): number[] // redistributes remainder proportionally
export function setColumn(matrix, colIndex, vector): number[][] // per-column simplex projection
export function clampParam(spec, value): number | number[]
Simplex(k)— k draggable bars; every drag re-normalises visibly. A child sees conservation happen.Matrix(k,k,COLUMN)— one column per source state; column header reads "if it was FALLING, where does it go next?"MatrixStack— one grid per policy with a policy selector.C— nats, unnormalised log preferences, edited through a 5-notch want-o-meter mapping to[-4,-2,0,2,4]. Log-preferenceCis invariant to an additive constant underKL[qO||softmax(C)], so the migratedC = ln normalize([0.06,0.24,0.70])fromlib/uni-motor.js:317is exact.gamma,Pi— positive log-scale sliders with unit chips.
4.2 LEGO primitives (the brick palette)
Each brick is a named pure function with a declared port signature, a playName, a playSentence, an equationPlain, a truthClass and a pinning test. lib/stack/brick-registry.js exports BRICK_REGISTRY; a stack naming an unregistered brick fails validation with UNKNOWN_BRICK.
Six sockets, printed on every baseplate, identical from L0 to L12:
| Play label | Socket | Formal content |
|---|---|---|
| WHAT I SEE | SEE |
o |
| WHAT I THINK | THINK |
q(x) |
| WHAT I EXPECT | EXPECT |
A, B |
| WHAT I WANT | WANT |
C, D, E |
| HOW SURE | SURE |
gamma, Pi |
| WHAT I DO | DO |
pi, a |
v1 palette (24 bricks):
SEE OBS_WORLD_SENSOR ("Look at the world") wraps observeWorld
OBS_INSTRUMENT_FRAME ("Read the machine") wraps instrumentObservation
OBS_FROM_UP_MESSAGE ("Listen to below") o_{ell+1} = g_up(...)
OBS_REPLAY_FROZEN ("A real thing we measured") requires SourcePin; OBSERVED collar
PRECISION_GATED_SENSE ("Squint") mixes o toward uniform by Pi, normalised
OBS_ORACLE_CHEAT ("Peek at the answer") CHEATING_CONTROL only; permanently badged
THINK BAYES_CATEGORICAL ("Weigh the guesses") exact categorical minimiser
HOLD_BELIEF ("Never change your mind") identity; ablation
POSTERIOR_FROZEN ("Decide once, forever") adversary
EXPECT GENERATIVE_AB ("Because / Then") A and B together
DURATION_M0_EXPONENTIAL .. M3_MIXTURE wrap lib/duration-models.js
FIRSTPASSAGE_DLT wraps lib/source-first-passage.js
WANT PREFERENCE_CDE ("What I want / Start / Habit")
PREFERENCE_FROM_DOWN ("Do what I'm told")
SURE PRECISION_FIXED ("Sure")
PRECISION_FROM_DOWN ("Be as sure as I'm told")
DO EFE_RISK_AMBIGUITY ("Pick the best") G = risk + ambiguity
EFE_UNIMOTOR_LEGACY_V1 ("Pick the best, old way")
ACTION_ARGMAX / ACTION_SAMPLE
ACTION_RANDOM ("Just guess") adversary
ACTION_NONE ("Do nothing") ablation
COUPLE up_q_eps_pi_F, down_C_from_parent_q, down_precision_gate (registry primitives, each with a written equation and a test)
Coupling maps are a closed registry with published equations, not free expressions. up_q_eps_pi_F is the identity packing of {q, eps, Pi, xi, F, complexity, accuracy, uncertainty, viability}. down_C_from_parent_q(qParent, mapMatrix) yields C_child = ln(M qParent + eps), a declared normalised log-preference — the output is type-checked as a Nats vector of the child's obsDim, so a coupling cannot deliver a non-distribution into a child's F pass.
4.3 UNIEXPR (audit mode only, never in play)
A restricted expression grammar for scalar shaping of gamma schedules, Pi schedules and coupling scalars. It is never used for F or G, which are compiled primitives.
program := binding (";" binding)* [";"]
binding := ident "=" expr
expr := term (("+"|"-") term)*
term := unary (("*"|"/") unary)*
unary := ["-"] power
power := atom ["^" unary] // right-assoc; exponent must be dimensionless
atom := NUMBER | IDENT | call | index | "(" expr ")"
call := FUNC "(" [expr ("," expr)*] ")"
index := IDENT "[" NUMBER "]"
FUNC := ln | exp | sqrt | abs | min | max | clamp | sigmoid | softplus | tanh
| sum | dot | entropy | kl | normalize | softmax
IDENT := [A-Za-z_][A-Za-z0-9_]{0,31} // must be a key of the injected scope
NUMBER := digit+ ["." digit+] [("e"|"E") ["+"|"-"] digit+]
Scope, per level and explicit: q, o, oPred, eps, xi, F, G, qPi, gamma, tau, t, nX, nO, nPi, the level's declared scalar params, and childQ, childF, childEps. Any other identifier is a parse error with {line, column, token}.
No assignment beyond a declared output name, no strings, no property access, no comparison, no control flow, no loops, no user functions, no random, no now.
Character class whitelisted before the parser sees anything: [A-Za-z0-9_+\-*/^(),.\[\] ;=\n]. Everything else is a tokenizer rejection.
Budgets, enforced by parse and by an explicit interpreter step counter: ≤ 512 source chars, ≤ 256 AST nodes, ≤ 24 depth, ≤ 64 identifiers, ≤ 4096 interpreter steps. Exceeding any throws UXL_BUDGET_EXCEEDED.
// lib/stack/expr.js
export function tokenize(src): Token[]
export function parse(src, scope): { ast, astSha256, nodes, depth }
export function typecheck(ast, scope): { kind, unit, truthClass, errors }
export function evaluate(ast, env, budget = 4096): number | number[]
export function compileMap(src, scope): UxlMap
export function applyMap(map, env): Record<string, number|number[]>
Unit algebra. + - min max clamp require identical units. * concatenates, / cancels over a canonical multiset. ln exp sigmoid softplus tanh softmax normalize ^ require unit === "1". entropy and kl require a probability-constrained input and return "nat". Adding a Nats to a Joules is UNIT_MISMATCH, reported as "F is in nats; tau·dθ is in joules; they cannot be added."
Two guards that are acceptance criteria, not risk-register prose:
- Tamper detection. A saved
UxlMapstores{source, ast, astSha256}.loadStackre-parses the source, recomputes the hash, and refuses onUXL_AST_MISMATCH. A hand-edited AST in JSON cannot bypass the parser. - Non-finite refusal.
applyMapruns every output through a finiteness and declared-range check. A non-finite or out-of-range output marks the receiving levelSTARVEDwithabsentReason: "UXL_NON_FINITE"and propagates nulls — never a NaN into a parent'sF.
5. Visual specification
Route app/stack-builder/page.tsx (server; loads frozen reports and the source-pin manifest exactly as app/math-workbench/page.tsx:15-77 does) plus app/stack-builder/stack-builder.tsx (client). The math workbench gains one nav entry in the array at app/math-workbench/scientific-math-workbench.tsx:256-259 linking here; the 412-line component is otherwise untouched until Phase 6.
CSS precondition (verified defect). app/globals.css consumes var(--cyan), var(--gold), var(--failure), var(--void) at lines 577-579, 586, 606, 609, 612, 626-628, 640-641, 649, 653-656, 664-666, and none of the four is declared; :root at lines 4-21 defines only --ink --muted --quiet --ground --ground-2 --panel --panel-strong --line --line-strong --signal --signal-dark --prediction --belief --evidence --danger --good. All new builder CSS uses only the declared sixteen. The four undefined properties are fixed in a separate bounded change with its own test (Phase 0.5, §10) and are not a dependency of this plan.
5.1 Layout
Three columns ≥ 1200 px, stacked below, matching the existing breakpoints at app/globals.css:684-692.
Left rail .stack-shelf (18 rem). Five starter towers as chunky tiles: The swimming bacterium (2 levels, runnable), One motor deciding (1 level), Guess the weather (1 level, no biology), Two levels talking, and — not on the child shelf — The ladder we have not built, reachable only from the audit-mode header. Below: the palette, grouped by socket colour, each brick drawn with its studs. Bricks that cannot seat anywhere currently empty are 35% opacity and not draggable; in play mode, NOT_BUILT bricks (the Python-only nodes) are hidden entirely rather than draggable-then-dead.
Centre .stack-tower. The tower, L0 at the bottom, scrolling upward, so the eye reads bottom-up in the direction the F pass runs.
Right rail .stack-inspector (26 rem). Four tabs: NUMBERS, MATH, MESSAGES, SOURCE.
Bottom .stack-timeline (9 rem). One horizontal lane per level, x = tick; a firing is a 3 px mark, an event-triggered firing a taller mark in var(--danger). A scrubber sets viewTick; the ladder re-renders from run.history[viewTick] as a pure array index with zero recomputation. Beyond a 512-tick ring buffer the scrubber recomputes from (stack, seed) and, on divergence, renders a REPLAY MISMATCH banner and stops — a determinism assertion that runs in production.
5.2 The level card, component by component
app/stack-builder/level-card.tsx. What a card shows, at every detail level:
Header row. ℓ badge in mono; title (playTitle in play); truth chip — rendered at every detail level, using child words in play (something we made up / something we measured / not built); species label when non-null; clock chip τ=4 · next t=12; activity dot flashing on ticks where the level was ACTIVE.
Left gutter (3 rem) — the clock lane. A vertical column of tick pips at tauTicks spacing, current pip lit in var(--signal). A τ=4 level visibly blinks a quarter as often as L0. Asynchrony becomes legible with no text.
Body — six sockets in a row. Empty socket: dashed outline with its playLabel. Seated brick: solid tile, studs drawn on the top edge, a 4 px truth-class collar (OBSERVED = var(--good) plus a drawn keyhole; MODELED = var(--belief); DERIVED = var(--signal); INFERRED = var(--evidence); FROZEN_PREDICTION = var(--prediction); NOT_BUILT = var(--quiet), hatched).
Right gutter (10 rem) — live readouts, monospace, always with a unit suffix.
play: bar heights and words only, e.g. a surprise bar plusI was a bit surprised, and the action wordRUN.lab/audit:F = 0.4213 nat,complexity = 0.912 nat,accuracy = 0.481 nat,|ξ| = 1.08,G(π*) = 2.71 nat,a = RUN.- Inert or starved: every value is an em dash
—, never0, at every detail level.
Expanded body. Left column: ProbabilityRow (reusing the existing component pattern at scientific-math-workbench.tsx:203-211) for prior, likelihood, q(x), oPred, o, q(π); ε and ξ as signed bars (negative left in var(--danger), positive right in var(--signal)) labelled diagnostic, not part of F. Right column: the F/G table in the shape of the existing table at scientific-math-workbench.tsx:331 — columns Policy | risk | ambiguity | G | q(π) — with the footer F = complexity − accuracy = 0.912 − 0.481 = 0.431 nat.
The free-energy handle (the single best teaching affordance in the design). In the expanded card, q(x) is draggable on a 3-simplex triangle. freeEnergyAt(q, prior, logLik) is exported separately from the minimiser precisely so the UI can evaluate F at any user-supplied q. Dragging q off the minimum makes an F bar visibly rise; releasing snaps back to q*. In play the number is hidden and only the bar height shows. A child physically feels that q* sits at the bottom of a bowl.
INERT card. Grey, hatched, no sockets at all — nothing can be dragged into it at any zoom — and one sentence: NOT BUILT — nothing is computed here. A STARVED card reads WAITING — L1.dwell sent nothing.
5.3 The seam
app/stack-builder/seam.tsx, 3.5 rem between cards, two CSS-only arrows (no canvas, no WebGL).
- UP, left half,
var(--evidence):F=0.431 nat · ‖ξ‖=1.08 · Π̄=1.40. Stroke widthclamp(2px, 2px + 6px*tanh(|ξ|/scale), 8px). - DOWN, right half,
var(--prediction):γ=4.00 · C̄=−0.51 nat · gate=1.00. - In
play, the same arrows carry words: "I tell the level above what I noticed" / "It tells me what to want" — with the truth chip still present. - ABSENT: a dashed grey outline captioned
NO MESSAGE — LEVEL NOT BUILT. Never a thin-but-present numeric arrow. - Hover shows
postedTick → deliverTickand the receipt hash prefix, making the one-tick latch visible rather than mysterious.
5.4 Transport bar (sticky)
⏭ STEP · ▶ RUN ×[10|100|1000] · ⏹ · ⟲ RESET · ↶ UNDO · seed field · stackHash[0..12] (click to copy) · detail switch play | lab | audit.
UNDO is a first-class control at every detail level. Its absence in the science-first design was judged fatal for the child path: an 8-year-old's learning mode is undo.
5.5 Progressive disclosure — the exact contract
One detail prop, identical DOM. What is always present regardless of detail: truth chips, species labels, NOT BUILT captions, unit suffixes on every scalar, em dashes for inert values, the ABSENT seam caption. What is progressive: Greek symbols and formal names (lab+); the equation pane (lab+); hashes, sourcePin sha256, the gate ledger, prospectivity banner, and UNIEXPR bricks (audit only).
A screenshot taken in any mode carries the truth class of every number on it. This is gated by tests/rendered-html.test.mjs.
6. The LEGO / child path
6.1 Studs are types
Seven shapes; the shape is the type. Round = a bag of chances (Simplex); square = one number (Scalar, with the unit written on it); hexagon = something I saw (Obs); triangle = something I do (Act); cross = a change table (Matrix, column-stochastic); round-with-a-notch = surprise money (Nats); square-with-a-notch = real work (Joules). The last pair is the CLAUDE.md thermodynamic/informational separation rendered as plastic: a Joules stud physically cannot enter an F socket, and there is no conversion function in the library.
Seam connectors are keyed opposite: up-studs exist only on plate tops, down-tubes only on plate bottoms. Direction inversion is not a mistake a child can make.
6.2 Snapping and the four-tier refusal ladder
There are no modal dialogs and no error codes on screen in play.
- Prevention. Sockets that cannot accept a dragged brick do nothing at all — no red, no highlight. Legal sockets glow
var(--signal)with a 6 px magnet radius. Unusable bricks are dimmed and undraggable. - Snap-back. A bad drop tweens home in 160 ms with a 3° shake. Zero words.
- The hover chip. 600 ms on a socket draws the shape it wants; on an incompatible brick, draws the two shapes with a slash. Text layer, six sentences total:
- "These pieces are different sizes. The blue piece makes 3 numbers; the yellow piece wants 5."
- "This piece measures surprise. That hole measures energy. They are not the same kind of thing."
- "That's a Want piece. This hole is for a Because piece."
- "This is something we made up. That hole only takes something we actually measured."
- "Up-pipes only go up. Turn it around."
- "This level is not built yet, so nothing can come out of it." Each carries a show me link that highlights the exact stud pair.
- The grey run button. A tower with an empty required socket reads
▶ needs 1 more piece; clicking scrolls to the socket and pulses it three times. The tower never runs half-built and never zero-fills.
Every drag has a keyboard equivalent (select brick, Enter on a lit socket). There is no force-connect anywhere.
6.3 The first three clicks
- Click the shelf tile The swimming bacterium. A two-plate tower appears, fully built, valid,
▶ STEPpulsing. - Click ▶ STEP. Bars move; the seam arrow thickens;
WHAT I DOflipsRUN/TUMBLE. One step, one visible consequence. - Drag Just guess (
ACTION_RANDOM) ontoWHAT I DO, replacing Pick the best. Press▶ RUN ×100. The bacterium stops climbing the gradient.
The child has run an adversarial baseline and watched it lose, in three clicks, with UNDO available throughout. This is exploration — it produces a trajectory, not a verdict.
6.4 From play to a scored claim
When the child (or researcher) clicks Make it count, the experiment apparatus appears: the tower's CONTROL, ADVERSARY and NEGATIVE_CONTROL arms are auto-generated, the refutation card is pre-filled from the brick's default with authored: false, and the seal button appears. The result is real and reported — but a defaulted falsifier caps the verdict at INCONCLUSIVE with the reason shown in plain words: "You did not say what would show you were wrong, so this counts as a look, not a finding." Authoring the falsifier lifts the cap.
refutation.authored is a self-report and defends against laziness, not intent. That limitation is printed on the record.
6.5 Verdict sentences
- Win: "Your model beat the coin flip by 0.42 (somewhere between 0.31 and 0.53). That is a real win."
- Loss: "Your model did NOT beat the coin flip. That is a real answer too — write down why you thought it would."
- Inconclusive: "We could not tell. The interval crosses zero, so this is not established."
The losing sentence is not removable and its content is appended to adverseFindings[].
7. Version control and variants
Content addressing. stackHash = "sha256:" + sha256Hex(canonicalJson(stack)) over everything except stackHash, createdAtUtc and authorNote, so mathematically identical stacks hash identically. parents[] records lineage. runId = sha256(stackHash + "|" + seed + "|" + ticks + "|" + armId).
On disk (git is the version-control system; nothing is reinvented):
experiments/stacks/<slug>/stack.json
experiments/stacks/<slug>/variants/<variantId>.json a PATCH, never a copy
experiments/stacks/<slug>/lineage.json append-only
experiments/results/stack/<runId>.run.json
experiments/designs/<EXP_ID>.experiment.json
experiments/predictions/<EXP_ID>.prediction.json
npm run stack:canon rewrites every artifact canonically so a one-parameter change is a one-line git diff. npm run stack:verify (scripts/verify-stacks.mjs) walks the tree and asserts: every stackHash recomputes; every variant's applyOps(parent, ops) reproduces childHash; every run's stackHash exists; every adverseFindings[] array is a superset of its previous committed value.
In browser. localStorage["uni.stack.workspace/1.0.0"], ≤ 2 MB, schema-versioned, oldest drafts evicted. No IndexedDB, no server, no network. A workspace that fails to parse or hash-verify is refused with a message, never silently reset. Export/import via Blob + URL.createObjectURL with repo-relative filenames plus an APPLY.txt; scripts/import-stack-export.mjs re-canonicalises, recomputes every hash, and fails on mismatch.
Variants are patches. role is one of the seven listed in §2.5. verifyVariant mismatch marks the variant BROKEN LINEAGE in var(--danger) and runExperiment refuses it: you cannot run a model whose ancestry does not reproduce.
Diff. lib/stack/diff.js:
export function diffStacks(a, b): {
levels: Array<{op:"add"|"remove", levelId, index}>,
params: Array<{levelId, socket, path, from, to, delta, columnSumsStillValid}>,
structure: Array<{levelId, path, from, to}>,
uxl: Array<{levelId, mapName, fromSha256, toSha256, fromSource, toSource}>,
truthAffecting: Array<{levelId, kind:"truthClass"|"built"|"sourceBinding", from, to, requiresSourceBinding}>
}
truthAffecting is a separate top-level field, rendered in a loud var(--danger) band above everything else, captioned TRUTH-AFFECTING CHANGE. Any move toward OBSERVED additionally displays the sha256 that must verify.
Lineage view. A text-first DAG: indented rows hash[0..12] · role · authorNote · Δ3 params, Δ1 level, tinted by last verdict — green SUPPORTED, red CONTRADICTED, grey INCONCLUSIVE, hatched NOT_RUN. A CONTRADICTED tip is never pruned; the graph is the record of what failed.
8. Experiment apparatus
8.1 Arms (closed enum; validateExperiment refuses an incomplete set)
| Role | Requirement | Content |
|---|---|---|
TREATMENT |
≥ 1 | the stack under test |
CONTROL |
≥ 1 | same stack, different seed |
ABLATION |
auto-generatable per level | one brick disabled or one parameter pinned neutral; recorded as an explicit ops[], never described in prose. Standard set: gamma→0, Pi→1, seam removed, C flat, B→identity |
ADVERSARY |
≥ 1 | ACTION_RANDOM, POSTERIOR_FROZEN, constant predictor, persistence baseline, KDE ceiling (training-only flexible model) |
NEGATIVE_CONTROL |
≥ 1 | outcome labels permuted within each unit at a declared RNG stream; expected paired advantage ≈ 0 |
CHEATING_CONTROL |
mandatory | OBS_ORACLE_CHEAT; must win by a wide margin or the score function cannot detect leakage and no other arm is interpretable |
BASELINE_PARENT |
auto for variants | the parent stack |
runExperiment refuses to emit a result without a CHEATING_CONTROL arm, and verdict() returns INCONCLUSIVE / CHEATING_CONTROL_DID_NOT_WIN if it fails to win.
8.2 Scoring
Primary: mean held-out log predictive density, nats per event, on the seconds scale. Secondary: CRPS. Both always computed; rulesAgree first-class; the word "better" only when they agree — lifted from audits/phase-b/b3-competition-protocol.v1.json.
The -log(stateMeanDurationS) Jacobian is mandatory for any normalised-duration density, exactly as lib/duration-models.js:96 does it; a density declared on a normalised scale without a declared Jacobian throws MISSING_JACOBIAN.
Uncertainty: paired cluster bootstrap over the declared experimental unit (motorId), resampling nUnits unit ids with replacement, never rows. assertUnitIsNotFrame throws UNIT_IS_FRAME when the unit field matches /frame|tick|time|index|row/i or when its cardinality equals the row count. Deltas are always TREATMENT − namedArm; the UI never shows an absolute score without at least one delta beside it.
8.3 Parameter recovery
recoverParameters(stack, trueParams, seeds) simulates from a known θ at declared seeds, refits with the stack's own fitter over the declared bounded box, and reports per-parameter bias, RMSE and 95% interval coverage. A level with coverage < 0.80 is stamped UNIDENTIFIABLE on its card in var(--danger), and verdict() downgrades to INCONCLUSIVE / PARAMETER_NOT_RECOVERABLE. Identifiability is a precondition for interpreting any comparison, and this is the gate that enforces it.
8.4 Prospectivity — three locks plus a delegated witness
- Unreachability.
env.holdoutis aProxywhose everygetthrowsHOLDOUT_SEALEDuntilsealExperimenthas been called. The answer is not hidden in the UI; it is unreachable from the computation. - Prior-run refusal.
sealExperiment(exp, existingReceipts)throwsALREADY_RUNif any run record exists for thatbaselineStackHash. This closes the see-then-freeze loop: you cannot pre-register a stack you have already run. - Seal integrity. The run stores
sealHash = sha256(canonicalJson(prereg)). A one-character post-hoc prereg edit yieldsSEAL_MISMATCH. - The witness is git, not the browser.
runExperimentin the browser stampsprospectivity: "TIMING_UNVERIFIED"— there is no code path by which the browser emitsPROSPECTIVE. The builder writesexperiments/predictions/<EXP_ID>.prediction.jsonin the exact shape validated bytests/semantic/prospectivity-provenance.test.mjs(recordId,claim,falsifier,madeAgainstCommit40-hex,predictionPath,resultPaths,prospectivity), initialisedPENDING. The user commits the prediction alone, runs, then commits the result; the existing gate measuresPROSPECTIVEfromgit merge-base --is-ancestor. A single squashed commit measuresSAME_COMMITand is forced toTIMING_UNVERIFIED, and an environment without git degrades toNOT_RUN. We reuse that adversarially-tested oracle rather than mint a second, weaker one.
8.5 Ledger integration
Builder gates append to experiments/results/stack-gates-report.json using the existing gate-record shape consumed at app/math-workbench/page.tsx:36-42 ({id, title, status, criterion, limitation}) and the existing status vocabulary PASS / FAIL / NOT_ESTABLISHED / BLOCKED_EXTERNAL / NOT_RUN, so they render in the Measured-evidence tab with zero new UI code. tests/semantic/adverse-record-preservation.semantic.test.mjs is extended to cover experiments/results/stack/*.run.json, so a CONTRADICTED verdict cannot be deleted in a later commit. Post-hoc defects are recorded as audits/phase-e/<ID>-correction-package.v1.json in the D1 schema; the run record is never edited and gains only a supersededBy pointer.
9. Test and mutation-gate list
House style throughout: header comment naming the frozen property and the target corruptions, node:test + node:assert/strict, hand-calculable fixtures, assertion messages naming the scientific consequence. All files appended to the explicit list in package.json:9.
9.1 Correctness gates (tests/stack/)
| File | Asserts |
|---|---|
hash-differential.test.mjs |
sha256Hex matches node:crypto over 20 000 seeded random inputs 0–4096 bytes incl. the 55/56/64-byte boundaries; canonicalJson stable under key reordering; throws on NaN/Infinity |
numeric-invariants.test.mjs |
normalize sums to 1 ± 1e-15; softmax additive-constant invariant; entropy ≤ ln n with equality only at uniform; kl(p,p) === 0 and kl ≥ 0 over 10⁴ pairs |
level-algebra.test.mjs |
the load-bearing gate. Over 10⁴ seeded (prior, logLik): (a) closed-form q* equals 200-step gradient-free minimisation of freeEnergyAt to 1e-9; (b) F(q*) ≤ F(q) for 100 perturbations each, every time; (c) F === complexity − accuracy exactly; (d) F === kl(q*, exactPosterior) − ln evidence with the first term ≤ 1e-12 — and exactPosterior is computed independently in the test file from prior .* exp(logLik), not imported from the kernel, so the oracle is not circular; (e) G === risk + ambiguity and risk === crossEntropy(qO,C) − entropy(qO); (f) qPi is a simplex and argmin G === argmax qPi at uniform E |
scheduler-determinism.test.mjs |
three runs from one seed give identical traceHash; replay(run) reproduces it; 20 seeded permutations of active-level order leave traceHash unchanged; seed+1 changes it; 1000 ticks over 3 levels complete under 200 ms |
bus-routing.test.mjs |
every message satisfies the direction/index ordering and deliverTick === postedTick + 1; every receipt recomputes |
expr-grammar.test.mjs |
frozen 60-row accept/reject table incl. constructor, a.b, this, process.exit(1), import("x"), while(1)1, backticks, __proto__, a=>a, ${x}, 0x41, a 600-char source, a 40-deep nest, unknown FUNC, out-of-scope IDENT; budgets throw; astSha256 stable across parse→serialise→parse; UXL_AST_MISMATCH on a hand-edited AST; applyMap on a NaN-producing map yields STARVED, not NaN; source scan of lib/stack/** finds zero eval, new Function, dynamic import(, Math.random, Date.now, performance.now, fetch, WebGL, WebGPU |
unit-algebra.test.mjs |
nat + J throws UNIT_MISMATCH; exp(J) throws; ln(uM) throws; entropy(q) returns "nat"; a Joules-valued expression cannot seat in an F socket |
ports-connect.test.mjs |
frozen kind×kind connectivity matrix; exhaustive registry scan asserting no brick signature maps a Joules input to a Nats output or the reverse (not a name grep); every refusal code returns a child message |
stack-hash-stability.test.mjs |
key reordering, float reformatting, and createdAtUtc/authorNote changes leave stackHash unchanged; any parameter change alters it |
scoring.test.mjs |
log score against a hand-computed exponential; CRPS against a closed form; interval width shrinks as 1/√nUnits; MISSING_JACOBIAN throws |
migration-parity.test.mjs |
S_MOTOR_V1 over 500 ticks reproduces stepAgent/stepSyntheticSystem on posterior, likelihood, risk, ambiguity, efe, policyPosterior, vfe, surprise to ≤ 1e-12 (a tolerance, not deepEqual — decomposition reassociates floating-point operations) |
9.2 Semantic / mutation gates (tests/semantic/)
| File | Kills |
|---|---|
stack-truth-lattice.semantic.test.mjs |
M-LAUNDER. Frozen 6×6 joinTruth table; joinTruth("MODELED","OBSERVED") === "MODELED"; bindObserved throws on sha256 mismatch, on a missing citation, and on a pin absent from SOURCE_PIN_MANIFEST; no exported function in lib/stack/ accepts a truthClass argument for computed state; applyOps rejects any path touching truthClass |
stack-message-direction.semantic.test.mjs |
M-DIR. post throws on mis-ordered indices; swapping up/down primitives changes traceHash (an inversion that changed nothing would mean the hierarchy is decorative); changing C at L1 leaves every L0 field at the same tick bit-identical; changing ε at L0 leaves L1's o unchanged until tick+1 |
stack-level-identity.semantic.test.mjs |
M-SWAP. levels[i].index !== i throws; exchanging two level indices changes traceHash and perturbs at least one F series by > 1e-9 |
notbuilt-inertness.semantic.test.mjs |
M-ZERO. Every INERT field is null (assert.strictEqual(x, null), which 0 fails); reducers throw NULL_COERCION_REFUSED; a level downstream of an ABSENT seam is STARVED with all-null numerics and does not fall back to its own D; built:false → true without parameters fails validation; adding a 12-level inert ladder leaves L0's traceHash bit-identical to the 1-level stack's |
stack-unit-separation.semantic.test.mjs |
M-UNIT. τ·Δθ is stamped "J" and cannot reach a "nat" socket; no exported function converts between them |
stack-seal-integrity.semantic.test.mjs |
M-SEAL. Holdout get throws before seal; sealExperiment throws ALREADY_RUN when a receipt exists; a one-char prereg edit yields SEAL_MISMATCH; a partial run throws PARTIAL_RUN_CANNOT_BE_SCORED; the browser path never emits PROSPECTIVE |
stack-experiment-completeness.semantic.test.mjs |
M-CTRL. Missing CONTROL/ADVERSARY/NEGATIVE_CONTROL/CHEATING_CONTROL is unsavable; the CHEATING_CONTROL must win or the verdict downgrades; the NEGATIVE_CONTROL paired interval covers 0 |
stack-pseudoreplication.semantic.test.mjs |
M-REPL. The bootstrap draws exactly nUnits ids; duplicating every event within each unit does not shrink the CI by > 5%; 8 motors × 500 frames gives the same interval as 8 motor aggregates; unitField: "frameIndex" throws |
uni-motor-parity.semantic.test.mjs |
M-DRIFT. UNIMOTOR_LEGACY_V1 reproduces stepAgent to 1e-12; RISK_AMBIGUITY and UNIMOTOR_LEGACY_V1 are asserted to differ, documenting lib/uni-motor.js:326-328 rather than hiding it |
ladder-not-built.semantic.test.mjs |
Every HUMAN_LADDER entry with index ≥ 1 has built:false, truthClass:"NOT_BUILT", no sockets, and openQuestion:true on L12 |
stack-lineage-integrity.semantic.test.mjs |
verifyVariant rejects an edited ops[]; applyOps throws on /__proto__, /constructor, /levels/0/truthClass |
9.3 Render gate (tests/rendered-html.test.mjs, extended)
Existing assertions unchanged. Added: GET /stack-builder returns 200; the human-ladder route renders NOT BUILT for every unbuilt level; no rendered element carries an OBSERVED chip while data-truth is not OBSERVED; every rendered scalar carries a unit suffix or an em dash; no inert level renders a numeric 0 for F, G, γ or Π; a play-mode render still contains a truth chip for every level.
9.4 Mutation battery
audits/phase-e/e1-stack-mutation-patch-set.v1.json (18 frozen mutations: M-LAUNDER ×3, M-SWAP ×2, M-DIR ×2, M-SEAL ×2, M-CTRL ×2, M-SCORE ×3, M-UNIT ×1, M-REPL ×1, M-ZERO ×1, M-ADVERSE ×1), executed by audits/phase-e/tools/run-stack-mutation-replay.mjs in the scratch directory, never the worktree.
Acceptance criterion, adopted from the science-first design because it is the only proposed defence against a vacuous gate: each mutation must be DETECTED_SEMANTIC with attributionSatisfied: true — naming the assertion that caught it. DETECTED_BY_HASH_ONLY (a diagnostic that merely reports a changed artifact hash) counts as UNCAUGHT. Any survivor is recorded as an OPEN COVERAGE HOLE in the ledger and in docs/STACK-BUILDER.md, never patched away silently. Phase C caught 0/10; this battery exists to make that unrepeatable.
10. Migration
Rule: wrap, never edit. lib/uni-motor.js, lib/duration-models.js, lib/source-first-passage.js, lib/observed-experiment.js, lib/walkthrough.js, lib/cad.js are byte-identical to HEAD c23f686 through Phase 9. All 103 tests keep passing because the modules they test are untouched.
The private-symbol constraint (verified). transitionPrior (lib/uni-motor.js:251) and policyTerms (lib/uni-motor.js:309) are module-private. Two of the three competing designs proposed binding to them directly; that is unbuildable without editing the module, which would widen the surface pinned at tests/semantic/world-agent-observation-boundary.semantic.test.mjs:134-138 (stepAgent.length === 3). Resolution:
- v1 adapters do not call them.
lib/stack/adapters/uni-motor-level.jsbuilds aLevelSpecfrom the literals in that file —AfromoutcomeLikelihood(:303-307, transposed toA[o][x]and column-normalised),B.RUN/B.TUMBLEfrombRun/bTumble(:254-263),C = ln normalize([0.06,0.24,0.70])(:317),D = [0.15,0.35,0.50](:91),Euniform,gamma = 4(from the literal-4at :348),effort = [0.02,0.07](:327),gScoring: "UNIMOTOR_LEGACY_V1",tau: 1, labels fromGRADIENT_STATES/OUTCOME_STATES/POLICY_NAMES(:10-12) — and the parity is proven against the exportedstepAgentto 1e-12. - Exporting the two private functions is deferred to its own bounded, separately-gated phase (out of scope here) with its own prediction record.
The world is a node, not a level. role: "WORLD", wrapping stepWorld/observeWorld unchanged. The Markov boundary is the frozen nine-key set already protected by tests/semantic/world-agent-observation-boundary.semantic.test.mjs:41-51, so the existing gate protects the builder for free. stepWorld uses Math.exp/Math.sin and is outside the bit-identical guarantee; such stacks record replayGuarantee: "ENGINE_ONLY", declared in the run artifact and shown in the UI.
The 11 catalog entries. They move verbatim from app/math-workbench/scientific-math-workbench.tsx:80-191 into lib/stack/catalog-nodes.js as CATALOG_NODES, each keeping its exact id/name/truth/equation/input/output/source/plain strings and gaining socket, ports, truthClass, built, role, sourceBinding. The .tsx then does:
import { CATALOG_NODES, toLegacyCatalog } from "@/lib/stack/catalog-nodes.js";
const modelCatalog = toLegacyCatalog(CATALOG_NODES);
toLegacyCatalog projects exactly the 8 legacy keys in the original order. The acceptance criterion is byte equality — JSON.stringify(toLegacyCatalog(CATALOG_NODES)) equals a frozen literal captured before the move — with tests/math-workbench.test.mjs and tests/rendered-html.test.mjs unmodified. This makes the migration provably a no-op.
Assignments:
| Catalog id | Role / socket | Truth | JS-executable? |
|---|---|---|---|
WORLD |
WORLD node | MODELED | yes (stepWorld) |
BOUNDARY |
SEE, L0 | DERIVED | yes (observeWorld) |
BAYES, VFE |
THINK, L0 | DERIVED | yes (via stepAgent) |
EFE |
DO, L0 | DERIVED | yes (via stepAgent) |
DURATION |
EXPECT, ANALYSIS | OBSERVED, source-pinned to experiments/results/observed-experiment-report.json + protocolId + DOI |
yes |
DLT |
EXPECT, ANALYSIS | DERIVED, pinned to the science-gates report | yes (evaluation only; fitting is Python) |
ROTATION (X03) |
ANALYSIS | DERIVED | no — NOT_BUILT in JS |
LATTICE (X06) |
ANALYSIS | MODELED, validity: "UNIDENTIFIABLE", carries MODEL FAILED IDENTIFICATION GATE |
no |
GMC (X07/X08) |
ANALYSIS | MODELED (source reproduction, not experimental truth) | no |
RFT (X09) |
ANALYSIS | DERIVED | no |
The four Python-only entries ship with implId: null, built: false, hatched grey, captioned NOT BUILT IN THE BROWSER — result comes from scripts/run-cross-study-parity.py. In play mode they are hidden from the palette entirely (per the child-usability judge: a draggable brick that produces a dead run button contradicts prevention-first refusal). In lab/audit they are visible and undraggable.
The nine B3 competitors. Any competitor list must distinguish NOT IMPLEMENTED from NOT RUN: M4_MIXTURE_K3, M5_GAMMA, M6_SEMI_MARKOV, M7_HIERARCHICAL, M8_EMPIRICAL_KDE exist only in frozen JSON specs and have no implementation in any language. The builder shows 4 implemented + 5 not implemented, never 9 pending.
The human ladder. lib/stack/ladder.js exports HUMAN_LADDER: L1 cellular … L11 offline replay, all built:false, truthClass:"NOT_BUILT", no sockets, each carrying a whatWouldItTakeToBuild string; L12 additionally openQuestion:true, claimed:false, claimForbidden: "This level is not built, not partially built, and no result from this repository bears on it." The ladder is not on the child shelf and is reachable only from audit mode, with a standing banner: "One level is built. The other twelve are names, not models." This is a presentational mitigation of a presentational risk and is recorded as such (§13).
11. Phased delivery plan
Ten phases. Every phase is independently shippable, independently testable, and reversible by reverting its own commit. Phases 1–5 touch no existing file; Phase 6 is the first that edits scientific-math-workbench.tsx, under a byte-equality gate.
Phase 0.5 — CSS custom-property repair (prerequisite, not part of the builder)
Deliverable. Declare --cyan, --gold, --failure, --void in :root (and any dark-scheme block) of app/globals.css, or replace their 15 use sites with declared properties.
Files changed. app/globals.css; tests/globals-css-vars.test.mjs (new).
Acceptance criterion. A test parses app/globals.css, extracts every var(--name) reference, extracts every --name: declaration, and asserts the reference set is a subset of the declaration set. Currently red for four names.
Falsifier. If the four properties turn out to be intentionally inherited from a parent document, the test is wrong and must instead assert an explicit allowlist of externally-supplied properties — recorded, not deleted.
Test that must fail first. tests/globals-css-vars.test.mjs on the unmodified app/globals.css.
Rollback. git revert the single commit. No builder code depends on it: all builder CSS uses only the sixteen declared properties.
Effort. Half a day. This is a separate bounded change with its own prediction record; it is listed here because every design that reuses .workbench-status chips inherits the defect.
Phase 1 — Canonical hashing and the truth lattice (one sitting)
Deliverable. canonicalJson, sha256Hex, contentHash, TRUTH_ORDER, joinTruth. Nothing else. No stack concepts, no runtime, no UI.
Files created. lib/stack/hash.js, lib/stack/hash.d.ts, lib/stack/truth.js, lib/stack/truth.d.ts, tests/stack/hash-differential.test.mjs, tests/semantic/stack-truth-lattice.semantic.test.mjs (lattice portion only).
Files changed. package.json (append two files to the test list).
Acceptance criterion.
sha256Hexagrees withnode:cryptoon 20 000 seeded random inputs of length 0–4096, explicitly including lengths 55, 56, 63, 64, 65, 119, 120.canonicalJsonis byte-stable under key insertion order; throws onNaN/Infinity; renders-0as0.contentHashof a fixture equals a hand-pinned literal.- The 6×6
joinTruthtable equals a frozen literal and always returns the weakest input. npm testpasses with the 103 existing tests' behaviour unchanged.
Falsifier. If sha256Hex disagrees with node:crypto on any input, every downstream truth guarantee — OBSERVED binding, seal integrity, lineage verification, AST tamper detection, trace replay — is void, because all of them reduce to this function. Stop and fix before any other phase.
Test that must fail first. tests/stack/hash-differential.test.mjs against an empty lib/stack/hash.js (module-not-found, then wrong-digest as the implementation lands).
Rollback. Revert the commit; delete lib/stack/; remove two lines from package.json. No existing file's behaviour was touched.
Effort. 1 day.
Phase 2 — Ports, level validation, F and G
Deliverable. lib/stack/types.d.ts, numeric.js, ports.js, level.js. A single level computes correctly with no stack, no bus, no scheduler, no UI.
Files created. lib/stack/types.d.ts, lib/stack/numeric.js(.d.ts), lib/stack/ports.js(.d.ts), lib/stack/level.js(.d.ts), tests/stack/numeric-invariants.test.mjs, tests/stack/level-algebra.test.mjs, tests/stack/ports-connect.test.mjs, tests/semantic/stack-unit-separation.semantic.test.mjs.
Files changed. package.json.
Acceptance criterion.
F(q*) ≤ F(q)for every one of 10⁶ perturbations across 10⁴ seeded cases.- Closed-form
q*equals a 200-step gradient-free minimisation offreeEnergyAtto 1e-9. F === complexity − accuracyexactly; theF = KL − ln p(o)identity holds against an independently computed posterior in the test file.G === risk + ambiguityunderRISK_AMBIGUITY;risk === crossEntropy(qO,C) − entropy(qO).- The exhaustive registry scan finds no Joules→Nats or Nats→Joules signature.
validateLevelreturns each named violation code on its targeted fixture.
Falsifier. If any perturbed q yields F below F(q*), the closed form is not the variational minimiser and the entire F pass is wrong. If the identity test can only be made to pass by importing the kernel's own posterior as the oracle, the test is circular and must be rewritten before the phase closes.
Test that must fail first. tests/stack/level-algebra.test.mjs clause (b), written against a deliberately non-normalised q*.
Rollback. Revert; Phase 1 stands alone and is unaffected.
Effort. 3–4 days.
Phase 3 — Bus, scheduler, inertness, bit-identical replay
Deliverable. rng.js, bus.js, scheduler.js, stack.js, ladder.js. A 3-level stack runs 1000 ticks headless; a 12-level inert ladder provably changes nothing.
Files created. lib/stack/rng.js(.d.ts), bus.js(.d.ts), scheduler.js(.d.ts), stack.js(.d.ts), ladder.js(.d.ts), tests/stack/scheduler-determinism.test.mjs, tests/stack/bus-routing.test.mjs, tests/semantic/stack-message-direction.semantic.test.mjs, tests/semantic/stack-level-identity.semantic.test.mjs, tests/semantic/notbuilt-inertness.semantic.test.mjs, tests/semantic/ladder-not-built.semantic.test.mjs.
Files changed. package.json.
Acceptance criterion.
- Three runs from one seed produce identical
traceHash;replay(run)reproduces it; 20 seeded permutations of active-level order leave it unchanged. - Every message satisfies the direction/index rule and
deliverTick === postedTick + 1. - Exchanging two level indices changes
traceHash. - Adding a 12-level inert ladder leaves the built level's
traceHashbit-identical to the 1-level stack's. - Every INERT field is
null; reducers throwNULL_COERCION_REFUSED; a STARVED level does not fall back to its ownD. - A
tau=8child still holds its parent's lastDownPayloadon intervening ticks. - 1000 ticks over 3 levels complete under 200 ms.
Falsifier. If permuting iteration order changes traceHash, the one-tick latch is not isolating the phases and order independence is false — the scheduler must then declare a fixed canonical order as a gated semantic requirement rather than claim commutativity. If adding inert levels perturbs the built level's hash by one bit, inert levels are not inert and are silently participating; that is the worst failure mode in this product and the phase must not ship.
Test that must fail first. tests/semantic/notbuilt-inertness.semantic.test.mjs, written against a scheduler that returns 0 for an inert F (assert.strictEqual(x, null) fails on 0).
Rollback. Revert; Phases 1–2 stand.
Effort. 4–5 days.
Phase 4 — Migration adapters, motor stack, headless CLI (first demoable artifact)
Deliverable. adapters/uni-motor-level.js, duration-level.js, first-passage-level.js; experiments/stacks/motor-loop/stack.json; scripts/run-stack.mjs printing an ASCII ladder with live F/G.
Files created. lib/stack/adapters/*.js, lib/stack/brick-registry.js(.d.ts), lib/stack/edit.js(.d.ts), experiments/stacks/motor-loop/stack.json, scripts/run-stack.mjs, tests/stack/migration-parity.test.mjs, tests/semantic/uni-motor-parity.semantic.test.mjs.
Files changed. package.json (tests + a stack:run script).
Acceptance criterion.
UNIMOTOR_LEGACY_V1reproducesstepAgent's posterior, likelihood, risk, ambiguity, efe, policyPosterior, vfe and surprise over 500 steps to ≤ 1e-12 (tolerance, not exact equality — decomposition reassociates float ops).RISK_AMBIGUITYandUNIMOTOR_LEGACY_V1are asserted to differ.lib/uni-motor.js,lib/duration-models.js,lib/source-first-passage.jsare byte-identical to HEADc23f686.- All 103 existing tests pass unmodified.
node scripts/run-stack.mjs experiments/stacks/motor-loop/stack.json --ticks 50prints a ladder with per-levelF,G, action and clock lane.
Falsifier. If parity cannot be reached at 1e-12 without editing lib/uni-motor.js, the general level is not a superset of the existing agent. Report the exact divergent term and stop; do not adjust either side to agree.
Test that must fail first. tests/semantic/uni-motor-parity.semantic.test.mjs against an adapter using RISK_AMBIGUITY (it must fail, proving the parity test can see the ambiguity-weight difference).
Rollback. Revert; the CLI and adapters are additive.
Effort. 3–4 days.
Phase 5 — UNIEXPR, typed editing, bricks, unit algebra
Deliverable. expr.js (tokenizer, parser, typechecker, interpreter, budgets, AST-hash tamper detection, non-finite refusal); simplex-projection editors; the 24-brick registry with canConnect.
Files created. lib/stack/expr.js(.d.ts), lib/stack/bricks.js(.d.ts), tests/stack/expr-grammar.test.mjs, tests/stack/unit-algebra.test.mjs, tests/stack/edit-projection.test.mjs.
Files changed. lib/stack/brick-registry.js, package.json.
Acceptance criterion.
- The frozen 60-row accept/reject table passes; all 12 hostile sources throw
ExprErrorat the correct index. - Budgets throw
UXL_BUDGET_EXCEEDED; unknown identifiers/functions throw with{line, column}. astSha256is stable across parse→serialise→parse; a hand-edited AST yieldsUXL_AST_MISMATCH.applyMapon a NaN- or out-of-range-producing map marks the receiving levelSTARVEDwithabsentReason: "UXL_NON_FINITE"and propagates nulls. (Acceptance criterion, not a risk note.)- Source scan of
lib/stack/**finds zeroeval,new Function,Function(, dynamicimport(,Math.random,Date.now,fetch. setSimplexEntryleaves the vector on the simplex over 10⁵ random edits.canConnectrefuses every truth upgrade and returns one of the six child messages per code.
Falsifier. If any legal UNIEXPR program can exceed 4096 interpreter steps, reference a name outside the injected scope, or reach a host object, the authoring mechanism is an injection vector and must be replaced by pure block composition with no expression language at all.
Test that must fail first. tests/stack/expr-grammar.test.mjs clause 4, written against an applyMap with no finiteness check (a NaN reaches the parent's F).
Rollback. Revert; UNIEXPR is audit-only and no shipped stack depends on it. The typed forms in edit.js would need to move to Phase 6 if expr.js is dropped.
Effort. 5–6 days.
Phase 6 — Catalog migration and the read-only tower UI
Deliverable. catalog-nodes.js + toLegacyCatalog; the /stack-builder route rendering a running tower: level cards, seams with live numerals, clock lanes, timeline, scrubber, inspector, transport bar, detail switch. No editing yet.
Files created. lib/stack/catalog-nodes.js(.d.ts), lib/stack/source-pins.js, app/stack-builder/page.tsx, stack-builder.tsx, level-card.tsx, seam.tsx, clock-lane.tsx, timeline.tsx, inspector.tsx, experiments/stacks/human-ladder/stack.json.
Files changed. app/math-workbench/scientific-math-workbench.tsx (catalog import + one nav entry), app/globals.css (new .stack-* section appended; no existing selector modified), tests/rendered-html.test.mjs, package.json.
Acceptance criterion.
JSON.stringify(toLegacyCatalog(CATALOG_NODES))equals the frozen literal captured pre-migration;tests/math-workbench.test.mjsand the existingtests/rendered-html.test.mjsassertions are unmodified and green.GET /stack-builderreturns 200 and server-renders the tower.- The human-ladder route renders unbuilt levels with
NOT BUILT, no sockets, and the L12OPEN QUESTIONcaption. - No inert level renders a numeric
0forF,G,γorΠanywhere in the HTML — only em dashes. - Every rendered scalar carries a unit suffix or an em dash; every level carries a truth chip in
playmode too. - New CSS references only the sixteen declared custom properties.
- A source scan of
app/stack-builder/**finds noMath.random, noDate.now, and no arithmetic onq/F/Gbeyond formatting.
Falsifier. If any displayed number cannot be traced to a field of a StackState produced by the engine, the UI has started computing science and the kernel-first contract is broken — delete the computation and move it into lib/stack/. If a play-mode render omits any truth chip, the disclosure mechanism is laundering by default and the phase must not ship.
Test that must fail first. The extended tests/rendered-html.test.mjs assertion "no inert level renders 0", written against a first-cut card that formats null as 0.
Rollback. Revert. The two edits to scientific-math-workbench.tsx are a one-line import swap and a one-entry nav append; reverting restores the inline literal exactly, and the byte-equality gate guarantees no behavioural difference either way.
Note on the bundle-purity check. The lego-first design's grep dist/ for fetch( is not implementable — the framework runtime contains fetch. The gate here is narrower and executable: scan only files under app/stack-builder/ and lib/stack/ for WebGLRenderingContext, WebGPU, three, navigator.sendBeacon, XMLHttpRequest, fetch(. Bundle-level purity remains an open question (§12).
Effort. 7–9 days. This is the largest phase; if it must be split, ship the level card + seam + timeline first and the inspector second.
Phase 7 — Drag, snap, refusal, parameter forms, the three-click path
Deliverable. The brick tray, shape-based snapping with the four-tier refusal ladder, keyboard equivalents, typed parameter forms, the F-handle simplex drag, UNDO, and the starter shelf.
Files created. app/stack-builder/palette.tsx, brick.tsx, param-form.tsx, simplex-handle.tsx, experiments/stacks/weather/stack.json, experiments/stacks/two-levels/stack.json, tests/stack/canconnect-messages.test.mjs.
Files changed. app/stack-builder/stack-builder.tsx, level-card.tsx, app/globals.css.
Acceptance criterion (executable parts only).
canConnectreturns exactly one of the six child sentences for each refusal code, asserted against a frozen table — no schema error code appears in any returnedchildMessage.- A stack with an empty required socket cannot run and the button text names the missing count.
- An uploaded/pasted stack with an illegal connection is still rejected by
validateStack, proving refusal is not UI-only. setSimplexEntryandsetColumnnever emit an off-simplex vector.- A source scan finds no force-connect or override path in the component tree.
UNDOrestores the previousstackHashexactly.
Acceptance criterion (non-executable, declared as such). The three-click path — shelf tile → STEP → swap in Just guess → RUN ×100 → visibly worse trajectory. This is a manual observation, not a gate. It is recorded in the ledger as NOT_ESTABLISHED until at least five children aged 7–10 complete it unaided. node --test cannot verify it and this plan does not pretend otherwise.
Falsifier. If any socket accepts a mismatched stud through any route — keyboard, paste, uploaded JSON — the geometric-impossibility claim is false. If five observed children cannot reach a running two-level stack in three clicks without help, the LEGO hypothesis is refuted and the interaction must be redesigned; record the refutation, do not restate the claim.
Test that must fail first. tests/stack/canconnect-messages.test.mjs against a canConnect returning bare codes rather than child sentences.
Rollback. Revert; Phase 6's read-only tower remains fully functional and shippable.
Effort. 6–8 days.
Phase 8 — Variants, lineage, diff, on-disk format, git handshake
Deliverable. variants.js, diff.js, the diff and lineage views, export/import, stack:canon, stack:verify, stack:import, and prediction-record emission in the existing validated shape.
Files created. lib/stack/variants.js(.d.ts), lib/stack/diff.js(.d.ts), app/stack-builder/diff-view.tsx, lineage-view.tsx, scripts/stack-canon.mjs, scripts/verify-stacks.mjs, scripts/import-stack-export.mjs, experiments/stacks/motor-loop/lineage.json, tests/stack/stack-hash-stability.test.mjs, tests/semantic/stack-lineage-integrity.semantic.test.mjs.
Files changed. package.json.
Acceptance criterion.
applyOps(parent, ops)reproduces the recordedchildHashfor every shipped variant; a tamperedopsyieldsBROKEN LINEAGEandrunExperimentrefuses it.applyOpsthrowsILLEGAL_OP_PATHon/__proto__,/constructor,/levels/0/truthClass, and any path outside the whitelist.diffStacksreportstruthAffectingas a separate top-level field; the view renders it above everything else.stack:verifypasses on the committed tree and fails when one digit of onestack.jsonis edited.- An emitted prediction record satisfies every schema assertion in
tests/semantic/prospectivity-provenance.test.mjs. adverseFindings[]in any run record is a superset of its previous committed value.
Falsifier. If a stack whose ancestry does not reproduce can still be run or scored, the version-control claim is cosmetic. If the browser ever writes prospectivity: "PROSPECTIVE", the prospectivity design is broken and the emitter must be removed before anything else lands.
Test that must fail first. tests/semantic/stack-lineage-integrity.semantic.test.mjs against an applyOps with no path whitelist (/__proto__ succeeds).
Rollback. Revert; the builder loses versioning and reverts to a single-stack tool. experiments/stacks/ artifacts remain readable.
Effort. 4–5 days.
Phase 9 — Experiments: arms, seal, scoring, prospectivity, verdicts
Deliverable. experiment.js, scoring.js, adversaries.js; the Compare view with sealed-outcome chrome; the first real experiment E_MOTOR_POLICY_ABLATION_V1.
Files created. lib/stack/experiment.js(.d.ts), lib/stack/scoring.js(.d.ts), lib/stack/adversaries.js, app/stack-builder/compare-view.tsx, experiments/designs/E_MOTOR_POLICY_ABLATION_V1.experiment.json, experiments/predictions/E_MOTOR_POLICY_ABLATION_V1.prediction.json, tests/stack/scoring.test.mjs, tests/semantic/stack-seal-integrity.semantic.test.mjs, tests/semantic/stack-experiment-completeness.semantic.test.mjs, tests/semantic/stack-pseudoreplication.semantic.test.mjs.
Files changed. tests/semantic/adverse-record-preservation.semantic.test.mjs, package.json.
Acceptance criterion.
- Holdout access before seal throws
HOLDOUT_SEALED;sealExperimentthrowsALREADY_RUNwhen a receipt exists for that baseline; a one-char prereg edit yieldsSEAL_MISMATCH. - A partial (hand-stepped) run throws
PARTIAL_RUN_CANNOT_BE_SCORED. - An arm list missing
CONTROL,ADVERSARY,NEGATIVE_CONTROLorCHEATING_CONTROLis unsavable. - The
CHEATING_CONTROLarm wins by a wide margin; theNEGATIVE_CONTROLpaired interval covers 0. - The bootstrap resamples exactly
nUnitsids; duplicating events within units does not shrink the CI by > 5%;unitField: "frameIndex"throws. verdict()emits both rules plusrulesAgree, and downgrades onPARAMETER_NOT_RECOVERABLE,REFUTATION_NOT_AUTHORED,PARTIAL_RUN,CHEATING_CONTROL_DID_NOT_WIN.- The browser path stamps
TIMING_UNVERIFIED, neverPROSPECTIVE.
Falsifier. If the CHEATING_CONTROL arm does not win by a wide margin, the score function cannot detect leakage and no comparison result from this builder is interpretable — halt and mark the comparison gate BLOCKED rather than publishing arm rankings.
Test that must fail first. tests/semantic/stack-seal-integrity.semantic.test.mjs, written against a holdout passed as a plain object (the pre-seal read succeeds).
Rollback. Revert; the builder reverts to exploration-only. No scored artifact would exist to orphan.
Effort. 5–7 days.
Phase 10 — Mutation battery and gate-ledger integration
Deliverable. The 18-mutation frozen patch set with per-cell predicted classifications, the replay runner, the executed ledger, stack-gates-report.json, and docs/STACK-BUILDER.md.
Files created. audits/phase-e/e1-stack-mutation-patch-set.v1.json, e1-stack-mutation-predictions.v1.json, tools/run-stack-mutation-replay.mjs, e1-stack-mutation-ledger.json, experiments/results/stack-gates-report.json, experiments/predictions/e1-stack-mutation.prediction.json, docs/STACK-BUILDER.md.
Files changed. package.json, README.md (the README currently does not mention the math workbench at all; this adds both).
Acceptance criterion.
- The patch set and its predicted classifications are committed strictly before the ledger, in separate commits, so the existing prospectivity gate measures
PROSPECTIVE. - ≥ 16 of 18 mutations are
DETECTED_SEMANTICwithattributionSatisfied: trueand zeroDETECTED_BY_HASH_ONLY. - Every survivor is recorded as an
OPEN COVERAGE HOLEwith its own falsification analysis in the ledger and indocs/STACK-BUILDER.md. stack-gates-report.jsonuses the existing status vocabulary and renders in the Measured-evidence tab with no new UI code.docs/STACK-BUILDER.mdstates the declared limitations: single-step policies; no hierarchical joint and therefore no cross-levelFaggregation;Pioutside the likelihood; the one-tick message lag;replayGuarantee: ENGINE_ONLYfor world-driven stacks; the child pathNOT_ESTABLISHED; four Python-only modulesNOT_BUILT; five B3 competitorsNOT IMPLEMENTED.
Falsifier. If any truth-laundering, level-swap, direction-inversion, seal-bypass, control-deletion or scoring-sign mutation survives — or is detected only by a changed artifact hash — the truth-contract claims of this builder are unproven, and it ships only with the coverage hole stated in the docs and the gate ledger. If the prediction and the ledger land in one commit, the existing gate forces TIMING_UNVERIFIED and the phase's own prospectivity claim fails; that is the correct outcome and must not be worked around.
Test that must fail first. The replay runner against a deliberately weakened joinTruth (M-LAUNDER-1); the run must classify it DETECTED_SEMANTIC with the naming assertion attributed.
Rollback. Revert; the audit artifacts are additive and the builder is unaffected.
Effort. 5–7 days.
Total: roughly 45–60 working days. Phases 1–4 (≈12 days) deliver a headless, tested, demoable engine. Phase 6 (≈8 days) delivers the first screen. Phases 8–9 are not optional polish: shipping the builder without versioning, controls and sealing yields an instrument that can edit models but cannot version, compare or pre-register them — scientifically worse than the current workbench.
12. Open questions requiring the user's decision
Multi-step policies. v1 scores
Gover a single transition. Extending to a policy tree of depthHchanges the schema (policiesbecome sequences), theGrecursion, and the UI. Do you want depth in scope, and at whatH? Until you decide, the word "policy" in this builder means "one action".The hierarchical joint. Downward messages modulate parameters; they are not a generative prior
p(x_ell | x_{ell+1}). Consequently there is no legitimate stack-total free energy. Do you want (a) to keep parameter modulation and permanently ban cross-levelFaggregation (current plan), or (b) to specify a real hierarchical joint, at which pointF_totalbecomes meaningful and the whole engine changes shape?Precision in the likelihood. I removed
Pifrom theFpass because a temperedA^Piis unnormalised and breaks theFidentity. The alternatives are: keep it out (current plan); introduce an explicitly normalised tempered likelihood with a declared temperature parameter and a modified identity; or model precision as a first-class latent with its own update. Which?Exporting
transitionPriorandpolicyTerms. The v1 adapter copies their literals and proves parity throughstepAgent. Exporting them would let a builder read and editA/Bdirectly against the live kernel rather than a copy, but widens the module surface pinned attests/semantic/world-agent-observation-boundary.semantic.test.mjs:134-138. Do you authorise that as a separate gated change?Rendering the human ladder at all. Every mitigation proposed across all three designs is presentational (hatching, captions,
openQuestionflags, audit-only access). A screenshot of a thirteen-storey tower with one storey lit is a claim about feasibility and ordering that nothing in this repository supports. Option: do not render unbuilt levels as a contiguous ordered ladder — show the one built level plus an unordered, unnumbered list of named open problems, so the tower shape cannot be photographed as a roadmap. Which do you want?Child usability evidence. The three-click path is a hypothesis with no gate. Do you authorise an observational study (five children aged 7–10, first-run path unaided, timed, with a pre-registered falsifier), or does the child path ship permanently marked
NOT_ESTABLISHED?Porting the Python-only modules. X03 rotation-gated binding, X06 the 8192-state lattice, X07 the 175-state GMC generator, X09 RFT propulsion are Python+SciPy. JS oracles already exist for parts of X03, X06, X08, X09 in
scripts/independent-cross-study-check.mjs. Porting them to the browser is a substantial separate project needing its own numerical-parity gate. Ship them permanentlyNOT_BUILTin the browser (current plan), or schedule the port?Bundle-level purity gate. Phase 6 scans only builder source, because the framework runtime legitimately contains
fetch. A true bundle-purity assertion needs a mechanism to exclude framework code from the scan. Do you want that mechanism specified and built, or is source-level scanning sufficient?Where the builder lives.
app/page.tsxcurrently redirects/to/math-workbench. The builder is proposed at/stack-builderwith a nav link. Should it instead be a 7th tab inside the existing component, or eventually become the landing route?UNIEXPR at all. It is audit-only, budgeted, totalised and tamper-checked — but it remains the only user-input-to-execution path in the product and the only place a parser bug becomes a determinism or injection hazard. The alternative is pure block composition with a slightly larger coupling-primitive registry and no expression language. Keep it, or cut it?
13. What we are NOT claiming
- We are not claiming the L0–L12 human ladder has been built. One level is built. L1–L12 are names with no parameters, no sockets, no numbers, no contribution to any total. A screenshot of the ladder is not evidence of progress and the builder must never present it as such.
- We are not claiming biological parity.
X16_FULL_BIOLOGICAL_PARITYis a conjunction over all gates and is currently FALSE. That is a hard ceiling, not a score. - We are not claiming general active inference. Policies are single-step. There is no policy tree, no temporal depth, no accumulated
Gover a horizon. - We are not claiming a hierarchical generative model. No
p(x_ell | x_{ell+1})is written down. Per-levelFbounds a per-level surprise and nothing more. Any "total free energy of the stack" would bound nothing, which is why the engine will not compute one. - We are not claiming cross-engine bit-identical replay. The hand-rolled transcendental library was cut. Replay is guaranteed on a pinned Node version and engine build; world-driven stacks are
replayGuarantee: ENGINE_ONLYbecauselib/uni-motor.js:139-198usesMath.exp/Math.sin. - We are not claiming that
epsandxiare variational quantities. They are predictive-coding diagnostics carried across seams because they are legible; they have no status in the categoricalF, and the card says so. - We are not claiming the builder can establish prospectivity. The browser emits
TIMING_UNVERIFIEDorPENDINGonly. Even the git-DAG witness is defeatable by a squash, a rebase, or a hand-edited file; it is an honesty scaffold against accident and self-deception, not a cryptographic commitment. It degrades toNOT_RUNwhere git is unavailable. - We are not claiming a child can use this. No usability evidence exists in this repository and this plan produces none. The three-click path, the shape vocabulary and the refusal ladder are design hypotheses recorded
NOT_ESTABLISHEDwith a stated falsifier. - We are not claiming the mutation battery covers the space. Eighteen named corruptions over a space of 24 bricks × 6 sockets × N levels is not exhaustive. Property-based generation of random valid stacks is an acknowledged gap, not delivered.
- We are not claiming the four Python-only modules run in the browser. X03, X06, X07/X08, X09 ship
NOT_BUILT; their results come fromscripts/run-cross-study-parity.py. - We are not claiming nine model competitors exist. Five of the nine B3 densities have no implementation in any language. The builder shows NOT IMPLEMENTED, distinct from NOT RUN.
- We are not repairing the existing adverse results. M2 lognormal beats M3 on held-out point score.
G03_PUBLIC_ARTIFACT_PARITYis FAIL andscripts/independent-science-check.mjs:41asserts the mismatch exceeds 0.5 relative error, so "fixing" it breaks the gate. The X06 latticeJdisagrees between full-distribution and moment fits. Phase-C blind mutations were detected 0/10. X10 and X12 areNOT_ESTABLISHED; X13–X15 and G07–G09, G11–G13 areSOURCE_ONLYorBLOCKED_EXTERNAL. All of these survive into the builder unchanged and visible. - We are not claiming this document is verified by execution. No test, gate, build or script was run in producing it. Every repository fact cited is read from source and committed reports, and the two acted-upon findings (private
transitionPrior/policyTerms; undeclared CSS custom properties) are corroborated by two independent judges but were not confirmed by me directly.
sha256 287fc0e01380e4d2 — of the original file, so what was ingested stays checkable.
Plain — written for this website, not the source document
Nothing in this plan has been built. It says so on its first page and again on its last: no test, gate, build or script was run in producing it, and the design was written by a design-and-judge pass rather than by execution.
What it proposes is a construction kit for layered models — a thing you assemble out of typed pieces, run, and then try to break. The shapes of the pieces are the rules: a piece measuring surprise physically cannot enter a hole that measures energy, and there is no function anywhere that converts between them.
One level would be built, by wrapping the existing motor code rather than rewriting it. The levels above it would ship as names with no parameters, no numbers and no contribution to anything, and adding them would have to leave the built level's result unchanged.
The last section is a list of things the plan explicitly does not claim, and it is the honest place to start reading.
Plain · written 2026-08-01 by claude-opus-5 · not yet checked by a person · about the document whose sha256 is 287fc0e01380e4d2
Clear — written for this website, not the source document
The document opens with a scope statement it asks you to read first, and half of that statement is negative. The proposed instrument is a repeatable construction and falsification tool for layered models, where each level owns its own hidden state, outcomes, policies and parameters, and passes typed messages across each seam. The existing motor loop would become the first fully built level, migrated by wrapping rather than rewriting.
Then the disclaimers, in the same breath. It is not a claim that the full ladder of levels exists: exactly one level is built, and the rest ship as not built, with no parameters, no numbers and no contribution to any total. It is not a general solver, because policies are single-step. It does not define a joint model across levels, so there is deliberately no total free energy for the stack — the engine refuses to compute a quantity that would bound nothing. It cannot decide in the browser whether a prediction was really made in advance. It carries no usability evidence. And it changes no existing science: the science modules stay untouched, and a migration of the catalogue is held to byte equality.
The middle of the document is design detail: a layered architecture with a one-directional dependency rule, and a data model in which every artifact is content-addressed and every port carries a kind, a dimension, a unit and a truth class. The truth class is never a settable field but a join over inputs that returns the weakest one. There is exactly one place where the strongest class can be minted, and it demands a matching checksum and a citation. No exported function accepts a truth class as an argument for computed state, and that absence is itself gated.
The engine sections give the exact equations for the belief update and for scoring each policy, and record two corrections made during design. A precision term was removed from the likelihood because raising the model to a power leaves it unnormalised and would stop the reported quantity bounding what it claims to bound. And the existing implementation's scoring, which counts one term twice, is preserved as a named legacy form with its discrepancy printed on the card, so that correcting it later is a visible, separately reviewed change rather than a silent drift.
Authoring is done through typed forms and a palette of named bricks, plus a deliberately tiny expression language available only in the most technical mode, with no dynamic evaluation anywhere. Units are algebraic, so adding an informational quantity to a physical one is a typed error with a plain-language message.
There is a full visual specification, and a section on the child path: shapes that are types, refusal by prevention rather than by error dialogs, and an undo control at every level. Reaching a scored claim requires the experiment apparatus, including a mandatory arm that cheats and must win — because if it does not win, the scoring cannot detect leakage and no other arm can be read.
The plan then lists its tests and its mutation battery, the migration rule of wrap-never-edit, ten phases each independently shippable and reversible, and ten open questions that are the user's to answer rather than the author's. It closes with a page of things it is not claiming. That no usability evidence exists. That the mutation battery does not cover the space. That several competitors have no implementation in any language. That existing adverse results survive unchanged into the builder. And that the document itself is not verified by execution.
Clear · written 2026-08-01 by claude-opus-5 · not yet checked by a person · about the document whose sha256 is 287fc0e01380e4d2