Skip to content

Latest commit

 

History

History
1959 lines (1811 loc) · 117 KB

DECISIONS.md

File metadata and controls

1959 lines (1811 loc) · 117 KB

Decision Records

Binding architecture and dependency decisions, with the reasoning that produced them. Each entry supersedes anything contradictory in PLANNING.md. Ordered by decision number; new decisions append.

D1 — MCP SDK: rmcp = "=2.2", stable MCP 2025-11-25

PLANNING.md §47 named rmcp loosely. rmcp 3.0.0-beta is in flight but beta; the stable line is 2.2, which speaks the MCP 2025-11-25 stable revision. Exact-pin (=) because rmcp 2.x → 3.x is a protocol-surface change, not a semver-compatible bump. Revisit when 3.0 reaches stable.

D2 — ONNX runtime: ort = "=2.0.0-rc.13" — DEFERRED by measured gate

ort rc.12 → rc.13 changed the API and rc.14 may again. fastembed pins rc.12, which is why fastembed is not used (D4). Pin exactly, keep behind the onnx feature, and bump only as a deliberate PR that re-runs the model round-trip tests. default-features = false, plus the load-dynamic feature packaged as the runtime-onnx-dynamic feature so distros can point at a system ONNX runtime.

Feasibility-gate result (measured 2026-09-24, scratch probe /nas/Temp/tmp/oc-ort-probe): the onnx feature does NOT ship in V0.1. The dependency is not added; the deterministic path is the only path. Per PLAN.md Phase 6 the gate had three legs:

  • (a) reference model loads with api-27 — FAIL. ort rc.13 defaults to api-27, which demands ONNX Runtime 1.27.x. Nothing on the reference machine provides it (system dylib: 1.21.0 / API 21; Python-wheel copies: 1.22.1, 1.24.1, 1.24.2 / API 22-24). Loading at the default API level fails with a clean typed error for every available dylib. It would pass only compiled down to api-24 (needs a 1.24.x dylib) or api-21 (all four load). Numerics are identical across API levels (measured).
  • (b) bit-identical logits round-trip vs Rust softmax — FAIL. Over 10,000 seeded random logit rows plus extremes: ONNX Softmax op, ONNX Div-chain, and Rust f32 softmax agree pairwise within max 5 ULP (~2.4e-7 abs, ~3.3e-7 rel) but no pair is bit-identical — including the two ONNX formulations with each other. ORT additionally flushes subnormal probabilities to 0 below ~1e-38 where Rust keeps them. Run-to-run ORT output is deterministic (0/640,000 values differed across 5 repeats) — the requirement that fails is cross-implementation bit-identity. Consequence for later: cache keys already avoid this (cached responses are stored, not recomputed in a second implementation), but verifier agreement and calibration comparisons must carry a 5-ULP tolerance, never == on f32.
  • (c) p99 single-decision latency ≤ budget — PASS. Single-row [1,8] p99: 30.8-61.0 us; batch [64,8] p99: 39.7-60.1 us across all four dylibs — two orders of magnitude inside the 5 ms budget (D9).

Re-entry conditions for the onnx feature: compile at api-21 or api-24 (not the default api-27); require an explicit ORT_DYLIB_PATH (no unversioned libonnxruntime.so resolves on this platform, and ort panics via dlopen without one); document the user-supplied dylib requirement; and carry the 5-ULP tolerance rule into verifier/calibration comparisons. Also: Session::run takes &mut self at rc.13, so a concurrent server needs a session pool or mutex.

D3 — MSRV: 1.90, stable channel

Edition 2024. Every dependency must support 1.90 (checked: criterion 0.8 needs 1.86, burn 0.20 needs 1.89 — both fine). rust-toolchain.toml pins stable locally; CI images use rust:1.90. MSRV bumps are release-note items.

D4 — No fastembed, no tantivy: hand-written BM25 + adapter-supplied

embeddings

  • fastembed → hard-pinned to ort rc.12, incompatible with D2, and drags hf-hub/model download into a privacy-first local crate. Rejected.
  • tantivy → full search engine (index files, concurrency, git dep for a fix) for what is an in-memory top-k over ≤256 candidates. Rejected.
  • Instead: ~200 lines of hand-written BM25 (opencodifier-engine), property-tested against hand-computed IDF/tf values, and an EmbeddingBackend trait so embedding models are supplied by the runtime crate — nothing downloads at runtime, ever.

D5 — Synchronous core, async at the edges

No Tokio (or any async runtime) in core/engine/schema/runtime. The engine is a sync library; Clock, Deadline, and CancellationToken are traits implemented by the caller. Interface crates (axum, rmcp, wasm-bindgen) adapt. Rationale: deterministic tests, no runtime contagion, trivial embedding in non-Tokio hosts.

D6 — Single normative cache-key site

CacheKeyBuilder in opencodifier-engine is the only code that computes a decision-cache key: SHA-256 (sha2 0.10) over length-prefixed fields — canonical request with sorted candidates, graph version, model id, calibration version, engine semver. Other crates reference CacheKey, never re-derive it.

Amendment (2026-09-28, relational solver): the model id in the key is the live classifier's id — DecisionEngine::new derives identity.model_id from Classifier::model_id() at construction, and wrapper classifiers compose their ids (relational-v1|builtin-lexical-v2), so a swapped or decorated classifier invalidates cached decisions without caller bookkeeping. A hand-set EngineIdentity::model_id is never trusted over the decider that actually runs.

D7 — Float policy: decision math in Rust, f64

All decision math is Rust-side f64 (softmax, calibration, margins, entropy, score means). ONNX graphs produce logits only. Guarantees the same decision on any backend and keeps quantization out of the decision path. float_cmp lint is denied outside test modules.

D8 — Trace contract frozen now

trace_version = 1 on every trace, #[non_exhaustive] on public enums, stable code() strings (ir.* prefix in core). Applied in Phase 1 — before any consumer existed — so no break can ever be "grandfathered".

D9 — Benchmarks: criterion from Phase 3, per-stage budgets replace §70

PLANNING.md §70's single whole-pipeline budget was too coarse to act on. Replaced by per-stage budgets (below), measured with criterion (harness = false) committed under benchmarks/ per release. Regression = p99 over budget on the reference machine class (4-core laptop).

Stage Budget (p99)
Normalize (schema → IR) 1 ms
Rule match + cache hit path 100 µs
BM25 narrowing, 256 candidates 1 ms
Embedding scoring (supplied backend) backend-declared
Deterministic decision path end-to-end 10 ms
HTTP overhead added by opencodifier-http 2 ms

D10 — Property testing: proptest + round-trip fixtures

Invariants that must never break, encoded as proptest cases: adapter round-trips (native ⇄ wire), distribution normalization under arbitrary probability vectors, cache-key stability under map re-ordering, candidate narrowing subset-safety. Fixtures under fixtures/ lock every wire format byte-for-byte.

D11 — Feature-flag matrix (heavyweight deps confined to one crate)

Feature Crate Pulls in
(default) core, engine, schema serde, thiserror, sha2 — nothing heavy
onnx opencodifier-runtime ort rc.13 (D2)
runtime-onnx-dynamic opencodifier-runtime ort load-dynamic
llamacpp opencodifier-model ureq (HTTP client only, D26)
tokenizers opencodifier-model tokenizers 0.22 + fancy-regex (Kai reference encoder, D33)
http opencodifier-http axum 0.8, tokio (pin corrected by D12)
mcp opencodifier-mcp rmcp 2.2 (D1)
cli opencodifier-cli clap v4
wasm opencodifier-wasm wasm-bindgen, getrandom js

No crate may enable another crate's heavyweight feature transitively; the default build of the whole workspace stays ML-free and runtime-free.

