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.
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.
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 toapi-24(needs a 1.24.x dylib) orapi-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
Softmaxop, 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.
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.
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 anEmbeddingBackendtrait so embedding models are supplied by the runtime crate — nothing downloads at runtime, ever.
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.
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.
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.
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".
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 |
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.
| 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.
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.
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.
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.
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).
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.
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_batchaccepts at most 16 requests (mcp.batch_too_largeabove 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 ismcp.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--traceand MCPcodify_explaincall it. codify_graphexposes the active graph document (via the newEngineHandle::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.
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::focusis all zero without one, andexecution_jsoninserts"focus"only whendecided > 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.
§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 areschema.*(400); structural graph failures are the engine's owngraph.*codes, identical to/v1/graph/validate. Abstention stays a200— §73's posture does not bend for ad-hoc graphs. - Content-addressed identity, never client-asserted. The scoped
engine's
graph_versionis the first 8 bytes (big-endian) of SHA-256 over the graph's canonical serialization (version+ node specs, serde field order). A client naminggraph_version: 1gets 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
decidecall. (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 sameDecisionGraphconstructor/v1/graph/validateuses — 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_graphremains 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.
DecisionDefinitionlives inopencodifier-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 optionalpolicy(absent =DecisionPolicy::default). - Identity is content, labels are labels. A definition's identity is
the SHA-256 hex of its canonical serialization (
content_hash()).idandversionare 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
DecisionRequestis 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 intoRequestMetadata, 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.
Registryindexes definitions byidand 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.
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.
embeddingonly 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.retrievenarrows: per choice question it keeps the toptop_ncandidates whose semantic score clearsfloor, and every dropped candidate is named in the trace with its score — never silent.rerankreorders: 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,
retrievekeeps 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.LexicalRerankeris the zero-ML default (BM25 over candidate descriptions, the same primitive as thelexicalnode);EmbeddingRerankeris cosine over anEmbeddingBackend(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-reranknodes on a handle without one fails assembly withengine.missing_backendbefore 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.
retrieveis semantic pruning with extra disclosure. Safe mode (the default) rejects any graph containing aretrievenode at assembly, exactly as it rejects thelexical_prune_limitknob; 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.
EngineIdentitygainsembedding_model(default"none"), folded into every cache key by the single normativeCacheKeyBuilder. 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.
retrieverequirestop_n ≥ 1;flooronly means something onretrieve;rerankeronly onrerank; 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.
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 nooutput-reachable path contains can never contribute to a response — with one exception:cacheis 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.cacheis excluded (side-effecting),thresholdandoutputexcluded (unique, response-shaping). Duplicate entries in adepends_onlist 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_versionis 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.
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(andruntime's traits). These are sync, allocation-only, IO-free crates — verified bycargo check --target wasm32-unknown-unknown, not assumed. The WASM crate depends on nothing else: no tokio, no axum, noort, 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.
WasmEngineassemblesEngineHandle::lexicalover the default pipeline — relational solver over BM25, exactly the zero-ML stack the native base binary ships. Wire formats go through theschemanative 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
ortin 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
-syscrates, no JS shims beyondwasm-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 nodejsand 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 onwasm32-unknown-unknown(a wasm module reads the host's monotonic clock), andstd::thread::spawnis unavailable there. Both are now declared seams, not runtime surprises:clock.rsre-exportsstd::time::Instantnatively and theweb-timereading on wasm32 (target-gated dependency — the native tree gains nothing), and the executor's parallel-wave path is compile-time gated off on wasm32, soRunReport::parallel_wavesis 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.
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 forx86_64-pc-windows-gnuwith the mingw linker present. Linked (2026-10-02, 2m44s at -j4 alongside the measurement chain):opencodifier.exe(PE32+ console, x86-64) andopencodifier_wasm.dll(cdylib), zero warnings. Verification level is link success +filePE 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/zigpresent 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-zigbuildcan link Mach-O without an Apple SDK. Verification is link success +filemagic 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-packoutput (built byjust 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 bycargo-zigbuild; verification level: link +filemagic (no Android host or emulator here; execution is structurally out of scope, same rule as darwin). - ios-aarch64 (
aarch64-apple-ios) — theopencodifier-ffistaticlib, 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 — anararchive 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/fromwasm-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).
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:
LadderPolicycomposes the existing gate type; the IR does not move. Engine config gains optional per-node / per-kindDecisionPolicyoverrides (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_byrecords 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 whoseidis 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_sourcetrace 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::thresholdremains 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.
LadderPolicycarriescalibrations: BTreeMap<String, Arc<dyn Calibration>>keyed by the samenode:<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 recordscalibration_sourcewhen 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. Itsversion()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 throughDecisionPolicy's validated mirror, rung calibrations as D15 artifacts with an optionalproof_awarewrapper — and refuses an empty profile. The CLI surfaces it as--ladder <PATH>ondecide,serve, andmcp servethrough the one sharedruntime::engine_config, so the three interfaces cannot drift. HTTP config surfacing stays deferred: no engine-configuration surface exists on/v1yet (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 whenp_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 existingRawDecisionPolicymirror, 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:
valuefollows the boundary,probabilityis the verdict's own mass (e.g. answering false atp_true = 0.6means 0.4), and calibrated confidence maps that mass through the rung's calibration viaCalibration::calibrate_probability(new provided trait method, identity default, D15-consistent;TemperatureCalibrationapplies the same per-entry rescale it applies to the top;ProofAwareCalibrationdelegates). 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_thresholdwhenever a boundary is configured, andboolean_flipped = truewhen 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.rssweeps0.50..=0.99in 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.idempty or"none"is refused at emit (the id decorates every cache key). Every emitted document round-trips through the realLadderProfileloader in tests, so a fitted profile can never drift from what--ladderaccepts. 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_questionappends 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
Abstainregardless 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_candidatewhenever a candidate rides a choice question,abstain_elicited = trueonly 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, notInferenceBackend.InferenceBackendis the tensor-in/tensor-out contract of the ONNX serving path (D7: namedDenseTensors). 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.LlamaDecisionClassifierimplementsopencodifier_engine::Classifier(decide →Distribution) and assembles throughEngineHandle::new/with_ladderas 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-modelbehind allamacppfeature; the HTTP client isureq(sync,default-features = false,jsononly) — 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 outsidedeny.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
Transporttrait (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'sDeadlineseam; 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.
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 inladder.rsbecause 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 throughDecisionEngine::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-
Acceptoutcome (VerifyorAbstain— 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_firedand arung_chainfact 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.
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) andOodMode::LexicalBand. In lexical-band mode the OOD score is1 − 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.
DecisionPolicygainsood_mode(serde defaultanswer_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 toabstain_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 todecideand returningNone. Theindexparameter 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_scoremeans under the default mode: existing traces, artifacts, and calibration cells remain valid; only policies that opt intolexical_bandget 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 sixcodify_*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
systemonecallers, 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
abstainfalls 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.mddocuments 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.
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-formatrequest header, valued with the adapters' own stablename()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-Typedescribes 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/batchitem), so an OpenAI-shaped client gets an OpenAI-shaped answer and never parses our native envelope to reach its data./v1/validatedecodes 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:
400schema.invalid_valuenaming 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
nativeand asopenaishares 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/systemonestill does not exist and is still not claimed (Phase 19b decides whether a Jev-shaped route earns its place under these same rules).
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: jevheader already carries the entire capability: the same adapter, the same decode/encode symmetry, the same cache identity. A/v1/systemoneroute would be a byte-for-byte alias ofPOST /v1/decidewith 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/systemonestays unclaimed. - Jev-shaped callers are served today:
POST /v1/decidewithx-opencodifier-format: jev(HTTP),opencodifier decide --format jev(CLI),Jevadapter in-process (library). This is documented as the integration path inINTEGRATIONS.md§2.4, not a workaround.
- 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 atmin(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 > 10before any model token was scored. Found by the JevBench full-system runs (task #105): the proofs-only ladder aborted twice on exactly this (first asengine.timeoutunder 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::kaiis the contract — exact prompt rendering (decision2-segmented-options-global-query-v1), tokenization, pointer computation with in-pipeline assertions, the fitted 5-level score bias, andf64softmax. 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::KaiOnnxBackendis the transport: the four named graph inputs, integral-valuedf32index tensors, and raw logits — no softmax (D7), no calibration. onnxis 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"](stdis required — ort's rc.13 needsndarray/std;api-21is D2's own re-entry condition, compiling the bindings below ort's defaultapi-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 byruntime-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_exceededupstream,model.prompt_too_longhere); truncating would silently move every pointer. (2) nolen(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. tokenizersis a new heavyweight dep foropencodifier-model, feature-gated. The reference HF encoder only loads behind it (0.22line,fancy-regexbackend — 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 changesMODEL_IDand 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::juliais the contract — upstreamjulia.data.sequencepacking (PROMPT_VERSION = "julia-data-sequence-v1"), marker computation (the option's<mask>start index — the opposite pointer convention from Kai's last-tokenanswer_pos),f64softmax, and no fitted bias table (upstream publishes none; inventing calibration is forbidden).opencodifier-runtime::julia::JuliaOnnxBackendis the transport, feature-gated behind the same reinstatedonnx(D33/D2). - The graph has five inputs, not four. Introspecting the real
export (
onnxruntimeget_inputs()) showsinput_ids,attention_mask,marker_pos,marker_mask, andqtype(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 atsession.runon every request; the parity script happened to passqtype, and only introspection makes the requirement explicit. Contract and transport carryqtypefrom the start (JuliaKind::qtype), andvalidate_shapesrefuses 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.jsonassigns 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 againstkai.rs's byte-level BPE oracle (model crate). The full tokenizer behind thetokenizersfeature remains the serving path. - Verified end-to-end on the compute host (fedora) 2026-10-05.
(1) Python parity
julia1_parity.py: 100/100parity-cases.jsonrows, 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-dynamicover the piponnxruntime1.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 atmodel_quantized.onnxon the compute host; its Rust-side run is pending, same runner pattern). - Packaging reality: plain
--features onnxdoes not link on this host (no static ort backend — ort's own "Enable thedownload-binariesfeature" linker error);runtime-onnx-dynamicis 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/scoreparity 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_versionchangesMODEL_IDand 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-rsruns the full Rust Kai path — request text →HfTokenizer→ collate →KaiOnnxBackend→ the contract's ownprobabilities(softmax + fitted score bias) — against the real quantized export on the compute host: 11/11ref.jsonencodes 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 onreview/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.
- What changed: the lexical classifier's three decision paths
(choice, boolean, score) pass their BM25 scores through
bound_lexical_spreadbefore the softmax. When the top score exceedsLEXICAL_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 idbuiltin-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 inf64. Measured on the JevBenchlong_policyitems (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
relationalsolver'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
Calibrationseam) ever supersedes the raw hypothesis, the cap moves under it — calibrated confidence may exceed the cap only by fitting, never by saturation.
- 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.pyinvocations, 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.pyalready 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:2the same tree measures 98.03 % DA (17,433/17,783, two independent runs agreeing to the line; 350-line residual classified indocs/COVERAGE.md, where the "escalation core debt" reading above is corrected — executor.rs is 36/973DA-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
DAmisses (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, wasmJsValueboundaries that SIGABRT a host test, binary-entry mains, ffi contract-violation guards (full ledger indocs/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 indocs/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-itemgencrate, 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-everysplit 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_valuesf32[B,3,H,W](CHW, mean/std normalized); outputpooler_outputf32[B,E](SigLIP2-base: E=768). - text graph — inputs
input_idsi64 rows +attention_maski64 rows; outputpooler_outputf32[B,E], likewise.
- vision graph — input
- Dep architecture (D11): ONNX transport lives in
opencodifier-runtime(siglip.rs); preprocessing, prompt encoding, and the zero-shot head live inopencodifier-model/vision/serve.rsbehind avisionfeature. The decision contract module (vision.rs) is ort-free — no ONNX dep, no ML runtime, compiles in every build. - Image carrier:
StatecarriesImageInput(typed PNG/JPEG bytes);state_from_messagesreturns 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
VisionRequiredand the HTTP adapter maps it to HTTP 400 withengine.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):
--imagewithout--vision-model-dirrefuses at CLI assembly;VisionClassifierrefuses 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).
-
What: route questions to the minimum sufficient rung based on JevBench family, through the existing
LadderPolicy::per_kindmechanism. -
Why not per-family calibration first:
per_family_calibration.pyon 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) andmulti_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_kindalready accepts aBTreeMap<NodeKind, DecisionPolicy>. The routing decision is: whichNodeKindgets which policy, and which families escalate immediately vs use the engine. TheNodeKind→ family mapping requires either (a) a question attribute carrying family, or (b) a lookup table from question text pattern to family. Thequestion_idin 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>toRequestMetadataorDecisionPolicy. At the ladder gate, map family to the minimum rung: high-accuracy families → engine-only (lexical + relational), low-accuracy families → escalate to model immediately. TheLadderPolicyalready 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.pyfor 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 plainRung, with no executor changes. -
Why it works without code changes:
NliVerifierimplements the engine'sClassifiertrait (the same seam every other rung uses), so passing it as a fallback toEngineHandle::with_rungsorDecisionEngine::new_with_rungsis sufficient. The executor's existing ladder walk already callsrung.classifier.decide_extended(state, question, handoff)— exactly whatNliVerifierimplements. -
Ladder integration: add
NliVerifieras aRungat 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 thenlifeature (tokenizers + onnx runtime). The contract —NliVerifier,NliScorer,Verbalizer,entailment_distribution, and theClassifierimpl — 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.
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(commit8f02796), which has the tree-mode/v1/decisionendpoint. OpenCodifier's patch on top isaliasfoxkde/llama.cpp@parallel-decision-oc(commit7ae36f4): it adds the full per-choicedistributionmap next to the winner-onlyprobability, which the Rust client requires — a winner-only server is refused rather than back-filled with synthesized mass. Optional third layer:woct0rdho/llama.cpp@strixfast 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 indocs/FORK_LLAMA_SERVER.mdfirst. - What was kept: transformer forward pass, fused LoRA application, tree-mode decision readout endpoint, static binary output.
- The Rust HTTP client does not change:
LlamaDecisionClassifierinopencodifier-model/src/llamacpp.rstalks to the server overPOST /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.
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:
RequestMetadatagainsfamily: Option<DecisionFamily>;LadderPolicygainsper_family: BTreeMap<String, DecisionPolicy>;LadderProfilegains the same field;LadderPolicy::resolvegains afamily: 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:
DecisionFamilyreuses 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'sschema.invalid_valuecode and HTTP 400. (Amended 2026-10-22: this record originally said the refusal carriesir.invalid_id. The IR does produce that code —DecisionFamily::newreturnsCoreError::InvalidId— but the wire path never reaches it: the native adapter deserializes the request with serde first, and serde'sDeserializefor the identifier newtype maps the validation failure to a serde error, which the adapter reports asschema.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.CacheKeyBuilderis unchanged: the metadata already serializes into the canonical bytes. - The trace names the source: the gate records
policy_source: family:<name>, alongside the existingnode:<id>andkind:<name>strings. - Profile keys are validated at load: a
per_familykey that is not a legal family name could never match a request and would be a silently dead override, soLadderProfile::into_ladderrefuses it withengine.invalid_config. - No wire-format change: the native format is an identity codec over the IR's own
serde representation, so
metadata.familyis 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_numericitems 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, optionallyTHH:MM, validated against the Gregorian calendar including leap years) and two fact forms —Deadline { entity, at }andEvent { 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) andmost_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>(wasrelational-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, classtemporal_ordering, generated bybenchmarks/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-v20.290 0.196 24 / 72 / 4 3.09 ms after relational-v2|builtin-lexical-v21.000 0.000 100 / 0 / 0 5.29 ms Determinism:
predictions_match = true,max_prob_delta = 0.0in 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_numericremains 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 samellama-decisionengine; 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:
- 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. - Their API is generation-shaped. Both expose
/v1/completionsand/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. - 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.
- They add a dependency without adding a capability. The engine's
Transporttrait is onepost_jsoncall; a second, heavier stack would satisfy it equally well and cost more.
- They are Python. The repo's binding rule is "no Python at runtime"
(
-
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) andrender_prompt(:666). Two details are load-bearing and both were wrong in the first attempt: the catalog quotes its allowed values (it writesjson_text(v), not the bare string), and the prompt is rendered with the model's jinja template, split at the<<decision-context>>sentinel, withenable_thinking: false. With those matched, the two runners agree exactly:coding analysis writing research server /v1/decision0.5432648062705994 0.42237746715545654 0.012004137970507145 0.02235357090830803 llama-parallel-decision0.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/decisiontakes 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 thecontextsarray 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 onepost_jsoncall, 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,strixbranch still unfetchable and irrelevant here).