Dated 2026-10-05 (task #92): the llamacpp and tokenizers rows were added for the model rungs — D26's llama.cpp client and D33's Kai contract. onnx is reinstated for the Kai rung under the D2 pin, see D33.

D12 — HTTP surface: axum 0.8, /v1 prefix

Endpoints: POST /v1/decide, POST /v1/graph/validate, GET /v1/healthz. Errors map code() → stable strings in the body ({"error": {"code", "message"}}), HTTP status by error class (4xx input, 5xx internal). Binding default 127.0.0.1:8177; non-loopback binds require --allow-remote (documented as a real risk in SECURITY.md).

Amended 2026-09-24: the original pin said "axum 0.9" — that version does not exist (latest published is 0.8.9); the pin was aspirational and never checked, the same failure mode as the ONNX api pin in D2. Corrected to the measured reality: axum 0.8.

D13 — CLI: clap v4 with an exit-code table

decide, graph validate, serve, models verify subcommands. Exit codes: 0 success/abstain-with-flag, 1 input error, 2 policy gate escalation, 3 internal error — documented in --help and the book so scripts can branch on them.

Implemented notes (2026-09-25): exit 2 covers every non-decisive outcome (abstain, escalate, verify, no_valid_candidate) — is_decisive() is the only honest split the DecisionOutcome enum supports. clap's own default exit 2 for bad arguments is normalized to 1 so scripts can trust the table. serve --policy validates the file then reports cli.policy_inapplicable: policy lives on the request in this IR, and a pretended server-wide override would be a lie.

D14 — Model integrity: SHA-256 manifests, never commit weights

models/*.json manifests record {file, sha256, bytes, source, license}; the runtime refuses a mismatch before any tensor is read. models/ weights are gitignored; manifests are committed.

D15 — Calibration storage

Per-question-class calibration parameters (isotonic or Platt) are versioned artifacts alongside model manifests, referenced by calibration_version in the cache key (D6). Un-calibrated runs report calibrated_confidence = raw with calibration_version = "none", so the cache never conflates calibrated and uncalibrated results.

Amendment (2026-09-28, implemented as Phase 14). The first shipped scheme is temperature scaling, not isotonic or Platt: the benchmark run JSONs record winner probability + correctness only, which fits a temperature exactly (winner-vs-rest margin fit) and nothing else. Concretely: Calibration trait in the engine (calibrate(class, distribution) -> f64 + version()); IdentityCalibration = raw with version 0 ("none"); TemperatureCalibration from a validated JSON artifact (format_version 1, scheme "temperature", temperatures keyed by question kind, deny_unknown_fields, calibration_version ≥ 1 — 0 stays reserved for "none"). Artifact versions fold into cache keys via EngineIdentity, so adopting a refit invalidates cached decisions. Fitting is offline Python over benchmark runs (benchmarks/decision-model/runner/fit_calibration.py); the fitted artifacts for the D16 tier arms are committed under benchmarks/decision-model/results/calibration/ with measured before/after ECE in results/CALIBRATION.md. The embedding rung's fit is degenerate (no finite temperature helps; scores are ordering-only), so no artifact is shipped for it — the per-model opt-in model means the engine refuses to fake confidence where the data says none exists. Per-class artifacts beyond the question-kind keys (task-level classes) wait on the IR carrying class tags; the fitter already prints the per-class spread, and it is large (0.5–13 across suite classes on 4B).

Amendment (2026-09-28, relational solver): an artifact ships only if the fit does not worsen ECE — in-sample NLL improves on nearly any 1-parameter fit, so ECE is the shape check. The engine arm of record is now the relational solver's bimodal proof/delegate stack, whose global fit (T = 0.658) worsens ECE (0.094 → 0.097): no artifact ships, the arm keeps identity calibration, and the retired builtin-lexical-v1 artifact was removed with the bare-lexical stack it described. The fitter enforces the gate.

Amendment (2026-10-07, risk–coverage gate): ECE alone no longer gates an artifact, because calibration error and selective prediction measurably diverge (RESEARCH.md §15.1: a better-calibrated model can rank worse). The fit provenance carries optional aurc_before / aurc_after / accuracy_at_coverage (serde-defaulted — historical artifacts load unchanged), the fitter records them for every fit, and both the fitter's ship gate and the engine's load gate (TemperatureCalibration::from_artifact) refuse an artifact whose AURC worsens beyond fitter rounding slack (1e-9). For temperature scaling the gate is currently a witness, not a constraint — a monotone rescaling cannot reorder items, so aurc_after == aurc_before is the expected reading — and it becomes load-bearing with the first non-monotone scheme. The regenerated field-additive artifacts and the selective-prediction numbers of record live in benchmarks/decision-model/results/CALIBRATION.md (§ Risk–coverage evidence, 2026-10-07).

D16 — Decision-model picks: tier scheme (measured, Phase 13; amended ×8)

Decision (amended 2026-09-27, third pass — full-sweep frontier): the tier scheme gains a frontier tier and the interactive reference moves:

  • Frontier / verifier tier: MiMo-V2.6-Distill-Qwen-9B Q3_K_S (sha256 in the manifest) — 0.817 overall (1.00 / 0.93 / 0.525), ECE 0.048 — best calibrated measured, and the first arm over the 0.50 relational ceiling. p50 14.3 s, 4.26 GB, bit-deterministic. MiMo-V2.6 is MoE (its RL trainer's own notes say so), which is why 9B runs at ~2× 4B latency, not dense-9B ~2.3×. Slot: verifier stage and bulk/offline decisions where 14 s/decision fits the D9 budget — never the interactive path. Q3_K_M ties its accuracy (0.817) but loses calibration (0.081) and latency (18.4 s); IQ3_XXS trades to 0.783 at 10.6 s.
  • Reference (interactive): Qwen3.5-4B Q3_K_S — 0.800 (1.00 / 0.95 / 0.45), ECE 0.069, p50 6.8 s; UD-Q4_K_XL is the fastest 0.800 (4.9 s, ECE 0.074). This supersedes Qwen3.8-4B-Distill (0.767) on accuracy at the same latency class. Every ≥3-bit 4B quant measured is ≥ 0.775; the 2-bit cliff is 0.617 (UD-IQ2_XXS; 0.383–0.450 at 2B) — quants at or below 2 bits are never shippable for decisions.
  • Balanced: Qwen3.5-2B unchanged (0.725, best 2B-class ECE 0.062, relational 0.50, p50 1.7 s); its UD-Q6_K_XL variant is the accuracy-lead option (0.758, ECE 0.079, +25% latency) when a decision point needs it.
  • Fast: Qwen3.5-0.8B unchanged (0.650 @ 613 ms, ECE 0.074).
  • Measured out: every ≤350M decoder (Granite-350M 0.342, Falcon-90M 0.275/0.267, Gemma-270M 0.200, glm5.1-distill 0.250 — all below the 5.3 ms engine layer they would need to justify); gte-modernbert-base zero-shot (0.575, ties the engine blend, best relational 0.50 at 3.4 s — but ECE 0.330, unusable as a gate before D15 calibration) and Laya-421M (0.475) stay embedding-rung references, not decision arms.
  • Interface-mismatch row (not a quality rejection): Jev-Style-0.8B-Decision-v3 scores 0.217 / ECE 0.408 / chat 0.0 through this harness because its trained readout is per-option verdict slots (h·(w_yes − w_no) at each option's -> position, shipped group temperatures) — candidate-id token paths and JSON answers are outside its trained interface, and zero transfer was measured. A native verdict-slot readout is the #25 arm type. (The surrounding jev-style project remains the closest external analog to this codebase: a decision-gated PreToolUse guard, artifact-embedded group temperatures with in-artifact fit quality, automation-at-error-budget eval — reference designs for #40 and D15/#34.)
  • Latency findings: importance-matrix quants are not uniformly slower — at 9B, IQ3_XXS (10.6 s) beats Q3_K_S (14.3 s), inverting the 4B pattern (IQ3_XXS 15.7 s vs K_S 6.8 s there). K2-Horizon (fork without /v1/decision) screens chat-only: 7B 0.800 / 4B 0.700 / 1B 0.725 at 9.1 s sampled p50 — labeled non-comparable in the summary.

Second pass (2026-09-26 — Qwen3.8 distills): Qwen3.8-4B-Distill (empero-ai, Q4_K_M GGUF, sha256 dec96e8c…, see benchmarks/decision-model/results/models.manifest.json) is the reference model for the candidate-conditioned decision layer: 0.767 overall (1.00 metadata — perfect / 0.95 lexical-semantic — new crown / 0.35 relational), ECE 0.057 — best calibrated measured, p50 3757 ms, under the identical constrained-scoring conditions as every other arm (thecodacus/llama.cpp parallel-decision ad129b0, POST /v1/decision tree mode, CPU-only host). It strictly supersedes Gemma-3-4b-it (0.750, ECE 0.236 — worse on both axes; Gemma is now fully superseded, kept only as an arm in the record).

The pick is a tier scheme, because the leaders trade off:

  • Reference: Qwen3.8-4B-Distill — accuracy + calibration leader. This is the model D15 calibration is fit against first and the verifier tier uses.
  • Balanced alternative: Qwen3.5-2B (0.725, ECE 0.062, p50 1735 ms) — holds the relational crown (0.50 vs the 4B distill's 0.35; its one weak class), half the latency. Preferred where the residual stream is relational-heavy or the latency budget bites.
  • Fast tier: Qwen3.5-0.8B (0.650 @ 613 ms, ECE 0.074) — unchanged; neither new 0.8B distill comes close (0.567).

Rejected on measurement: Qwen3.8-2B-Distill (0.617 @ 1344 ms — below Qwen3.5-2B everywhere that matters), Qwen3.8-0.8B (0.567 @ 808 ms bf16 — dominated by Qwen3.5-0.8B on accuracy, latency, and calibration), MiniCPM5-1B, Llama-3.2-1B, Qwen2.5-0.5B (at or below the engine baseline). Provenance findings folded into the record: (a) both community "Qwen3.8-0.8B" repos ship bit-identical safetensors (sha a84cd623…) — one model, two packages, caught by manifest discipline (D14); (b) their uploads omit the MTP block declared in config — GGUF conversion requires llama.cpp's --no-mtp.

Amendment history: 2026-09-28 eighth pass (relational solver, PLAN Phase 15) — the zero-ML floor is re-tiered. The engine's default stack became the relational solver over the lexical classifier (exact proofs over extracted facts, delegating everything it cannot prove); the re-run engine arm on the byte-locked suite scores 0.683 blended (0.88 / 0.23 / 0.950) at 1.3 ms p50 (REPORT F22). Consequences: (a) the fast tier is undercut on both axes — Qwen3.5-0.8B (0.650 @ 613 ms, 537 MiB) is retained as the fast model tier for lexical-heavy residuals, but any deployment whose residual stream is metadata + relational structure should ride the engine floor instead: 0.683 @ 1.3 ms, zero MiB, zero download, bit-deterministic; (b) the 0.50 relational ceiling is now known to bind likelihoods, not proofs — MiMo-9B keeps the model-arm crown (0.525 @ 14.3 s), while the runtime proves 0.950 where models guess; (c) the calibration of record for the engine arm stays identity — the global temperature fit worsens ECE on the bimodal proof/delegate distribution (CALIBRATION.md), and the old builtin-lexical-v1 artifact was retired with the stack it described. Board stands at 49 runs. 2026-09-28 seventh pass (blockwise int4 arm) — no tier changes; the Q4_0-analog question is measured closed. ORT 1.30's MatMulNBits quantizer (block 32, asymmetric, 4-bit) on the gte fp32 graph yields a 226 MB build scoring 0.575 / ECE 0.330 — blended, per-class (0.47-0.75-0.50), and ECE identical to fp32 to the digit — at 815.7 ms/item vs 811.4: no speedup (REPORT F21). Blockwise 4-bit is therefore a free memory fallback (2.6× smaller at zero quality cost), not a speed play — the CPU MatMulNBits kernel does not beat fp32 GEMM at batch-1 encoder shapes; the fp32 runtime stands. Contrast F20: the int8 collapse was the scheme (per-channel dynamic), not quantization itself. q4f16 (int4 weights, fp16 activations) is untestable on this host — ORT's 4-bit op has no fp16-compute CPU path; that format belongs to QNN/CoreML-class EPs. Board stands at 48 runs. 2026-09-28 sixth pass (embedding-rung bake-off) — no tier changes; the embedding rung's runtime moves to ONNX fp32: the same gte-modernbert-base encoder on onnxruntime scores identically to the torch row to the digit (0.575 / ECE 0.330 / 0.45-0.78-0.50) at 811.4 ms/item vs 3374.8 — 4.2× faster on CPU (REPORT F20). Dynamic int8 is rejected (0.442 — metadata 0.45→0.28, relational 0.50→0.28 — for −17% latency); an fp16-weights export is unloadable by onnxruntime (mixed-dtype LayerNormalization from torch 2.14's exporter) and skipped; EmbeddingGemma-300M Q8_0 via llama.cpp (319 MiB, new llamacpp backend in run_embed.py) records 0.500 / ECE 0.256 / 634.9 ms — below the gte rung reference on accuracy, better calibrated, no tier. The board stands at 47 runs; gte (any runtime) remains embedding-rung reference only — ECE ≥ 0.330 keeps it behind the D15 calibration gate. 2026-09-28 fifth pass (ternary second pass) — no tier changes, no new completed runs; two ternary candidates resolved as measured negatives. Bonsai-8B (Q1_0, 1105 MiB; Bonsai-8B.gguf and Bonsai-8B-Q1_0.gguf are byte-identical artifacts under two names) loads and answers a trivial prompt in 5.4 s, then prefills at ≈1.5 s/token beyond a ~30-token knee (196 s @ 130 tokens, 405 s @ 260, >420 s @ ~520) — ≈26× Bonsai-4B per-token at matched length despite shipping the same Q1_0+F32 type set (verified from the GGUF headers); the 120-item suite projects to ≈9–13 h of pure prefill, so the arm is out of campaign budget (REPORT F18). Ternary-Bonsai-2-27B (PTQ1_0, 5.67 GiB, qwen3, multimodal per its mmproj files) is unloadable in the ad129b0 build: output.weight has invalid ggml type 143. should be in [0, 43) — prism-ml's packed type requires their own fork, and no other file in the repo is host-viable (F16 = 51 GB; PQ2_0 = the unreadable Ternary-8B family) (REPORT F19). Net: on-CPU ternary viability in this build is 4B-and-below; the #35 accelerator question now spans Q2_0_g64-at-8B, Q1_0-at-8B, and type-143 PTQ1_0-at-27B. 2026-09-28 fourth pass (extension) — no tier changes; three arms added. Bonsai-4B (ternary, 546 MiB) posts the board's best accuracy-per-byte (0.650 — the 0.8B tier's accuracy at a quarter of the size) but at 4B-class latency (4.86 s p50), so it wins no tier; recorded as a measured record and an accelerator-tier candidate, not a pick. LFM2.5-2.6B Q4_K_M (0.608 @ 2.55 s — first hybrid-conv architecture tested) is dominated by Qwen3.5-2B at the same latency; the DavidAU "X12 NEO MAX" merge (0.667 @ 8.35 s, best sub-3B lexical at 0.775) is latency-toxic and wins no tier. Ternary-Bonsai-8B is not measurable on the CPU host (g64 quant >10 CPU-hours per decision request; plain Q2_0/PQ2_0 tensors unreadable by the ad129b0 build; F16 exceeds host memory) — ternary-class weights are an accelerator question (feeds the #35 Vulkan A/B). 2026-09-27 third pass — tier scheme gains the MiMo-9B Q3_K_S frontier (first model over the relational ceiling), the interactive reference moves from Qwen3.8-4B-Distill to Qwen3.5-4B (K-quant or UD-Q4_K_XL), the 2-bit cliff and the 9B IQ-quant latency reversal are recorded, and Jev-Style-0.8B is entered as an interface-mismatch row. 2026-09-26 first pass — Qwen3.5-2B, moved from Gemma because a 3-item gap was noise while calibration (0.062 vs 0.236), relational (0.50 vs 0.45), half the parameters, and ~57% of the latency were not. 2026-09-25 original — gemma-3-4b-it (Q4_K_M GGUF, sha256 882e8d2d…), led every class (0.95 / 0.85 / 0.45, 0.750 overall) over the original five typed models (Qwen2.5-{0.5,1.5,3}B, Llama-3.2-1B; Qwen2.5-3B runner-up at 0.675).*

Serving mechanism is NOT the decision. The measurement went through llama.cpp's /v1/decision; the integration seam in this codebase remains the opencodifier-runtime InferenceBackend trait (D7, logits-only). The ONNX re-entry gate is D2. Any backend that can produce candidate-conditioned logits is admissible; the pick names the weights, not the server.

Conditions carried forward (blocking exposure, not the pick):

  • Raw winner probability is uncalibrated at every model (ECE 0.048–0.626 across the forty-four runs; even the best, MiMo-9B Q3_K_S at 0.048, has had no post-hoc calibration fitted). D15 calibration must be fit per class before any confidence leaves the runtime (§73 confirmed by measurement).
  • relational_compositional held at ≤ 0.50 for every arm until MiMo-9B Q3_K_S reached 0.525 at 14.3 s/decision; every interactive-tier pick scores 0.35–0.50 there — that class stays escalation/verifier territory for anything latency-bounded, which is precisely why a relational-weak interactive reference is tolerable.
  • The ladder holds: the lexical engine at 5.3 ms keeps metadata_match (0.88 vs 1.00 for the best model at ~700× the latency); a model call is only bought when cheaper layers abstain.
  • Single-shot, chat-JSON and decision-arm latencies are comparable on CPU (prefill-dominated); the decision arm earns its slot on determinism (bit-exact replay vs batch-composition-sensitive generation), exact candidate distributions, and 5–8× bulk throughput — not on single-shot wall clock. Note the arm's accuracy dominance is model-dependent: the Qwen3.8-2B distill is the first arm whose chat baseline beats its decision score (0.700 vs 0.617) — reasoning-by-writing distills lose under forced token-path commitment.

D17 — MCP surface: rmcp 2.2 stdio, six decision tools (2026-09-28)

opencodifier-mcp (PLAN Phase 10, §31/§55) serves the runtime to MCP hosts over stdio only — the transport is the local process's stdin/stdout, so the local-first posture is structural: there is no socket to bind and nothing remote to refuse (unlike HTTP's loopback gate, there is no flag that opens this surface outward).

  • SDK pin stays at rmcp =2.2 (D1). rmcp 3.x exists (major line, breaking API) and is not adopted; upgrading would be a new decision with its own migration evidence. Phase 15's tool set, verbatim: codify_decide, codify_batch, codify_graph, codify_validate, codify_verify, codify_explain. §31's two model-dependent tools (classify/score-shaped) wait for the model rungs, as planned.
  • Thin surface, one pipeline. Tools normalize through the schema adapters and execute through EngineHandle — the identical stack HTTP drives. The engine is sync (D5); the HTTP shell off-loads to blocking threads because a concurrent server must protect unrelated connections, while a stdio MCP session is strictly sequential, so MCP calls the engine inline. That is a deliberate asymmetry, not an inconsistency.
  • Batch bound. codify_batch accepts at most 16 requests (mcp.batch_too_large above it) and decides items independently: one item's refusal is that item's error envelope, never a failed batch. The bound is transport hygiene, not a pipeline limit.
  • Error discipline. Tool failures are tool-level results (is_error: true) carrying HTTP's exact envelope ({"error":{"code","message"}}) under the same stable codes; the crate's only own code is mcp.batch_too_large. Abstention is a successful result with the typed outcome — §73's rule survives the transport.
  • Shared surfaces, one definition each. The engine's graph-document shim is public (GraphDocument) and HTTP's private mirror is deleted; the execution-report JSON projection lives in the engine (opencodifier_engine::report::execution_json) and both the CLI's --trace and MCP codify_explain call it.
  • codify_graph exposes the active graph document (via the new EngineHandle::graph): the identity decisions are cached under, node count, parallelism, cache state, and the validated DAG itself. This is local introspection of a local runtime, consistent with §31's explainability stance; §32's deeper tool introspection remains future work.

D18 — Focused-question extraction: recall-oriented views + reverse escalation (2026-09-28)

PLAN Phase 16 (§45): a decision-model rung pays per token while every cheaper rung reads whole state. The engine may therefore decide a question on a per-question view of long state — but only under rules that make the view strictly safer than truncation:

  • Extraction is deterministic, model-free, and recall-oriented. Sentences split with delimiters attached (kept views are the original prose, byte for byte, in original order); BM25 over sentence documents with question text + candidate descriptions as the query; verbatim candidate-id mentions are unconditional evidence. Zero- evidence sentences are never selected, so the median view sits near the budget while every kept sentence earned its place. Token accounting is the engine's bytes-over-4 estimate — a budget, not a measurement (no tokenizer exists; D2).
  • Blind extraction declines; decisive sentences are never amputated. When no sentence shows positive evidence the view is the full state; when one sentence exceeds the whole budget it is kept whole. A view that saves nothing is the full state. Dropping the one decisive sentence is strictly worse than an over-long view, so recall beats the budget everywhere they conflict.
  • Reverse escalation, once, pre-gate. If extraction engaged and the focused top probability is below the request's min_confidence, or entropy trips the §19 ceiling (default off), the engine re-decides on the full state before any gate or verifier sees an answer. The fallback cannot produce a degraded accepted answer; its cost is one extra classify, and it is visible in the trace (focus_escalated) and the report counts (decided/engaged/ escalated).
  • Facts are never touched. Only the text view shrinks; structural facts pass through to every view. Hostile state text is content, not instruction — a view feeds a classifier and nothing else, and no policy/threshold/graph decision is ever read from state text (§73, unchanged).
  • Cache identity (D6 extension). The focus policy folds into the model-id component as |focused-v1@<budget_tokens>, so budget changes and focus on/off invalidate cached decisions without caller bookkeeping.
  • Byte-stability for existing consumers. Unfocused engines have no focus surface at all: trace keys appear only when a policy is configured, RunReport::focus is all zero without one, and execution_json inserts "focus" only when decided > 0.
  • Evidence (Phase 16). Long-suite A/B through the real binary (suite_long.json, ~3,681-token states): answer-identical to the full-state run on 120/120 items at --focus-budget 512, engaged views p50 93 tokens, 36 blind declines, 42 escalations (none answer-changing), ~2.5 ms/item extraction overhead at this scale (REPORT.md "Long-context A/B"). Measured limits: the A/B exercises benign dilution, not adversarial distractors; a distractor that out-scores the decisive sentence under BM25 remains the known failure mode, caught (not prevented) by escalation — recorded as the extractor's standing threat model.

D19 — /v1/graph/run: client-supplied graphs are ephemeral and content-addressed (2026-09-29)

§36 lists POST /v1/graph/run among the primary endpoints: a client submits a graph document plus a canonical request, and the runtime decides through that graph instead of the built-in pipeline. That is the one surface where the client controls pipeline structure, so it gets the tightest rules in the runtime:

  • Body and response. {"graph": <GraphDocument>, "request": <canonical request>}; the response is the standard native decision envelope plus an "identity" object reporting the identity the run actually executed under. Shape errors are schema.* (400); structural graph failures are the engine's own graph.* codes, identical to /v1/graph/validate. Abstention stays a 200 — §73's posture does not bend for ad-hoc graphs.
  • Content-addressed identity, never client-asserted. The scoped engine's graph_version is the first 8 bytes (big-endian) of SHA-256 over the graph's canonical serialization (version + node specs, serde field order). A client naming graph_version: 1 gets a hash instead; two runs of the same graph share one identity, and any node edit changes the hash. This keeps §73's rule (cache keys fold graph identity) true by construction: a decision made under graph A can never be served for graph B.
  • The shared serving cache is never consulted. The scoped engine is assembled fresh per request — ad-hoc graph runs are an evaluation/inspection surface, not the serving path — so any cache it has is created and dropped with the handle: a one-slot, one-second private cache that serves at most a repeated question inside one decide call. (The cache rejects a zero-capacity config by contract, so "never the serving cache" is expressed as throwaway assembly, not as a disabled flag.) Assembly is cheap (validation + rule bookkeeping; no model loading under the lexical posture), so per-request engines cost microseconds and no state survives the request.
  • What the client does not control. Calibration, parallelism caps, execution-time ceilings, and body limits are the host's; the scoped engine carries no rule pack and no verifier — graph nodes plus the built-in classifier are the whole pipeline. Node count is capped at limits.max_graph_nodes (128) before construction, and the DAG contract (ids, edges, cycles, single output) is enforced by the same DecisionGraph constructor /v1/graph/validate uses — never a weaker local copy.
  • Engine surface, one definition. EngineHandle::ephemeral(graph) is the only constructor for scoped runs; it derives the content-addressed identity and disables the shared cache internally. HTTP composes it with the ordinary decode/decide/encode path — no endpoint-local pipeline logic (D5 sync core; the async shell stays in the interface crate).
  • MCP parity deferred. MCP codify_graph remains introspection only; an ad-hoc execution tool would need the same identity rules and is future work, not an omission.

D20 — Decision Registry: definitions are content-hashed artifacts, requests stay canonical (2026-09-29)

§63 wants decision definitions to become reusable artifacts: a named, versioned document carrying the question shape and the policy, from which a runtime builds a real request. The registry is bookkeeping over artifacts; it is never a second decision path.

  • One type, one crate. DecisionDefinition lives in opencodifier-schema (registry.rs) next to the wire adapters: the document is a wire format, and instantiation goes through the same core constructors (ChoiceQuestion::new, DecisionPolicy's validating conversion) every other ingress uses — never a weaker copy. Fields: id (dotted, non-empty), version (u64, ≥ 1), question (type + text), candidates (a choice question's static candidate list, or {"dynamic": true} for caller-supplied ones), and an optional policy (absent = DecisionPolicy::default).
  • Identity is content, labels are labels. A definition's identity is the SHA-256 hex of its canonical serialization (content_hash()). id and version are labels for humans and tooling — naming a version does not make two different documents the same artifact, and editing a document changes its identity even if the labels do not move. This is D6's discipline applied one level up.
  • Definition identity never enters cache keys. The instantiated DecisionRequest is canonical and cache keys fold the request content plus engine identity (D6), nothing else. Two definitions that instantiate to byte-identical requests legitimately share a cached decision — the decision depends on the request, not on which artifact produced it. Provenance stays artifact-side; instantiation does not smuggle the definition id into RequestMetadata, where it would perturb keys.
  • Instantiation is total or refuses. A choice definition with dynamic candidates needs the caller's candidate list at instantiation; a static one refuses extra candidates rather than silently merging. Question text and candidates go through the same validating constructors as any wire request, so an invalid definition fails at load with the adapter's own schema.* codes — a registry cannot hold a definition the engine would refuse.
  • The registry itself is boring on purpose. Registry indexes definitions by id and rejects duplicate ids at construction — lookup, not discovery; no filesystem, no network, no reload. Loading documents from disk is the caller's job (CLI, tests, recipes), which keeps the crate sync and local-first.
  • Recipe, not ceremony. One recipe demonstrates the path: definition → instantiate → decide through a committed graph, with the response captured by the same procedure as every other recipe — the registry has to earn its place in the fleet, not ship as an unused library.

D21 — Retrieval and rerank are explicit, disclosed narrowing stages (2026-09-29)

PLANNING §24/§52 (embeddings), §53 (retrieval), §26/§54 (reranking) land as three graph node kinds — embedding, retrieve, rerank — plus two engine seams. The design keeps every §73 rule intact while making the spec's 50 → metadata → BM25/embedding → 10 → reranker → 3 → decision pipeline expressible as a plain DAG.

  • Three nodes, three honest jobs. embedding only annotates: it embeds the state text once per run and scores every surviving candidate description by cosine, recording the scores — it never removes or reorders. retrieve narrows: per choice question it keeps the top top_n candidates whose semantic score clears floor, and every dropped candidate is named in the trace with its score — never silent. rerank reorders: it rebuilds each choice question's candidate list in the reranker's score order — it never removes.
  • A floor that cannot starve. If the floor would eliminate every surviving candidate, retrieve keeps the top-1 and lets the confidence gate — not a narrowing artifact — decide the question's fate. Narrowing stages never manufacture an abstention.
  • The Reranker seam is a trait with two implementations. Reranker::rerank(query, candidates) → Vec<ScoredCandidate> exactly as §26 sketches it. LexicalReranker is the zero-ML default (BM25 over candidate descriptions, the same primitive as the lexical node); EmbeddingReranker is cosine over an EmbeddingBackend (PLANNING §24). The node spec names its reranker ("lexical" | "embedding"); the engine never silently substitutes one for the other.
  • Missing backend ⇒ refuse at assembly, never at request time. The handle carries the optional embedding backend; a graph naming embedding/retrieve/embedding-rerank nodes on a handle without one fails assembly with engine.missing_backend before a socket opens. The zero-ML base binary stays useful with no model — by refusing graphs that need one, not by degrading them into lies.
  • Safe mode refuses retrieval pruning, same rule as the prune knob. retrieve is semantic pruning with extra disclosure. Safe mode (the default) rejects any graph containing a retrieve node at assembly, exactly as it rejects the lexical_prune_limit knob; the opt-out is explicit (with_safe_mode(false)), per-engine, and never per-request. Hostile input cannot switch it.
  • The embedding backend is part of engine identity. EngineIdentity gains embedding_model (default "none"), folded into every cache key by the single normative CacheKeyBuilder. An embedding swap or upgrade invalidates cached decisions exactly as a classifier swap does (§73, §64) — semantic scores feed narrowing, so they are decision inputs and get identity like any other.
  • Node validation is additive and strict. retrieve requires top_n ≥ 1; floor only means something on retrieve; reranker only on rerank; unknown knobs on other kinds are ignored by serde but rejected by the validator, because a graph that carries dead knobs is a graph the author did not mean.

D22 — The optimizer only removes the unobservable (2026-09-29)

PLANNING §51 wants a decision compiler. V1.1 ships the two passes whose correctness is provable over the DAG's actual semantics — and refuses the passes whose "optimizations" would be guesses.

  • Equivalence is over decisions, not traces. Two graphs are equivalent when every request produces identical answers, outcome, and confidence. Traces legitimately differ (fewer nodes executed); they are instrumentation, not output. Everything below is measured against that definition, fleet-wide: every recipe graph decides byte-identically (answers, outcome, confidence, identity's model fields) optimized and unoptimized.
  • Pass 1 — dead-node elimination is reachability from output, minus the cache. A node no output-reachable path contains can never contribute to a response — with one exception: cache is observable across requests (it probes/reports the key and a later request reads the same cache), so cache nodes are never eliminated, however unreachable. Everything else is a pure function of its dependencies.
  • Pass 2 — CSE over identical pure specs. Two nodes with equal (kind, depends_on, knobs) in the pure set (normalize, rule, filter, lexical, embedding, retrieve, rerank, choice, boolean, score, branch) compute the same value against the same wave snapshot, so dependents re-point to the first and the duplicate disappears. cache is excluded (side-effecting), threshold and output excluded (unique, response-shaping). Duplicate entries in a depends_on list are dropped as part of normalization.
  • What V1.1 refuses, and why. Constant folding has no constants worth folding (rule conditions read hostile input; folding one input path specializes the graph to a benchmark). Early exit and parallelization hints change wave structure and therefore traces and timing contracts for gains the per-stage budgets (D9) have not asked for. Candidate pruning is not a graph transform at all — it is narrowing, and D21 already owns its disclosure rules. The optimizer that ships is small enough to prove.
  • Surface. opencodifier_engine::optimize(graph) → DecisionGraph (pure, returns a fresh graph; graph_version is preserved — D6 keys on the author's version, and optimization must not silently change cache identity). Equivalence proofs: per-pass unit tests plus a fleet-wide test that optimizes every committed recipe graph and asserts identical decisions.

D23 — The WASM boundary is the zero-ML decision runtime (2026-09-29)

PLANNING §57 asks for opencodifier-wasm (CPU/SIMD/threads/WebGPU) with a browser demo; §68 fixes the security posture. V1.1 lands the boundary, not the model path:

  • What compiles to wasm32-unknown-unknown: core + engine + schema (and runtime's traits). These are sync, allocation-only, IO-free crates — verified by cargo check --target wasm32-unknown-unknown, not assumed. The WASM crate depends on nothing else: no tokio, no axum, no ort, no filesystem, no networking, no environment access, no storage API of any kind (§68's IndexedDB clause is satisfied vacuously until a storage adapter exists, and the crate is the only place one may ever live).
  • The exposed engine is the base posture. WasmEngine assembles EngineHandle::lexical over the default pipeline — relational solver over BM25, exactly the zero-ML stack the native base binary ships. Wire formats go through the schema native adapter only; the browser never sees IR internals unvalidated.
  • The model rung stays native for now — stated, not hidden. §57's WebGPU/ONNX-Web path would put ort in the browser; that is a runtime choice (D2 territory) with its own supply-chain and integrity story (§68's "model downloads explicit and integrity checked"). No browser model path ships until that record exists. The WASM build's usefulness does not depend on it: deterministic decisions, rules, narrowing, and traces run fully client-side.
  • Security posture is structural, not promised (§68). No dynamic native code: the artifact is pure wasm, no -sys crates, no JS shims beyond wasm-bindgen's glue. No telemetry: there is no output channel. All input is hostile: every entry point decodes through the validating schema adapters and returns typed errors; nothing from a request can touch policy, thresholds, graph structure, or paths (PLANNING §73 carries over unchanged).
  • Verification without a browser. The artifact is built with wasm-pack --target nodejs and exercised in Node — decide, validate, hostile-input refusals — because the same wasm module the browser loads is the module the harness runs. The browser demo (§57's drag/drop page) is presentation on top of the exact artifact the Node harness proves; it stays out of V1.1 and rides the distribution phase (§58) rather than blocking the runtime.
  • The platform seam the Node harness forced into the open. The first artifact trapped: std::time::Instant::now() has no source on wasm32-unknown-unknown (a wasm module reads the host's monotonic clock), and std::thread::spawn is unavailable there. Both are now declared seams, not runtime surprises: clock.rs re-exports std::time::Instant natively and the web-time reading on wasm32 (target-gated dependency — the native tree gains nothing), and the executor's parallel-wave path is compile-time gated off on wasm32, so RunReport::parallel_waves is 0 there by construction. Deadline math, cache TTLs, and the sequential wave path are the same code on every target; only the reading and the thread count are the platform's.

D24 — The distribution matrix is what this host can honestly verify (2026-09-29)

PLANNING §58 asks for cross-platform release distribution. The matrix is set by verification honesty, not aspiration — every column names how it is proven, and nothing ships a claim its evidence cannot carry:

  • linux-x86_64-gnu — the platform of record: release-built, run-tested, e2e-proven (30/30) on this host; GitForge releases carry it today.
  • windows-x86_64-gnu — the full workspace (all ten crates incl. opencodifier-wasm) compiles clean for x86_64-pc-windows-gnu with the mingw linker present. Linked (2026-10-02, 2m44s at -j4 alongside the measurement chain): opencodifier.exe (PE32+ console, x86-64) and opencodifier_wasm.dll (cdylib), zero warnings. Verification level is link success + file PE magic + a stated not-executed-here caveat — and that is this host's terminal level for the target: no wine and no Windows machine exists here, so the smoke run is not "pending a quiet window", it is structurally out of scope for this host (a Windows box or CI runner with wine would be the venue).
  • linux-aarch64 — aarch64-unknown-linux-{gnu,musl} targets installed, cargo-zigbuild/zig present for linking; both linked 2026-10-01 (the gnu one dynamically, the musl one static). A smoke run is structurally out of scope on this host for the same reason as windows: no qemu-user (and no arm hardware) exists here — an arm box or a qemu-capable runner would be the venue.
  • darwin x86_64/aarch64 — rust targets installed; cargo-zigbuild can link Mach-O without an Apple SDK. Verification is link success + file magic only: no macOS host runs it here, no codesigning or notarization exists, and the artifact states that in its release notes rather than implying support.
  • Package-manager publication (crates.io, Homebrew, winget) is an outward action, never an automatic one. Each needs an account, credentials outside the repo, and an explicit user go — the release flow of record is GitForge releases; external registries are opt-in follow-ups, listed in PLAN 18j's remainder.
  • The WASM artifact rides releases as wasm-pack output (built by just check-wasm, gitignored in-tree): pkg/ attached when a release is cut, never committed.

Rule the matrix enforces: a target either has named verification evidence or is named as pending — the release page never says "supported" where the truth is "compiles".

Status 2026-10-01 — the staged cells now carry their named evidence. The overnight cross-build run (recorded matrix: runs/crossbuild__matrix.md, rustc 1.98.1 / zig 0.16.0, from the repo root) links all four remaining targets: aarch64-unknown-linux-gnu (dynamically linked ELF aarch64, 4.4 MB), aarch64-unknown-linux-musl (statically linked ELF aarch64), aarch64-apple-darwin and x86_64-apple-darwin (Mach-O arm64 / x86_64). Per this record, that is link + file-magic verification only — no foreign-arch execution is claimed (no qemu on the host; no macOS host exists here). windows-x86_64-gnu joined the linked set 2026-10-02 (opencodifier.exe PE32+ + opencodifier_wasm.dll, file-magic verified, not executed — no wine/Windows on this host, so smoke stays structurally out of scope here, not merely quiet-window pending). What remains open in the matrix is unchanged: registry publication is an explicit user go, and native smoke exists only for linux-x86_64.

Status 2026-10-07 — the release recipe widened to nine legs, three of them new to this record. just release-build <target> now stages and attests every cell of the matrix (the recipe's preflight fails with the missing tool's name when a leg lacks its toolchain):

  • android-aarch64 (aarch64-linux-android) — the CLI binary, linked by cargo-zigbuild; verification level: link + file magic (no Android host or emulator here; execution is structurally out of scope, same rule as darwin).
  • ios-aarch64 (aarch64-apple-ios) — the opencodifier-ffi staticlib, not a CLI binary: with no Apple SDK on this host no linked Mach-O image can be produced or codesigned, and pretending otherwise would be the "compiles = supported" lie this record bans. The library is the honest artifact — an ar archive whose members are arm64 Mach-O objects a host app links against the C ABI (include/ocffi.h), which IS producible and attestable without an SDK.
  • wasm32-unknown-unknown — formalized from "pkg/ rides the release" to a versioned dual-glue bundle (opencodifier-<v>-wasm32-unknown-unknown.tar.gz: pkg-web/ + pkg-nodejs/ from wasm-pack --release), gzip-magic attested.

Supporting machinery, all in this commit: scripts/generate_attestation.py grew per-target artifact contracts (container + arch + static/member probes for PE32+, Mach-O, ar, and gzip — an unmapped target refuses to attest rather than shipping unverified); just release-checksums writes dist/sha256sums.txt; opencodifier-ffi is the new staticlib crate (C-ABI JSON boundary over opencodifier_engine::wire, the same module the WASM binding calls — one wire contract, every transport).

D25 — The escalation ladder is engine-internal per-node policy, never IR (2026-09-30)

The fusion study (F23: engine→gte→2B blend 0.867 vs 0.725 best single) bounds what a confidence-gated ladder can score, and the rungs' confidences are not commensurable — a relational proof is p = 1.0 by construction, the embedding rung's scores are ordering-only (D15 finding 3: the probability fit is degenerate; the measured gate is min_margin ≈ 0.018, results/embed-margin-study.md), and the model rung carries a fitted temperature. One request-level policy cannot gate all of them. The wiring decision:

  • LadderPolicy composes the existing gate type; the IR does not move. Engine config gains optional per-node / per-kind DecisionPolicy overrides (opencodifier-engine/src/ladder.rs), resolved at the existing confidence gate: per_node[id] → per_kind[kind] → request policy. No wire change, no schema change — pub(crate)-level plumbing only (QuestionDecision::decided_by records which node decided, so the threshold node re-resolves the same rung).
  • The cache stores completed (gated) responses, so the stale-gate hazard is real; the protection is identity: a configured ladder decorates the engine's model id as |ladder-v1@<id> (same rule as focused extraction, D6) and rides every cache key. A changed ladder re-keys; it never re-gates old responses. Assembly refuses a non-empty ladder whose id is empty or "none" — an anonymous ladder is a correctness bug, not a style choice.
  • Empty ladder is byte-identical to no ladder — no decoration, no policy_source trace facts, locked wire fixtures. The ladder ships opt-in (F24: gates inherit their rung's calibration; per-domain calibration precedes a default-on ladder).
  • Traces explain the gate: when an override fires, the decide and threshold entries record policy_source (node:<id> / kind:<name>) — the no-hidden-thresholds rule applies to the ladder like every other gate.
  • NodeSpec::threshold remains unwired (validated and fingerprinted since introduction, never consumed by the executor). The ladder composes alongside it; wiring the graph scalar to the gate is a separate, deliberate change with its own record.

Amendment (2026-10-01 — per-rung calibration and shipped profiles). The ladder's remaining D25 scope is wired and the profile documents ship:

  • Per-rung calibration. LadderPolicy carries calibrations: BTreeMap<String, Arc<dyn Calibration>> keyed by the same node:<id> / kind:<name> strings the trace records; the executor resolves it beside the policy and a rung calibration replaces the engine-level calibration for that rung's questions (None = unchanged, byte-identical). The trace records calibration_source when one fires. A calibration-only ladder is non-empty — validate() demands the identity for it too, so an artifact swap can never re-gate decisions under single-policy cache keys.
  • ProofAwareCalibration (CALIBRATION finding 2, no artifact shipped): exact proofs arrive as single-entry distributions at p = 1.0 and pass through raw; multi-entry distributions delegate to the inner calibration. Its version() is the inner version — safe because the wrapper only changes semantics inside a ladder whose id decorates the cache identity; bump the ladder id when wrapper behavior changes, exactly like an artifact swap.
  • Profiles are data. LadderProfile (serde, deny_unknown_fields) loads a JSON document — per-kind/per-node policies through DecisionPolicy's validated mirror, rung calibrations as D15 artifacts with an optional proof_aware wrapper — and refuses an empty profile. The CLI surfaces it as --ladder <PATH> on decide, serve, and mcp serve through the one shared runtime::engine_config, so the three interfaces cannot drift. HTTP config surfacing stays deferred: no engine-configuration surface exists on /v1 yet (D12), and a flag that silently re-gates a running server would need its own record.
  • Shipped profiles (ladders/, kept loadable by test): fusion-v1 — rule rung accepts proofs at 1.0; classifier kinds run the measured accept-on-margin shape (min_confidence 0.0 + min_margin 0.0183, results/embed-margin-study.md — the gate cascade is demote-only, so accept-on-margin is expressed by a probability gate that never fires and a margin floor that demotes near-ties). proofs-only-v1 — only proofs accept outright. Ladders remain opt-in (F24: per-domain calibration precedes any default-on ladder).

Amendment (2026-10-08 — the fitted Boolean boundary rides the same seam). RESEARCH §15.6 item 2 measured the largest single lever in the sweep: a Boolean verdict taken at the hard argmax is the wrong operating point for skewed tasks (tuned thresholds moved F1 0.499 → 0.748 on UNFAIR-ToS, 0.243 → 0.353 on GoEmotions; median tuned threshold 0.86). The boundary is policy, not code:

  • DecisionPolicy::boolean_threshold: Option<f64> — the question is answered true when p_true >= threshold. Validated strictly inside (0, 1); None — the default — is byte-identical everywhere (serialization omits it, so canonical forms, cache keys, and wire fixtures do not move). It composes through the existing RawDecisionPolicy mirror, so profile documents carry it per-kind like any other gate override.
  • The executor never rewrites the distribution. Agreement with the argmax passes through untouched. A flip keeps the model's measured distribution exactly as it is — no valid binary distribution can put a minority verdict on top — and instead the answer describes the verdict actually given: value follows the boundary, probability is the verdict's own mass (e.g. answering false at p_true = 0.6 means 0.4), and calibrated confidence maps that mass through the rung's calibration via Calibration::calibrate_probability (new provided trait method, identity default, D15-consistent; TemperatureCalibration applies the same per-entry rescale it applies to the top; ProofAwareCalibration delegates). The gate therefore reads the honest confidence: a threshold-overruled verdict at minority mass routes to verify/abstain unless the rung's own gates accept it. Statistical dimensions (entropy, margin, OOD, top probability) stay the distribution's — they describe the evidence, not the verdict.
  • Traces disclose both facts: the deciding node records boolean_threshold whenever a boundary is configured, and boolean_flipped = true when it overruled the argmax. Absent otherwise — no hidden thresholds, no noise on unconfigured runs.
  • The offline fitter is opencodifier ladder fit-boolean. opencodifier-engine/src/ladder_fit.rs sweeps 0.50..=0.99 in 0.01 steps, scores F1 on the true class, breaks ties toward the smallest threshold, and emits the profile document (id + per_kind.boolean) composed over a base policy. id empty or "none" is refused at emit (the id decorates every cache key). Every emitted document round-trips through the real LadderProfile loader in tests, so a fitted profile can never drift from what --ladder accepts. Evidence is JSONL {"p_true": f64, "label": bool}; the grid uses integer-then-divide arithmetic so emitted boundaries are clean decimals an operator can diff across fits.

Amendment (2026-10-08 — the elicited-abstain candidate rides the same seam). RESEARCH §15.6 item 3: an elected "none of these" was right in 17 of 18 research cases — a rung that cannot say "the state does not decide this" is forced to guess, and the guess ships with high confidence (E1-C measured relational mean top-prob 0.917 against 0.610 accuracy). The escape hatch is policy, not code:

  • DecisionPolicy::abstain_candidate: Option<AbstainCandidate> — a candidate id + description pair. The id marks the abstain candidate: prepare_question appends the synthetic candidate last (after rerank sort) to every choice question of a policy that carries one, unless a declared candidate already owns the id — then that declared candidate takes the abstain role and nothing is appended. Construction refuses an empty id, the reserved id "none", and an empty description; the builder re-validates.
  • Election is a terminal abstention. When the top answer is the marked candidate, the outcome is Abstain regardless of confidence — the gate cascade and the escalation walk's outcome re-derivation both defer to it (a 0.99-confidence "none of these" is not re-derived into an acceptance). The answer is withheld from the response, so the synthetic id can never leak into an accepted answer set.
  • Traces disclose both facts on the deciding node: abstain_candidate whenever a candidate rides a choice question, abstain_elicited = true only when elected. Absent otherwise.
  • Default byte-identity. None — the default — serializes as absent (canonical forms, cache keys, and wire fixtures do not move), appends nothing, records no facts, and leaves every decision byte-identical to the pre-field engine (integration-tested).
  • Handoff note: an appended candidate breaks the lexical build-once handoff (D28) — the handing index has no score for the synthetic — so the classifier builds its own index in that case (bit-identical either way).

D26 — The model rung is a prompt-shaped Classifier over llama.cpp, not a tensor backend (2026-10-02)

The escalation ladder (D25) has one missing rung: the decision model the measured board says is worth escalating to (F23: the simulated engine→gte→2B blend scores 0.867 @ 753 ms vs 0.725 best single). The serving reality on this host is the llama.cpp parallel-decision fork — its native verdict-slot readout is the only interface that ever scored the Jev-Style weights honestly (F26: 0.8083 native vs 0.217 tree-mode mismatch, F10), and ONNX Runtime is not the path (F25: ≥9× prefill-bound latency; no Vulkan EP exists to fix it). The wiring decision:

  • The rung implements Classifier, not InferenceBackend. InferenceBackend is the tensor-in/tensor-out contract of the ONNX serving path (D7: named DenseTensors). The fork's readout is prompt-shaped — a rendered candidate-conditioned prompt in, verdict-slot logprobs out. Forcing one shape onto the other would fabricate tensors and re-create the F10 interface mismatch this board spent a phase correcting. LlamaDecisionClassifier implements opencodifier_engine::Classifier (decide → Distribution) and assembles through EngineHandle::new / with_ladder as decider or verifier — zero engine changes, the same seam every other rung uses.
  • Rust owns the decision math. The server returns logprobs; the softmax over the verdict slot and any calibration happen in Rust f64 (D7), and confidence is calibrated through the existing D15 seam before any gate reads it. Server-reported probabilities are never exposed raw.
  • One crate, one feature, one new dependency. The client lives in opencodifier-model behind a llamacpp feature; the HTTP client is ureq (sync, default-features = false, json only) — blocking matches the engine's sync core (D5), and a leaf crate gains no async runtime. Plain HTTP, deliberately: the endpoint is loopback by the bind rule, and every Rust TLS backend (ring, aws-lc-rs) drags in either an OpenSSL-derived license outside deny.toml's allowlist or a C toolchain build for a hop TLS cannot help. Without the feature the default build is dependency-identical; with it, cargo-deny licenses and advisories gate the addition like any other.
  • No live server in the test suite. A minimal Transport trait (JSON in, JSON out) is the injection seam: unit tests script verdict-slot responses, timeouts, malformed payloads, and non-200s through a mock. Real-server integration is opt-in against a loopback endpoint (the bind rule — a default must never point at a remote host) and skips when absent.
  • Determinism is a contract, not a hope. Sampling is pinned greedy server-side; model_id() composes build + GGUF + readout config and feeds cache keys like any classifier's (D6); timeouts ride the engine's Deadline seam; retries are bounded and explicit, never ambient. Double-replay of a suite arm must be bit-identical before any number is of record.
  • Weights never ship (D14). The backend addresses a user-provided llama-server; no GGUF in the repository, no download-at-runtime, no vendored model artifacts.

Acceptance is the F23 target the ladder has been simulating: blended accuracy ≥ 0.80 at ≤ 1 s mean on the locked suite, measured in-process through the real engine, not through the benchmark harness's post-hoc blend.

D27 — Cross-rung escalation: a configured rung list, fired only by the gate (2026-10-02)

D25 gave every rung its own gate; D26 gave the ladder its model rung. What the F23 cascade actually does is still missing: when a rung's gate does not accept, the question moves to the next rung. Today a non-accepting outcome dead-ends in Verify/Abstain against a single optional verifier. The decision:

  • Fallbacks are an engine-level ordered rung list, not graph nodes. Rung { classifier, calibration, policy } (all optional beyond the classifier) lives in ladder.rs because it is a ladder rung — the same escalation vocabulary, now with an ordered tail. The graph stays a declarative DAG of pipeline stages; model routing is engine configuration, and no IR or wire shape changes. Engines assemble through DecisionEngine::new_with_rungs / EngineHandle::with_rungs; every existing constructor passes an empty list and is unaffected.
  • The gate is the only trigger — never two classifiers on a happy path. At the threshold node the primary rung is gated exactly as today. Only a non-Accept outcome (Verify or Abstain — both mean "this rung could not decide") fires the next rung, which is gated by its own policy when it carries one, else the deciding node's ladder-resolved policy. The list walks until a rung accepts or is exhausted; the last rung's outcome stands and the existing verifier cascade (agree → Verified, disagree → Abstain) applies to the final distribution. Abstention at the end of the list remains a successful outcome, never an error.
  • Rung overrides are honest about scope. A rung's calibration replaces the engine-level calibration for its own distribution (the D15 seam, per rung, as B4 did for gates); a rung's policy replaces the node-resolved policy the same way. Fallbacks decide on the full state over the already-narrowed question — the same inputs the verifier gets today — and their distributions are clipped to the surviving candidate set like every other classifier's.
  • Cache identity carries the rung list. A non-empty list decorates the model id |rungs-v1@<model>@<cal-ver>+… in list order, so a changed composition (model swap, calibration refit, list reorder) re-keys every cached decision mechanically. A rung's policy has no version of its own: changing one is an artifact change under the same discipline D25 set for calibrations — express it by bumping the ladder id (or altering the composition the decoration already sees).
  • The trace explains the walk. A question that escalated gains rungs_fired and a rung_chain fact naming each rung's model, top key, and gate outcome in order — the deterministic-explainability rule (§71) applied to routing, no chain-of-thought involved. An engine without fallbacks emits byte-identical traces, which the fixture suite asserts.
  • Confidence-gated, budget-aware. Each rung firing passes the wave guard (deadline + cancellation) before the call, so a rung walk cannot outrun the request's budget.

D28 — The OOD channel gains a policy-selected input-likeness mode (2026-10-02)

Measured motivation (REPORT, JevBench engine row; RESEARCH §9.1): the zero-ML ladder admits 81% of adversarial items with a 0.377-accuracy rung, and a 3-rung fusion tops out below its best single arm — because the OOD channel feeding ood_ceiling is normalized answer entropy, which measures the shape of the distribution, not whether the input resembles anything the rung was calibrated on. The policy seam (Policy::with_ood_ceiling, §19) has existed since Phase 14; it has been receiving the wrong signal. The decision:

  • Two deterministic OOD modes, selected by policy, never fused. OodMode::AnswerEntropy (the default — today's behavior, byte-identical) and OodMode::LexicalBand. In lexical-band mode the OOD score is 1 − lexical_coverage, where coverage is the fraction of non-stop query tokens (the closed BM25 stop-word list, same tokenizer) present in the best-scoring document's term set — computable from the index the rung already builds, zero new inference, fully deterministic. Answer-entropy stays the fallback wherever a rung exposes no lexical evidence (relational proofs, the model rung), so every rung has a defined OOD value in both modes.
  • No-evidence is maximal OOD. A query whose informative tokens are all stop words yields coverage 0 → OOD 1. A lexical rung that cannot lexically ground its input must escalate, not trust a softmax over nothing — the §73 posture, made mechanical.
  • Policy selects; the ladder composes. DecisionPolicy gains ood_mode (serde default answer_entropy; old payloads deserialize unchanged), so the D25/D27 machinery overrides it per node, per kind, and per fallback rung like every other gate. The gate relationship check is unchanged (mode is orthogonal to abstain_below <= verify_below <= min_confidence). Cache identity is carried by the existing ladder decoration, and a policy change re-keys mechanically through it.
  • Raw lexical evidence leaves the classifier through the deciding path only. Classifier::decide_extended(state, question, index) returns (Distribution, Option<f64>) — the coverage of the best document — with a default impl delegating to decide and returning None. The index parameter is the build-once handoff (task #80): the executor may pass the narrowing node's already-built index for the identical candidate set; classifiers remain free to ignore it and build their own (bit-identical either way — same documents, same index, same scores).
  • What this is not. Not a trained density model, not a learned projector — a deterministic lexical grounding signal, the input-unlikeness evidence available before any model exists (the same doctrine the entropy placeholder's own comment states). It also deliberately does not change what ood_score means under the default mode: existing traces, artifacts, and calibration cells remain valid; only policies that opt into lexical_band get the new semantics, and they get them spelled out here.

D29 — First-party integrations are adapters over the public surfaces, never a privileged stack (2026-10-02)

User directive (2026-10-02): OpenCodifier will integrate with Amortyx and the other in-house services "for full stack and compatibility … but we don't want to force people to use one stack, workflow, pipeline, or another."

  • The public surfaces are the only surfaces. Everything an in-house consumer can do — HTTP (/v1/decide, /v1/batch, /v1/graph/*, /v1/validate, /v1/models, /v1/capabilities), MCP (the six codify_* tools), the CLI, the Rust crates, the WASM runtime — is what any third party gets. A first-party integration may not take a back door: no in-process linkage beyond the public crate layering, no endpoint that only Amortyx/Control-Center can call, no feature that ships only through a first-party path.
  • First-party consumers are examples, not requirements. The default posture stays exactly what PLANNING.md §1 states: fully local, offline, no accounts. Installing Amortyx, the harness, or any sibling service is never a prerequisite for any OpenCodifier capability, and no OpenCodifier capability may detect or prefer a first-party consumer.
  • Each side keeps its own responsibilities (INTEGRATION_AMORTYX.md §1, unchanged): OpenCodifier makes semantic decisions; Amortyx makes economic/operational ones; neither absorbs the other. The Jev adapter doctrine generalizes: compatibility with any ecosystem — Jev/System-One, Fastino-style systemone callers, OpenAI or Anthropic clients — is an adapter mode over the IR, never the identity of the runtime.
  • Failure posture stays degrade-to-consumer. A first-party consumer must treat the decision runtime as optional: connection refused, timeout, or abstain falls back to that consumer's own prior behavior (the INTEGRATION_AMORTYX.md §4 rule). No consumer's availability may depend on the decision runtime's availability.
  • Docs follow the same rule. docs/INTEGRATIONS.md documents third-party integration paths as the primary story; first-party integrations appear in it as worked examples of the same public surfaces, pointing at their own design docs of record.

D30 — Wire-format selection over HTTP is an explicit header, native by default (2026-10-02)

Phase 19a. The OpenAI/Anthropic/Jev adapters were reachable only through the CLI --format flag and as library types; every HTTP route decoded through Native. Adopters with existing OpenAI-, Anthropic-, or Jev-shaped tooling had to shell out to the CLI to be understood.

  • Selection is the x-opencodifier-format request header, valued with the adapters' own stable name()s (native | openai | anthropic | jev). Not an envelope field: a drop-in OpenAI or Anthropic client posts its own body shape and cannot be asked to wrap it. Not content negotiation: Content-Type describes the media type, not the decision-runtime projection, and sniffing a body to pick a decoder is exactly the guesswork this surface refuses. Header absent ⇒ Native, so every pre-D30 caller — and every recorded trace, benchmark, and fixture — is byte-identical.
  • Decode and encode are symmetric per request. The same adapter that decodes the request projects the response (/v1/decide, and every /v1/batch item), so an OpenAI-shaped client gets an OpenAI-shaped answer and never parses our native envelope to reach its data. /v1/validate decodes through the selected adapter but keeps the runtime's own verdict shape — a preflight has no external shape to preserve. The graph routes stay native-only: a graph document is a native construct with no external analog.
  • Errors keep one contract. The {"error": {"code", "message"}} envelope is the native one on every route regardless of the selected format. A client integrates against exactly one error vocabulary; projecting errors into vendor shapes would fork that vocabulary per format for no capability.
  • An unknown format name is a refusal, never a guess: 400 schema.invalid_value naming the supported set. Header matching is case-insensitive on the value; the adapters remain stateless value objects built per request, so no server state grows.
  • Cache semantics are unchanged by construction: decisions are keyed on the canonical IR after decode, so the same semantic request arriving as native and as openai shares one cache entry — the format is transport, not identity.
  • Fidelity stays the adapter's own documented posture (PLANNING §7/§42): where a format cannot express an IR feature the adapter refuses or discloses — the HTTP layer adds selection, not silent conversion. /v1/systemone still does not exist and is still not claimed (Phase 19b decides whether a Jev-shaped route earns its place under these same rules).

D31 — No dedicated Jev-shaped route; the format header is the integration path (2026-10-02)

Phase 19b, closing the question D30 left open. The Jev/System-One ecosystem (and Fastino's GLiDE demo) convention is a /v1/systemone path. Does a route by that name earn its place?

  • No. D30's x-opencodifier-format: jev header already carries the entire capability: the same adapter, the same decode/encode symmetry, the same cache identity. A /v1/systemone route would be a byte-for-byte alias of POST /v1/decide with the header pre-set — new surface, zero new capability, a second URL to document, version, and keep honest.
  • Identity doctrine (CLAUDE.md) is the tiebreaker: OpenCodifier is not a Jev clone — Jev/System-One compatibility is one adapter mode, not the identity. Promoting a Jev convention to a top-level route inverts that: it advertises the adapter mode as the product's shape. Headers compose; brands should not.
  • Revisit condition, stated up front: a concrete consumer that cannot set a request header (a hard-coded ecosystem client with no configuration surface) changes the calculus. Then the alias route ships as pure dispatch to the decide handler with the Jev adapter selected — no separate pipeline, no separate error contract, no separate cache namespace. Until a named consumer exists, the route stays unshipped and /v1/systemone stays unclaimed.
  • Jev-shaped callers are served today: POST /v1/decide with x-opencodifier-format: jev (HTTP), opencodifier decide --format jev (CLI), Jev adapter in-process (library). This is documented as the integration path in INTEGRATIONS.md §2.4, not a workaround.

D32 — The request execution ceiling follows the rung's measured tail: 10 s → 120 s (2026-10-05)

  • What changed: Limits::default().max_execution_time (the IR's per-request wall-clock ceiling and the schema's validation reference) is 120 s, not 10 s. A request may declare any deadline at or under the ceiling and never above it; the engine still runs at min(request, server config), so nothing about enforcement moves.
  • Why: the ceiling was calibrated before the model rung existed. Its measured CPU tail — a 15 KB long-policy state over a 5-candidate parallel readout — is ~22 s, so every long-document escalation died as schema.limit_exceeded: 120 > 10 before any model token was scored. Found by the JevBench full-system runs (task #105): the proofs-only ladder aborted twice on exactly this (first as engine.timeout under the old adapter's 10 s payload, then as a hard rejection when the adapter declared more than the IR allowed).
  • What it is not: not a loosening of enforcement. The hostile- input posture is unchanged — the ceiling is still finite, still validated at the wire, still min-ed with the server's own config. D9 per-stage performance budgets are unaffected (they measure engine stages, not request deadlines).
  • Revisit condition: if a rung's measured tail ever exceeds the ceiling, the ceiling moves again — to the measured tail, not to a round number.

D33 — The Kai-0.6B-ONNX decision rung: contract in opencodifier-model, transport behind the reinstated onnx feature (2026-10-05)

  • What shipped: the Kai-0.6B-ONNX rung (task #92) as two pieces. opencodifier-model::kai is the contract — exact prompt rendering (decision2-segmented-options-global-query-v1), tokenization, pointer computation with in-pipeline assertions, the fitted 5-level score bias, and f64 softmax. It is feature-free and verified against a frozen, trimmed upstream parity fixture (kai/fixtures/kai-reference-trimmed.json, 3 of the 11 recorded cases, with provenance). opencodifier-runtime::kai::KaiOnnxBackend is the transport: the four named graph inputs, integral-valued f32 index tensors, and raw logits — no softmax (D7), no calibration.
  • onnx is reinstated under the D2 pin, not re-decided. D2's pin is kept exactly: ort = "=2.0.0-rc.13", default-features = false, features ["std", "ndarray", "api-21"] (std is required — ort's rc.13 needs ndarray/std; api-21 is D2's own re-entry condition, compiling the bindings below ort's default api-27, which no available dylib satisfies). D2's two measured FAIL legs still stand as recorded: the api-27 dylib gap and the cross-implementation softmax bit-identity. The second is now irrelevant by construction — the rung never computes softmax inside the graph, so there is no cross-implementation float to disagree. The first is a deployment constraint, handled by runtime-onnx-dynamic (system dylib) exactly as D2 prescribed. A graph execution against real weights has not been run on this host — the feature compiles, is lint-clean, and is unproven end-to-end until the compute-host parity run.
  • The contract differs from the §10.5 summary; the fixture won. Three deltas, each verified against the upstream runtime's own fixture, not inferred: (1) no truncation — a prompt over max_input_tokens (8192) is refused (max_length_exceeded upstream, model.prompt_too_long here); truncating would silently move every pointer. (2) no len(state_ids) base offset — pointers are absolute token indices into the whole prompt, the option segment's last token; the state-relative base belonged to the Strands packer (§6.5's model), a different model. (3) temperature is 1 for every kind — there is no per-kind temperature table; the only fitted correction is the 5-level score bias.
  • tokenizers is a new heavyweight dep for opencodifier-model, feature-gated. The reference HF encoder only loads behind it (0.22 line, fancy-regex backend — pure Rust, no C dependency). The feature-free path carries a built-in byte-level BPE oracle over the fixture's merge closure, and a test asserts the two encoders agree on every frozen segment, so the default build reproduces the fixture without the dependency.
  • Revisit condition: any change to the served artifact, its prompt_version, or the bias table changes MODEL_ID and invalidates cached decisions (D6); the fixture test must be re-frozen against the new upstream fixture in the same commit.

D34 — The Julia-1 ONNX decision rung: a five-input graph, a Metaspace fixture closure, and the first Rust-side execution on real weights (2026-10-05)

  • What shipped: the second ONNX rung (task #92), beside D33's Kai. opencodifier-model::julia is the contract — upstream julia.data.sequence packing (PROMPT_VERSION = "julia-data-sequence-v1"), marker computation (the option's <mask> start index — the opposite pointer convention from Kai's last-token answer_pos), f64 softmax, and no fitted bias table (upstream publishes none; inventing calibration is forbidden). opencodifier-runtime::julia::JuliaOnnxBackend is the transport, feature-gated behind the same reinstated onnx (D33/D2).
  • The graph has five inputs, not four. Introspecting the real export (onnxruntime get_inputs()) shows input_ids, attention_mask, marker_pos, marker_mask, and qtype (int64[batch]: choice 0, score 1, noul 2). The four-input transport drafted from the parity script's first four feed calls would have failed at session.run on every request; the parity script happened to pass qtype, and only introspection makes the requirement explicit. Contract and transport carry qtype from the start (JuliaKind::qtype), and validate_shapes refuses a wrong-width row. Lesson recorded: a graph's published signature is read off the artifact, never off the caller that happens to work.
  • The fixture tokenizer is a derivation closure, not a vocab subset. Julia-1's tokenizer is Metaspace (space → ▁) with byte fallback, so final ids cannot be re-derived from final ids alone. julia/fixtures/julia-fixture-tokenizer.json assigns each real token's GPT-2-alphabet spelling its real id and composes each spelling with a left-to-right merge chain (filler ids for intermediates); chains are ordered metaspace-runs-first then longest-first, and a demote-and-verify repair loop moves any pair that fires outside its run's chain. The generator (benchmarks/validation/runner/julia_fixture_gen2.py) verifies every frozen segment byte for byte against the Rust oracle algorithm before shipping; a tree-side test re-proves it against kai.rs's byte-level BPE oracle (model crate). The full tokenizer behind the tokenizers feature remains the serving path.
  • Verified end-to-end on the compute host (fedora) 2026-10-05. (1) Python parity julia1_parity.py: 100/100 parity-cases.json rows, max |Δlogit| 7.8e-5 against the export's official bar — the export matches PyTorch. (2) Rust-side (benchmarks/validation/runner/julia-parity-rs, runtime-onnx-dynamic over the pip onnxruntime 1.30.0 dylib): 3/3 fixture argmax, max |Δlogit| 6.3e-5 — ids, markers, tensors, graph execution, and logits all reproduced from the shipped Rust path. This is the first Rust-side graph execution on real weights for any ONNX rung; D33's open boundary is closed for Julia-1 and still open for Kai (the Kai graph exists at model_quantized.onnx on the compute host; its Rust-side run is pending, same runner pattern).
  • Packaging reality: plain --features onnx does not link on this host (no static ort backend — ort's own "Enable the download-binaries feature" linker error); runtime-onnx-dynamic is the only verifiable packaging here, exactly as D2 prescribed. The output width is exactly the declared option count (no padding lane), so the transport's [batch, width] check holds against the real graph.
  • Fixture boundary: the frozen fixture carries 3 choice rows only (narrowest/median/widest of the 100); the export records no noul/score parity cases, so those kinds are exercised in-tree with a stub tokenizer over the same sequence math — the kind-generic parts (packing, markers, qtype, softmax) are pinned, the kind-specific logits are not.
  • Revisit condition: as D33 — any change to the served artifact or prompt_version changes MODEL_ID and invalidates cached decisions (D6); the fixture re-freezes against the new upstream fixture in the same commit.
  • Dated addendum (same day): the Kai boundary is closed too. benchmarks/validation/runner/kai-parity-rs runs the full Rust Kai path — request text → HfTokenizer → collate → KaiOnnxBackend → the contract's own probabilities (softmax + fitted score bias) — against the real quantized export on the compute host: 11/11 ref.json encodes byte-identical (ids, option pointers, answer pointer rebuilt from text, not fed pre-tokenized), 11/11 argmax, max |Δp| 0.0174 (language/lang, the quantization residual on a choice case). The score case lands at 0.0014 only with the fitted bias applied — the raw-softmax comparison (python checker: 0.0392 on review/damage) omits the bias the upstream runtime itself applies, confirming the bias table is the export pipeline's own correction and belongs on the serving path. No graph-execution boundary remains open for either ONNX rung.

D35 — Lexical confidence is bounded: no BM25 softmax output may reach 1.0 (2026-10-06)

  • What changed: the lexical classifier's three decision paths (choice, boolean, score) pass their BM25 scores through bound_lexical_spread before the softmax. When the top score exceeds LEXICAL_LOG_ODDS_CAP (ln(99) ≈ 4.595), the top is pulled to the cap and the rest compress into [0, cap], order-preserving. Below the cap nothing changes. Model id builtin-lexical-v1 → builtin-lexical-v2 (D6 re-key: cached v1 confidences include saturated 1.0 values).
  • Why: the boolean hypothesis is an unbounded BM25 score against a fixed zero null, and the score is a term-frequency product — state length × question-term overlap. Past ~37, softmax([evidence, 0]) returns exactly 1.0 in f64. Measured on the JevBench long_policy items (task #115): 15 KB policy prose drove boolean evidence to 42–64 and choice score gaps to 60+, saturating confidence to 1.0, and the exact-proof ladder (min_confidence: 1.0) accepted those as proofs — four wrong answers at maximum confidence (3× noul predicted "no" on gold-"yes" items via the negation-polarity flip firing on rubric text; 1× choice winner = the criterion whose words the state repeated most). All four reproduced bit-exactly by an independent replication of the arithmetic. This is also why the winner-prob gate refit (#114) hit the grid edge: p≈1.0 was a state-length proxy, not a correctness signal — the p≈1.0 band converted at 0.235 while the [0.5, 0.6) band converted at 0.769.
  • What it is not: not a calibration claim. 0.99 is not a calibrated confidence; it is a structural ceiling that keeps lexical overlap from certifying better than 99:1 odds and from impersonating exact proofs. Predictions are unchanged everywhere (the bound preserves score order), so engine accuracy on any suite is unchanged; what changes is routing under confidence gates — saturated lexical answers now escalate instead of self-accepting at p = 1.0 — and the entropy/margin features are no longer constant-zero for exactly the items where saturation fired. The relational solver's proofs are untouched: exact proofs over extracted facts remain the only source of proof-grade confidence in the zero-ML stack.
  • Revisit condition: if a calibrated lexical artifact (fit through the Calibration seam) ever supersedes the raw hypothesis, the cap moves under it — calibrated confidence may exceed the cap only by fitting, never by saturation.

D36 — The coverage floor is a ratchet, and a red lane must say its number (2026-10-06)

  • What was found: workspace line coverage measured 79.25% (3,955/19,061 lines missed; 77.41% functions; 80.59% regions) against a CI floor of 98.5 — the GitForge coverage lane has been red since the B5/B6/Kai/Julia expansion (8,404 lines added since the 2026-09-29 baseline bfd6488), and the failure was invisible in practice because GitForge's jobs-level API returns [] for finished runs, leaving no job-level truth to read.
  • Measurement first: a throwaway worktree at the baseline commit, measured with this host's exact toolchain, reproduces the recorded baseline (98.13% lines engine crate) — the measurement environment is exonerated; the debt is real and concentrated in the escalation core the expansion grew (executor.rs 980 missed, ladder.rs 424, classifier.rs 294, engine.rs 251, narrowing.rs 184, lexical.rs 169, handle.rs 155). The mature paths never regressed (rules 99.82%, relational 98.95%, cache 98.14%). Zero ignored tests: absent tests, not skipped ones.
  • Decision 1 — ratchet: the CI floor moves to the measured current value (98.5 → per-tranche schedule in scripts/coverage_floor.py invocations, starting at the post-expansion measurement) and rises with each landed Phase 20 tranche back to 98.5. A permanently-red lane is a lying signal — every future red becomes dismissible. A green ratchet makes decay structurally impossible and coverage a monotonic ledger. Closing ~2.5k lines of core tests before anything else was rejected: it leaves the lane red for weeks anyway and blocks every other gate signal behind it.
  • Decision 2 — a red lane must say its number: coverage_floor.py already prints the measured value and the floor on both pass and fail; the missing half is GitForge-side — job logs and job results must be queryable after a run finishes (the jobs-level API returns [] for completed runs today). That observability fix is tracked against GitForge itself (Phase 20h), not against this repo.
  • Revisit condition: when Phase 20g restores 98.5, the ratchet clause is retired and the floor is again a fixed constant; any future intentional debt (e.g. generated code) gets a scoped waiver in docs/COVERAGE.md, never a lowered floor.
  • Update (2026-10-07) — ratchet re-based on the pinned image. The 89.28 % measurement this decision ratcheted from came from a local rustc whose coverage mapping is finer-grained than the CI image's; inside opencodifier-ci-rust:2 the same tree measures 98.03 % DA (17,433/17,783, two independent runs agreeing to the line; 350-line residual classified in docs/COVERAGE.md, where the "escalation core debt" reading above is corrected — executor.rs is 36/973 DA-missed in-image, not 980 lines dark). The schedule becomes 89.0 → 97.5 (now, 0.53 under measured) → 98.5 (as the Phase 20 tranches close the reachable residual). Standing rule added: only an in-image measurement may move the floor; local toolchains are for tranche targeting, never for floor arithmetic.
  • Update (2026-10-08) — ratchet retired at the reachable ceiling. Phase 20g's tranches recovered every unit-reachable line (cli ladder 65, itemgen classes, the macjev render fall-through) and the closing line-by-line pass over the residual found the pool empty: the remaining 298 DA misses (98.39 %, 18,187/18,485 in-image) are all in proven-infeasible classes — #[non_exhaustive] wildcards no in-crate test can construct, defensive-unreachable typed arms kept so a future change fails loudly, wasm JsValue boundaries that SIGABRT a host test, binary-entry mains, ffi contract-violation guards (full ledger in docs/COVERAGE.md). Reaching 98.5 would require deleting honest defensive code or covering infeasible lines, so the "restore 98.5" exit condition is amended: the floor rests at 97.5 as the fixed constant and the ratchet clause is retired now rather than at 98.5. The revisit clause's own remedy applies verbatim — the residual is the scoped waiver, recorded group by group in docs/COVERAGE.md; a future tranche that finds a genuinely reachable line reopens the ratchet upward.

D37 — Generated relational/contrastive items are solver-verified in-engine, never emitted unproven (2026-10-07)

  • What was found: the r2 post-mortem (TRAINING.md §9.6) traced the decision model's relational weakness to the data, not the rung: the e1-sft corpus carries almost no relational-analog rows and its labels are not contrastive — nothing supervises why a plausible-looking wrong answer is wrong. Prompt-side rebalancing alone cannot fix that; the wrong-answer slots need provable exclusions.
  • Decision: relational/contrastive corpus items are generated by the new opencodifier-itemgen crate, which samples symbolic fact graphs, renders them through the engine's own extraction grammar, and verifies each item against [RelationalSolver] — the same zero-ML stack the serving rung decides with. An item ships only when the solver proves its gold at probability 1.0 over the full ballot, which makes every non-gold slot's " no" supervision exact by construction. Verification is three-layered: round-trip extraction (the rendered text must re-extract to the constructed fact set), solver proof (unique probability-1.0 gold), and a non-blocking lexical probe recorded per family (the AFLITE-style separability number the pilots read for hardness).
  • The suite stays the judge: generated items are held to a stricter standard than the decision suite itself, whose relational items are labeled by hand and are not always uniquely provable under the solver's operators (suite item C-0000 is the recorded example). The suite is never modified to match the solver; generated items are never added to it; the two surfaces stay separate by design.
  • Hard-negative discipline (per Kabra et al., ICLR 2026, and the §9.8 research pass): every ballot carries ≥5 slots with ≥3 near-misses, each carrying a named structural reason it is wrong (symptom, standalone failure, healthy peer, runner-up, waiter, degraded-heavy, zero-report). Structural diversity comes from four sampler families × four entity domains × four question phrasings × per-item graph shapes; a suite-collision guard (exact normalized match plus shared 12-word span) rejects near-duplicates of suite items instead of emitting them, and the manifest reports the count.
  • Determinism: the corpus is a pure function of (seed, n) — SplitMix64 in-crate, no RNG dependency, byte-identical reruns asserted by test. A deterministic --probe-every split routes a held-out slice to a separate file so pilots can tell generator overfitting from rule learning.
  • Rejected: generating without in-engine verification (a label the solver later disagrees with trains the model against its own serving rung); free-form LLM generation for these rows (not deterministic, not verifiable locally, violates the deterministic-first principle); soft-label smoothing over the ballot (masks which exclusions are exact versus approximate).

D38 — The SigLIP2 zero-shot vision rung: split-tower ONNX transport, feature-free contract (track V, 2026-10-16)

  • What: image-bearing states are decided by a SigLIP2 zero-shot classifier that takes an image and a candidate list and returns a calibrated probability distribution — no training data, no fine-tuning, no ML dependency in the decision surface.
  • Arm of record: onnx-community/siglip2-base-patch16-256-ONNX; the split-tower variant (vision_model.onnx + text_model.onnx) rather than the 1.5 GiB monolith, so the text tower runs once per question set and the image tower once per state.
  • Wire contract (grounded against the transformers.js and optimum-onnx export configs for SigLIP2):
    • vision graph — input pixel_values f32 [B,3,H,W] (CHW, mean/std normalized); output pooler_output f32 [B,E] (SigLIP2-base: E=768).
    • text graph — inputs input_ids i64 rows + attention_mask i64 rows; output pooler_output f32 [B,E], likewise.
  • Dep architecture (D11): ONNX transport lives in opencodifier-runtime (siglip.rs); preprocessing, prompt encoding, and the zero-shot head live in opencodifier-model/vision/serve.rs behind a vision feature. The decision contract module (vision.rs) is ort-free — no ONNX dep, no ML runtime, compiles in every build.
  • Image carrier: State carries ImageInput (typed PNG/JPEG bytes); state_from_messages returns a parts array when images are present, a plain string when text-only (byte-identical to pre-D38 behavior for text-only callers, D38§2).
  • Ladder: image-capable requests fire the vision rung only when configured; without it the engine returns VisionRequired and the HTTP adapter maps it to HTTP 400 with engine.vision_required.
  • Confidence: the zero-shot head (cosine similarity, softmax, logit scale) is calibrated before the result is exposed — raw softmax probability is not calibrated confidence.
  • Rejection (Phase H): --image without --vision-model-dir refuses at CLI assembly; VisionClassifier refuses text-only states with a typed error rather than degrading silently.
  • Rejected: single-tower monolith (charges every request for both towers' memory); storing embeddings for re-ranking (no vector DB in V1); serving the ONNX transport from the model crate (violates D11 heavyweight-dep confinement).

D40 — Per-family ladder routing: accuracy-based, not calibration-based (2026-10-21)

  • What: route questions to the minimum sufficient rung based on JevBench family, through the existing LadderPolicy::per_kind mechanism.

  • Why not per-family calibration first: per_family_calibration.py on e1e-slot-231 shows all 18 families hit T=200 (temperature ceiling) — the E1-E model's overconfidence is accuracy-dominant, not calibration-shape. Per-family T fitting produces degenerate results on every family. The ECE gap (0.207) is closed only by better training, not better calibration parameters.

  • The routing lever: engine arm accuracy varies 0.00–1.00 across families. tool_selection (0.99) can skip the model rung entirely. temporal_numeric (0.333) and multi_hop (0.11) need the model rung directly. Routing on accuracy thresholds lets the engine handle high-confidence families at zero model cost while escalating hard families immediately.

  • IR change required: none. LadderPolicy::per_kind already accepts a BTreeMap<NodeKind, DecisionPolicy>. The routing decision is: which NodeKind gets which policy, and which families escalate immediately vs use the engine. The NodeKind → family mapping requires either (a) a question attribute carrying family, or (b) a lookup table from question text pattern to family. The question_id in JevBench results encodes family (hard-temporal_numeric-03) but that lives in the eval harness, not the decision request. Until a family attribute is added to the request IR, the routing is an eval-harness post-process (run engine-only on tool_selection/intent items, model-only on multi_hop/temporal_numeric items), not a live engine feature.

  • Implementation when family is in the request: add family: Option<String> to RequestMetadata or DecisionPolicy. At the ladder gate, map family to the minimum rung: high-accuracy families → engine-only (lexical + relational), low-accuracy families → escalate to model immediately. The LadderPolicy already resolves per_kind, so this is a config change, not a code change.

  • Calibration seam: per-family calibration artifacts are still produced by per_family_calibration.py for documentation. The routing decision uses accuracy thresholds, not ECE thresholds.

  • Blocking on eval data: E1-F and E1-G holdout eval rows will show which families improve under the new arms. Routing thresholds should be re-tuned after those results land.

  • What: the NLI verbalization verifier (NliVerifier) wires into the escalation ladder as a plain Rung, with no executor changes.

  • Why it works without code changes: NliVerifier implements the engine's Classifier trait (the same seam every other rung uses), so passing it as a fallback to EngineHandle::with_rungs or DecisionEngine::new_with_rungs is sufficient. The executor's existing ladder walk already calls rung.classifier.decide_extended(state, question, handoff) — exactly what NliVerifier implements.

  • Ladder integration: add NliVerifier as a Rung at the desired position in the fallback list. The NLI rung fires when the previous rung's gate does not accept, and the NLI distribution (entailment masses renormalized over candidates) is the final answer if the NLI rung's own gate accepts.

  • Feature gate: the serving scorer (OnnxNliScorer) requires the nli feature (tokenizers + onnx runtime). The contract — NliVerifier, NliScorer, Verbalizer, entailment_distribution, and the Classifier impl — is always compiled and usable with a scripted scorer in tests.

  • Model id encodes the verbalizer tag: "nli-zeroshot:model.onnx:bare" vs "nli-zeroshot:model.onnx:answer-is". Swapping the template invalidates cached decisions automatically (D6).

  • Cache key: the ladder already decorates each rung's model id and calibration version into the engine identity, so a ladder with an NLI rung re-keys when the graph or template changes.

  • Distingushed from the verifier cascade: the engine's verifier cascade (which runs after the threshold gate) is a separate mechanism — it checks whether a second opinion agrees with a verified-band answer. The NLI rung is an escalation step that replaces the decision model for any answer below the accept gate, not just verified-band ones.

D41 — llama.cpp decision inference fork: branch-only tracking of fast LoRA kernels (2026-10-22)

Reddit post (2026-10-21): fused LoRA kernels recover ~80% of throughput loss from applying small adapters. On AMD Strix Halo, a 35 MB LoRA dropped token throughput from 1500 → 300 t/s without kernels; fused kernels recovered it to 1400 t/s. The bottleneck is mat_mul_id — the identity-matrix-scaled LoRA computation running every token.

  • Tracking strategy: branch-only until the fork graduates. The base is thecodacus/llama.cpp @ parallel-decision (commit 8f02796), which has the tree-mode /v1/decision endpoint. OpenCodifier's patch on top is aliasfoxkde/llama.cpp @ parallel-decision-oc (commit 7ae36f4): it adds the full per-choice distribution map next to the winner-only probability, which the Rust client requires — a winner-only server is refused rather than back-filled with synthesized mass. Optional third layer: woct0rdho/llama.cpp @ strix fast LoRA kernels for AMD RDNA 3.5, with NVIDIA CUDA port TBD.
  • Nothing is stripped (amended 2026-10-22). This record originally claimed the build removed generation code, paged KV, all quant types except Q4_K_M, all attention variants except SDPA, and MoE routing. The shipped build removes none of them: it is a stock llama.cpp build of the decision fork, and the only feature switches it sets are the build-target ones (LLAMA_BUILD_EXAMPLES=OFF, LLAMA_BUILD_TESTS=OFF). Stripping is a per-quantization-type and per-attention-variant edit across the whole tree — a permanent merge burden against every upstream rebase — and it buys a size win, not a latency one, since the decision path reads the same kernels the generation path does. The binary is already 18 MB with the full feature set, which is not the constraint. Rule: strip only what a measured constraint requires, and record the measurement in docs/FORK_LLAMA_SERVER.md first.
  • What was kept: transformer forward pass, fused LoRA application, tree-mode decision readout endpoint, static binary output.
  • The Rust HTTP client does not change: LlamaDecisionClassifier in opencodifier-model/src/llamacpp.rs talks to the server over POST /v1/decision; the fork replaces only the server binary.
  • Disk layout: forks/llama.cpp/ in the repo root — README.md, build.sh, serve.sh. No GGUF artifacts: T5500 bundle or HuggingFace.
  • Provenance: the built binary's commit, backend, and host are written to forks/llama.cpp/build/BUILD-INFO.txt, so a shipped server is traceable to the fork commit that produced it.
  • Revisit condition: when fused LoRA kernels land in mainline llama.cpp, the fork tracking becomes unnecessary and forks/ can be removed.

D42 — Per-family ladder routing: the family is a validated request attribute (2026-10-21)

D40 established that the routing lever is accuracy, not calibration, and left the mechanism unimplemented because the request IR had no family attribute. This decision ships that mechanism.

  • What: RequestMetadata gains family: Option<DecisionFamily>; LadderPolicy gains per_family: BTreeMap<String, DecisionPolicy>; LadderProfile gains the same field; LadderPolicy::resolve gains a family: Option<&str> argument and a family tier. Resolution order is node id → family → node kind — most specific first. The family tier sits between the other two because a family names what kind of decision is being made, which is more specific than the deciding node's kind but less specific than the node's own id.
  • The family is a validated identifier, not free text: DecisionFamily reuses the IR's shared identifier rules ([A-Za-z0-9_.-], max 128 bytes). The label is caller-supplied and lands in cache-key material, in trace output, and in log lines, so it is bounded by construction and cannot smuggle a policy directive through — it selects only among the rungs a deployment has already configured. A request carrying a malformed family is refused at the wire, before the engine ever sees it, with the schema's schema.invalid_value code and HTTP 400. (Amended 2026-10-22: this record originally said the refusal carries ir.invalid_id. The IR does produce that code — DecisionFamily::new returns CoreError::InvalidId — but the wire path never reaches it: the native adapter deserializes the request with serde first, and serde's Deserialize for the identifier newtype maps the validation failure to a serde error, which the adapter reports as schema.invalid_value. The IR-level code is reachable only from Rust code that constructs the type directly. The security property the claim was making — a malformed family never reaches the engine, the ladder, or the cache — holds either way, and the wire test asserts the code that is actually emitted.)
  • Absent means absent: the field is skip_serializing_if = "Option::is_none", so a request without a family serializes byte-identically to the pre-field form and its cache key does not shift. A request with a family is part of the canonical request bytes, so two requests differing only in family get different cache keys — required, because the family changes which rung answers. CacheKeyBuilder is unchanged: the metadata already serializes into the canonical bytes.
  • The trace names the source: the gate records policy_source: family:<name>, alongside the existing node:<id> and kind:<name> strings.
  • Profile keys are validated at load: a per_family key that is not a legal family name could never match a request and would be a silently dead override, so LadderProfile::into_ladder refuses it with engine.invalid_config.
  • No wire-format change: the native format is an identity codec over the IR's own serde representation, so metadata.family is already on the wire; the CLI and MCP read native request documents, so the label flows through unchanged.
  • The thresholds are NOT decided here: D40 measured all 18 JevBench families at the T = 200 calibration ceiling, so the routing thresholds are accuracy-based and must be re-tuned once E1-F/E1-G land. This decision ships the mechanism; no family profile is shipped until there is a measured basis for one.

D43 — Temporal operators: the solver gained dates, and the premise of #180 did not survive them (2026-10-21)

#180 asked the relational solver to close the JevBench temporal_numeric family (E1-E 2B accuracy 0.333). It does not, and this decision records why, what was built instead, and what the measurement actually shows.

  • The premise was falsified by inspection. All 15 temporal_numeric items were read. Their answers are band labels (sep_26, eur_692_00, 9_credits, over_limit) that require multi-constraint reasoning the solver does not have and should not have: daylight-saving transitions, leap years, business-day resets, proration, unit conversion. A deterministic relational operator proves entities, not bands. The gap is model-side (routing per D40, plus E1-F/E1-G training), not solver-side. The exact-proof lever on this family was already recorded as dead in RESEARCH §23.6; this is the same finding seen from the solver side.

  • What was built anyway: a general capability. The facts grammar gained ISO timestamps (YYYY-MM-DD, optionally THH:MM, validated against the Gregorian calendar including leap years) and two fact forms — Deadline { entity, at } and Event { entity, at } — parsed from "<entity> <phrase> by <ts>" and "<entity> <phrase> on <ts>". Two new operators ride them: soonest_deadline (the entity whose deadline falls strictly first) and most_recent_event (the entity whose event falls strictly latest). Both are general ops-analytics primitives, not benchmark-shaped.

  • Two operators, not one, on purpose. A single "temporal ordering" operator cannot tell "deadline" from "event"; the two run in opposite directions, so conflating them would answer confidently and wrongly. Keeping them separate means a context that mixes both produces disagreement → abstain rather than a confidently incorrect pick. Ties abstain. A winner outside the candidate set delegates to the next rung rather than answering.

  • Date-shaped words are not entity names. The extractor rejects a token that parses as a timestamp when testing whether it is an entity, so a date can never be read as a candidate name. Dates are also not lexically salient, so the answer cannot be recovered by overlap.

  • The cache key moved: the solver's model id is now relational-v2|<inner> (was relational-v1|<inner>). Behaviour changed, so cached decisions from the old solver must not be reused.

  • The measurement — engine arm on suite_temporal_v1.json (100 items, class temporal_ordering, generated by benchmarks/decision-model/suite/ generate_temporal_suite.py, seed 20261021), same binary, same host (SWARMONE):

    arm identity accuracy ECE outcomes (accept/abstain/verify) p50
    before relational-v1|builtin-lexical-v2 0.290 0.196 24 / 72 / 4 3.09 ms
    after relational-v2|builtin-lexical-v2 1.000 0.000 100 / 0 / 0 5.29 ms

    Determinism: predictions_match = true, max_prob_delta = 0.0 in both arms.

  • What that 1.000 does and does not mean. The suite is generated from the solver's own grammar, so accuracy 1.000 says the operators implement the semantics they claim, exactly and deterministically. It does not say the capability transfers to temporal_numeric — a deliberately in-grammar suite cannot measure transfer. The before-arm is the more informative half: 72 of 100 items abstained rather than guessing, which is the abstention-is-success posture working as designed when the evidence was absent.

  • Not shipped as a release lever. The temporal operators are a general engine capability, not a benchmark row. No JevBench number changed, and none is claimed to. temporal_numeric remains at 0.333 for E1-E 2B and is routed like any other family.

  • Re-tuning is deferred: the family-routing thresholds (D42's mechanism) still need E1-F/E1-G measurements before any family profile ships.

D44 — The decision runner is the fork's own decision-only binary; vLLM and SGLang are rejected (2026-10-22)

The model rung (D26) is an HTTP client to a llama.cpp process. The natural follow-up question is whether to replace that process with a general-purpose inference server — vLLM, SGLang, a combination — so that one stack serves both the engine and everything else. This decision answers that question by measurement, and records the runner contract.

  • The runner is llama-parallel-decision, the fork's own decision-only binary. It is 9.2 MB against the server's 18.4 MB, speaks a line protocol on stdin/stdout instead of HTTP, and has no chat/completions surface to misuse as a general LLM. Both binaries link the same llama-decision engine; the size difference is the HTTP server, chat templating, samplers, and OpenAI compatibility — none of which the model rung uses.

  • vLLM and SGLang are rejected, on four independent grounds, any one of which is disqualifying:

    1. They are Python. The repo's binding rule is "no Python at runtime" (CLAUDE.md), and the base binary must be useful with zero ML. A vLLM install is tens of gigabytes of wheels plus an interpreter; the decision runner is a static ELF with no runtime dependency.
    2. Their API is generation-shaped. Both expose /v1/completions and /v1/chat/completions — a token stream. The model rung needs the full per-choice distribution in one forward pass with no sampling, which the fork's decision engine computes directly over the answer trie. A general server would have to reimplement that as constrained decoding and then re-derive the distribution from logprobs — the trap already recorded in RESEARCH §2.
    3. They are built for GPU throughput. This host has no CUDA. The 2B arm's ~600 ms is a CPU-serial prefill, not a batching problem.
    4. They add a dependency without adding a capability. The engine's Transport trait is one post_json call; a second, heavier stack would satisfy it equally well and cost more.
  • Parity is proven, not assumed. The decision-only runner does not accept a schema — it accepts pre-rendered prompt files — so the rendering had to be reconstructed from the server's own compile_schema (decision-engine.cpp:610) and render_prompt (:666). Two details are load-bearing and both were wrong in the first attempt: the catalog quotes its allowed values (it writes json_text(v), not the bare string), and the prompt is rendered with the model's jinja template, split at the <<decision-context>> sentinel, with enable_thinking: false. With those matched, the two runners agree exactly:

    coding analysis writing research
    server /v1/decision 0.5432648062705994 0.42237746715545654 0.012004137970507145 0.02235357090830803
    llama-parallel-decision 0.543265 0.422377 0.0120041 0.0223536

    Token counts match too (shared 67, context 29), so the two runners are the same engine over the same prefix.

  • Latency is at parity, not better. An earlier session's figures suggested the CLI was meaningfully faster than HTTP; that did not reproduce. Re-measured interleaved on the same host at load_avg ~19: worker 0.64–0.81 s warm versus server 0.62–0.88 s warm. The runner's win is footprint and surface area, not speed. Recording the honest number matters more than the flattering one.

  • The server's batch path shows no per-decision amortization on this host. /v1/decision takes up to 256 contexts; per-decision time was flat — n=1 → 584 ms, n=4 → 550, n=8 → 563, n=16 → 586, n=32 → 525. Batching is a capacity feature on CPU and a speed feature on GPU; here it is only the former. This changes what the contexts array is worth in this deployment.

  • Thread count matters more than transport. The worker mode takes a thread count; 8 threads was best (0.52–0.55 s warm) and 16 threads was catastrophic (6.5–13.3 s) on a 16-core host already at load ~19. Oversubscription, not the runner, explains the earlier outlier.

  • The engine seam is unchanged. Transport (crates/opencodifier-model/src/llamacpp.rs:136) is still one post_json call, and a worker-mode transport would be a second implementation of that trait with the line protocol in place of the HTTP request. It is not built, and the measurements say it is not urgent: the HTTP path is the same speed and the server's batching is the capability the runner lacks. No live server runs in the test suite (D26); everything about the model rung is testable through a scripted transport.

  • Full write-up: DECISION_RUNNER.md, including the reproduction steps and the honest limits (CPU-only, shared host, small four-choice payload, strix branch still unfetchable and irrelevant here).