Skip to content

Engineering Notes

André Borchert edited this page Sep 19, 2026 · 25 revisions
TinyTitan

Engineering Notes

The reference half of the engineering record: traps that have already cost time, measurements that closed a question, observations nobody has explained, and decisions that should not be re-litigated. No tasks live here — open work, one ID per item, is in the Project Tracker.

This page is engineering-facing. The rest of the wiki describes TinyTitan as it ships today; nothing here is a promise or a supported feature. Every claim carries the measurement it came from, and anything unmeasured says so.

Current handover: docs/handover-tinytitan.md in the checkout — the only one, after the three earlier handovers were deleted on 2026-09-14 so nothing competes with it. It starts with the prompt for the next session, and carries the rules a gate or a harness has to follow — installed-models-only verification, when a baseline can be captured, and the launcher mandate for the benchmark scripts.


Plugins

The DeepSeek Harness bundles in plugins/ are clients and sidecars around the loopback server, not the engine: they follow the harness's pinned version instead of the engine's release, and they are not part of what a TinyTitan install ships. What each one does is on its own page — TinyTitan Plugin and LAN Manager — and their open work is in the Project Tracker. What follows is what was measured and decided.

plugins/dsh-lan-manager — TinyTitan DSH LAN Manager (ttlanmanager)

2026-09-18 — the plugin's work is committed on main (from its first commit 0ddce77); the notes below record what was measured as it landed. The plugin mounts a LAN-scoped management API on a running harness's web server; ttlanmanager (sources/TinyTitanFleet/) is the CLI that drives a group of them. The plugins find each other; the CLI is the only thing that prompts or mutates, and it talks to the member that owns the thing it is acting on — a plugin never relays a prompt. User-facing page: LAN Manager.

Working, and measured:

  • Discovery finds tailnet peers on any OS and any continent (the first cut filtered on OS === "macOS", which hid every Linux box in every datacenter), plus Bonjour browse, configured seeds and an opt-in /24 sweep, on a jittered 60 s timer. One cycle over 513 candidates: 2.5 s to find, 8.1 s to probe, and a worst event-loop delay of 26 ms — discovery does not block the harness it runs inside.
  • A member's answer is cached including a failure. Twelve stale .local names cost 30 s per cycle uncached and 0 s on the second cycle cached, which is the difference between a Bonjour-heavy LAN working and not.
  • Endpoints: /peers, /peers/:id, /inventory, /prompt, /prompt-all, /sessions/:id/messages, POST /workspaces, /sessions/:id/archive, /workspaces/:id/delete, /health.
  • 107 plugin tests and 44 manager tests. The source-address fence is the best-covered part: every allowed range, the address just outside each one, the malformed literals that used to be repaired into an allowed range, and the spoofed forwarded header.

Both previously-absent capabilities landed (2026-09-18)

Starting a session is wired (TT-028) — by delegating to the harness's own session controller (sessionController.create({ workspaceId | cwd, agentPreset? })), which composes the agent's world from the preset, resolves the default model, creates the working directory, mints the id and attaches the session to the workspace. Reimplementing that here would be a second copy of harness logic, drifting at every release. A profile composing no controller still answers 501 rather than claiming a session it did not create.

Bonjour is browse-only by decision (TT-029), not by omission: registering _dsh-lan._tcp would advertise a loopback-only API, so a peer would be discovered and then fail to probe. The plugin says so at every mount.

The route was driven end to end against a real model, and both write paths were broken (TT-014, 2026-09-18)

What ran. A private harness (tools/dsh_local.sh, pinned 0.1.6-alpha.2, port 7788) with the plugin installed, and TinyTitanServer serving qwen3.5-4b_4-Bit on 8080 — an install already under models/, nothing fetched. Driven with curl against the running harness:

  • POST /dsh-lan/sessions on the seeded workspace → session-9bb889e8…, preset tinytitan.
  • POST /dsh-lan/promptdelivered: true, wakeup: true, message 87e73078….
  • GET /dsh-lan/sessions/:id/messages → the assistant's "42".

The model call is in the server's own log, so the answer was not a harness cache: chatcmpl-5d9c051f… completed in 297.432 s, prompt=7439 cached=0 completion=3 finish=stop. The 7439 prompt tokens are the harness's agentic system prompt, not the question; the harness also issued its session-title call in the same second, and the server's one-generation-at-a-time queue serialized them. The 297 s is a first-call figure, left standing as an observation rather than explained here.

Two defects, both invisible to the unit tests, both fixed (TT-032).

  1. POST /sessions asked the harness for a service named sessions. That name is @deepseek-ai/dsh-session's raw store, whose create(id, options) mints a bare session; the controller is registered as sessionController (@deepseek-ai/dsh-api-session-controller). The call died with session header id "[object Object]" does not match session id "[object Object]" and created nothing. The plugin's fake had encoded sessions as the controller, so 104 green tests pinned the wrong name.
  2. POST /prompt built a user message with createUserMessage({ content }) and no source. Upstream's factory mints an id and a role but deliberately does not invent a source, and the agent loop reads message.source.kind. Delivery reported delivered: true, wrote the message into the inbox, started a turn and killed it 5 ms later with Cannot read properties of undefined (reading 'kind') — no model call, no error at the route. The session's own turn/end event is the only witness.

The fallback message shape already carried source: { kind: "user" }, so the plugin worked only where the dynamic dsh-llm import failed — the opposite of the intent, and the same "a claim broader than the code" shape as the two defects that reached a release in this project. The receipt also reported wakeup: options.wakeup !== false while Agent.followup(message) takes no such option and always wakes; it now says true as a fact and the dead option is gone.

The regression tests pin the service name (the fake records every service read and asserts sessions was never asked for) and the source on both factory paths (a stub @deepseek-ai/dsh-llm module drives the primary path the suite had never exercised). 105 plugin tests pass.

Also driven live, all sound: POST /sessions/:id/archive (archived: true, and the session leaves GET /sessions), POST /workspaces for a throwaway folder, POST /workspaces with "startSession": true (returns the session and preset), and POST /workspaces/:id/delete (with archiveSessions, which reported the archived session id).

A session's history is readable with no live agent (TT-030, 2026-09-18)

GET /sessions/:id/messages used to answer 404 whenever the session had no live agent, so an idle or archived session — the exact thing a fleet audit asks about — could not be read at all. It reads storage now.

The cold path is deliberately the harness's own: sessionQuery.readSession() loads and replay-validates the stored log, and the plugin hands that { header, events, inheritedEventCount } back to the sessions service's prepare() with eventState: "detached" — the same call sessionQuery makes internally to build an observation — then calls the same deriveMessages() the live path uses. Surface markers and compaction rules therefore stay the harness's, no frame parser is written here, and the plugin still imports no harness internals.

Verified live against the private harness with no TinyTitan server running: after a restart, so no agent was live, the TT-014 session read back its four messages, including the assistant's 42; an unknown id is a clean 404 no session …; and a freshly created session still takes the live path. A profile that composes no cold reader answers 503 and says so, rather than reporting an empty conversation. 107 plugin tests pass.

The mesh has seen a second member

2026-09-18. Two throwaway harnesses on this machine — separate DSH_HOMEs, ports 3199 and 3198, each seeded with the other — found each other in both directions (source=seed, 9–11 ms). The first was booted before the second, so its boot cycle could see nothing; the fact that it found the peer anyway proves the periodic cycle, not just the boot-time one.

Two things the run taught. The interval has to be changed in the row config rather than the environment, because the shipped patch writes discoveryIntervalSeconds: 60 and a row value beats the documented DSH_LAN_DISCOVERY_SECONDS fallback — that was TT-031, fixed the same day: the two pinned fields are commented in the patch now, both fallbacks apply, and test/patch.test.js fails if a field with an environment fallback is ever written back in. And a peer is a member only after a GET /inventory that returns 200 with the shared key: a machine in the group with the wrong key is reachable, logs as 401, and is deliberately not a peer.

Bonjour stays browse-only (TT-029, 2026-09-18). The plugin browses _dsh-lan._tcp and nothing registers it, so the LAN source finds only a third-party advertiser. Registering the service was rejected rather than deferred: Bonjour advertises this host's LAN address, and the API answers on loopback only (TT-020), so a registered service would be a false beacon — a peer would appear in the group and then fail to probe, which costs more than an empty browse. The plugin says so in the log at every mount, and registration belongs with a reachable bind.

Verified against the harness we now pin. tools/dsh_local.sh moved from 0.1.5-rc.2 to 0.1.6-alpha.2 on 2026-09-18 — an alpha, and npm agrees: latest and next are still 0.1.5-rc.2. Two consequences were checked rather than assumed: the notice version the pin "moves with" is unchanged (2026-08-13.1), and the alpha falls below ^0.1.6-rc.1 in semver, so dsh-tinytitan's dsh-compaction-basic peer range was widened to ^0.1.5-rc.2 || ^0.1.6-alpha.1.

The supported range is now exactly the pin (2026-09-18). Those ranges were widened only to include the release; they also admitted everything else, and "supports this alpha" is not the same claim as "supports whatever npm resolves". So dsh-compaction-basic and dsh-agent-presets are exact 0.1.6-alpha.2, and both plugins refuse to run on any other harness — older, newer, a build from main, or a version that cannot be read at all. dsh-lan-manager carries the same gate, and its constant is asserted against the launcher's pin so the two cannot drift apart. plugins/*/test/support.test.js fails if either peer range, either constant, or the pin disagrees — and CI now runs those suites, which nothing ran before.

Neither gate throws. A refusal is one log line naming both versions followed by a return: no route, no preset, no watcher, and no writes into the harness home. DSH still boots, every other plugin still loads, and removing ours leaves nothing to undo. An unreadable version is refused rather than assumed, because a plugin that writes into ~/.dsh has no business proceeding on a harness it cannot identify.

And the refusal had to move to stderr (2026-09-18). Booting a throwaway harness in a temporary DSH_HOME — with both plugins installed, on an unsupported version — found what reading had not: the harness collects a plugin's log records into its startup diagnostic and prints them only when the boot itself fails, and its startup exporter is registered at levels: { default: 2 }, so ctx.logger.info reaches nothing at all. The first gate therefore refused silently: DSH was fine and nothing was written, but the operator had no way to learn why the plugin had stopped. Both refusals now go to stderr as well as the host logger. The throwaway run also showed the LAN manager never activates in a headless profile at all — it injects webServer, so it sits pending and its gate is only reachable behind a web profile, which is where it is tested.

Where the gate stands (2026-09-18). Both installed copies are refreshed to it. The global harness was reloaded and took 0.1.6-alpha.2 without a refusal — its settings.yaml was rewritten at boot by the route step while the agent preset was left untouched, which is the gate passing and the preset generation staying idempotent. The private harness's copy is refreshed but idle until its next start. The four fleet nodes run plain DSH with no TinyTitan plugin, so there the gate has nothing to refuse.

Deliberate, so it is not re-litigated:

  • The group key ships with a public default, so by default it groups rather than protects. That was an explicit operator decision; change DSH_LAN_KEY on a network that is not solely yours.
  • A plugin never sends a prompt or mutates another instance. Only the CLI does.
  • Gossiped addresses are candidates, validated like any other against the LAN/Tailscale fence before anything is dialled, and the list is capped.
  • discoverSubnet is off by default: it is the only source that touches hosts which never opted in.
  • ttlanmanager is not a release product. It is an operator tool and stays out of release.sh's PRODUCTS.
  • The manager's scanner was deliberately not built on worker_threads: it never blocks the loop, and libuv's threadpool is per process, so a worker would share the same four threads and buy nothing.

plugins/dsh-tinytitan — the bundle behind the browser chat window

The preset and route bundle that the launcher's --web installs. Its open item, publication, is TT-019 in the Project Tracker.

Publishing — the listing attempt closed without landing

2026-09-18 — status corrected. The research is settled and the code half is done; what changed is the submission itself. PR #5094 was closed on 2026-09-15 with no comment, and the catalogue carries no entry for this plugin (data/plugins/Pummelchen__TinyTitan--plugins-dsh-tinytitan.yml → 404). It is not on npm either (dsh-tinytitan → 404), which was always optional for a listing.

The submission itself is TT-019 in the Project Tracker: either resubmit the entry — the file is ready in docs/dsh-plugin-publication.md — or take the other route the research already found (npm, or a git host). What follows is the research, so it is not repeated.

What is settled, so it is not re-researched:

  • Where a plugin is published. Upstream has no registry and no catalogue; dsh plugin --profile <name> … forwards to pnpm in the profile directory. Distribution is therefore npm, a pnpm pack tarball, or a git host — the last needing a prepare script and the user's allowBuilds permission. Discovery is the community list awesome-dsh-plugin/awesome-dsh-plugin, whose whole submission is one generated file, data/plugins/<owner>__<repo>.yml: the handover's "one-file catalogue PR" is that repository, not upstream.
  • The licence is MIT on purpose, recorded with its reason in the plugin README: the package is an independent work that talks to the server over its HTTP API and copies nothing from the fork's lineage, so it is deliberately not aligned with the repository's Apache-2.0.
  • The code half is closed (2d9daa9): the route refresh no longer needs a checkout — generate.js runs the discovered TinyTitanServer --catalog --models-dir <dir> — and test/generate.test.js pins the generated block byte-for-byte to tools/dsh_route.sh --print.
  • Readiness passed against the list's checklist before the first attempt: dsh.bundle, the plugins/ subpackage, the repository field, 34 tests, repo age, the dsh-plugin topic, and the npm name unclaimed. One trap the dry run found, worth keeping: build-site.mjs parses the generated READMEs rather than data/plugins/*.yml, so a yml-only submission builds a site missing its own entry until the regeneration step runs — a local row count one below readEntries() is that, not a dropped entry.

Decisions, so they are not re-litigated

The wiki Roadmap page was retired on 2026-09-18. Planned work is recorded one item at a time in the Project Tracker; there is deliberately no separate roadmap to re-create.

What does not transfer from the KV-streaming work

Recorded so it is not re-litigated: the transport design (pinned host to VRAM over PCIe, cudaMalloc pools, Unified Memory interplay) and the residency cost model. Unified memory has no VRAM/host split to manage, our bottleneck is NVMe to RAM under F_NOCACHE, and their planner assumes every KV page is needed every token. Routed experts are sparsely selected — ten of 512 — and reused, so our problem is prediction, not bounded full scans.

Requests from issue #5 that do not apply here

The icon request in issue #5 is done and shipped in 5.4. The same issue listed three Fieldfare features the requester wanted here, and all three were features of a GUI front end — which TinyTitan does not have: it is an engine and a loopback server, and the client owns everything with a window. They are recorded as closed rather than open, so nothing here sends a future session after a front end:

  • Image upload — a prompt that carries an image. Every model here is text-only, the Qwen 3.5 9B among them (its vision tower is deliberately not repacked), so nothing in the current engine can consume an image with or without a UI.
  • Conversation history — durable, resumable conversations. This was a renderer feature. A client gets the equivalent from the server's stored Responses and its prompt-state reuse; the engine persists no conversation of its own.
  • LaTeX rendering — typeset math in responses. This was a renderer feature; typesetting is the client's job now.

Not scheduled, not sized, and not a commitment.


Blocked work

The one item still blocked is upstream: the LAN manager's remote access, TT-020. What follows is why.

Three hardware-blocked items were closed on 2026-09-19, not resolved: TT-021 (validate on M1/M2/M4/M5/M6) and TT-022 (ANE behaviour across chip generations) needed machines that do not exist here, and TT-023 (long-context parity beyond the exactness window) needed a machine that can hold the ~360 GB bf16 reference, which this one has no disk for. The two caveats below therefore stand: they are why the tasks existed, and closing the tasks did not answer them.

The chip-range claim is the one to watch. The README and wiki state M1 through M6; all published measurements come from a single M3. Nothing is known to be wrong on other chips — nothing has been tested on them. Treat the range as a design intent, not a measured result, until someone runs the benchmark suite on other hardware.

Long-context behaviour for Qwen 3.8 is verified only at a lowered budget, where the sparse path engages after 67 tokens. The arithmetic is identical at 2,051, but nothing has been diffed against a reference at that length because the reference cannot hold this model.

A related harness limit, not hardware: steady-state decode past the ANE handover transient had never been measured, because every decode figure in the ANE work is a ~60-token window (the pinned benchmark prompt hits end-of-turn early). Measured 2026-09-18 — see Steady-state decode after ANE prefill below: on AgentWorld 35B-A3B 4-bit it costs +0.7%, i.e. nothing.


Unexplained observations

Open questions with real measurements behind them and no accepted cause.

Two engines, one install, two answers at 4-bit

2026-09-11, "Capital of Paris" matrix, three repeats each. Qwen 3.5 2B 4-bit, asked the ambiguous prompt, answers degenerately on the GPU path -- "The capital of France, and the capital of the country France, is Paris. Paris is the capital and capital city of France. It is the capital of the French Republic and the capital of the French Republic." -- and cleanly on the CPU path with the same weights: "The capital of France is Paris. Located on the left bank of the Seine River..." (51 tokens). The GPU text is identical in all three repeats, and the plain control question is clean on both engines ("The capital of France is Paris.", 8-9 tokens), so this is not sampling noise and not the prompt alone.

The port's equivalence gate is what makes this a puzzle rather than a bug report: it drives both engines from the same prompt and the layer dumps agree -- L0-L2 matched the Python reference to bf16 rounding, and after the width fix L3's v matched exactly. Greedy decoding needs only one near-tie to diverge, and at 4-bit this one diverges into a repetition on the GPU path. Measured 2026-09-18 — see TT-002: the 2B's two answers are one near-tie below: the divergence is the fifth generated token, , against is after "The capital of France", and the cross-engine logit spread there is 0.37–1.44, well above a 0.41 margin. The gate compares dumps, not the argmax of the final logits, which is why it could not see this. Measurements: One Prompt, Every Model, prompt 1, rows 1-2.

AgentWorld 8-bit thinks with the switch off

2026-09-11, "Capital of Paris" matrix. Qwen AgentWorld 35B-A3B 8-bit, asked that prompt with the server's --reasoning off, spends its whole 128-token cap inside a <think> block and stops there (finish_reason: length); the re-run reproduced it exactly (128 tokens, 9.02 tok/s). The 4-bit install under the same server answers in 8 tokens (stop).

The template is not the difference, and neither is the request path: both installs carry a byte-identical chat_template.jinja, which with enable_thinking: false renders a closed <think>\n\n</think> block, and the only differing sidecar is config.json's quantization spec (4-bit honours two 8-bit overrides -- embedding and head -- and 8-bit is uniform). So the 8-bit weights reopen a block the prompt had closed. It is not a runaway either: at a 512-token cap it stops by itself at 482 tokens with the answer after the closing </think>.

What the model does to a client is fixed (C90): a thought the model starts while the switch is off is reasoning, not the answer, so it rides reasoning_content and the server logs thinking off, but the model wrote N characters of reasoning on the request's line. Before that fix the scaffold arrived as content and a client capping tokens got a thinking transcript where it expected an answer.

What stays unexplained is why two widths of one checkpoint disagree this sharply, and what the catalog should advertise: /v1/models offers off for this install as for every other Qwen 3.6-family one, and for the 8-bit install off still means "think anyway" -- it now says so in the log and in reasoning_content rather than silently. Three repeats make the shape of it sharper: the thought appears on the ambiguous prompt only (3/3, 477 characters each time), never on the plain control question, and never on the 4-bit install of the same family. No cause is accepted; the report is One Prompt, Every Model and the raw rows are in benchmark/benchmark-results/capital-of-paris-20260911T1935/.

The ANE decode gap

Three hypotheses are tested and dead:

  • handover wiring latency — 136 ms, cannot account for it;
  • expert-cache eviction during prefill — wiring the cache across prefill measured identically, 3,139.9 ms of expert-I/O await against 3,128.7 ms;
  • extra expert I/O — this was the documented claim and it is false. Decode reads the same bytes either way: 9.18 GiB at 70.5% hit with ANE off, 9.16 GiB at 70.6% with it on.

What differs is how long those identical reads take to complete: 859 ms of await against 3,129 ms — same reads, 3.6x the wait. The fourth candidate was that Core ML's E5RT arenas are not actually returned at releaseModels() and keep costing memory bandwidth or residency through decode. Tested 2026-09-18 (TT-004) and dead: the arena measures ~152 MiB and is returned synchronously, with nothing surviving into decode — see TT-004: the E5RT arenas are returned below. The 3.6x wait now has no accepted cause.

Residual −14.7% at 4-bit

After the allocation-time pin (1b3fdfe) the 4-bit ANE decode gap went from −53.6% to −14.7%. Separated 2026-09-18 — see The ANE window penalty is a re-warm, and the pin removes it below: the pinned configuration performs no unpin at all, so there is no re-warm to find, steady state is ~0, and the remainder was drift.

MTP width-2 verify cost

A width-2 verify measured 1.965x a single token where the union model predicts under 1.3x. Attributed 2026-09-18 — see Where the MTP verify pass goes below: the union comes in at the model's price, and the rest is the prefill path's non-expert kernels (1.70x where the model assumes 1.0x) plus ~200 ms/pass of host and commit overhead.


Traps that have already cost time

Each of these produced a confident wrong conclusion at least once.

  • find does not follow a symlink given as its own starting point, and a release shipped without its resources because of it. release.sh staged the products with cp "$BIN/$product" and the bundles with find "$BIN" -maxdepth 1 -name '*.bundle'. Both are correct as written — except that this toolchain's $SCRATCH/release is a symlink to out/Products/Release, which cp follows and find does not. The bundle copy matched nothing, no gate noticed (nothing asserted the archive's contents), and the 5.6 release was published with six executables and no TinyTitan_TinyTitan.bundle: every downloaded binary died with Fatal error: unable to find bundle named TinyTitan_TinyTitan on the first model load. The script now resolves the products directory physically, refuses to stage without that bundle, and greps the finished archive for it. The rule this cost a release to learn: after publishing, download the artifact and run it from a clean directory. Every gate in the runbook passed while the published binaries were unusable; the asset was repaired from the same tagged build, without moving the published tag.

  • Renaming the checkout folder invalidates more than the model receipts. .build's debug half is compiled against absolute paths, and after Downloads/NVMAIDownloads/TinyTitan it held 7,312 files naming the old path, so swift test died with precompiled file …_Builtin_stdbool….pcm was compiled with module cache path '/Users/andreborchert/Downloads/NVMAI/…' before a single test ran. release.sh surfaces that as swift test did not report a passing run, which reads like a failing test and is not one — check the test log for a compiler error. The release build had been rebuilt after the rename and was fine; only the debug tree was stale. The fix is to remove .build/arm64-apple-macosx/debug and let it rebuild: it is not a cache purge to make a gate pass, it is invalid build output. Every install receipt is invalidated by the same rename, for the same reason (absolute paths) — see Renaming an install invalidates its receipt below.

  • Benchmark noise is ±15% run-to-run. Only interleaved within-run comparisons are load-bearing. Absolute numbers are not comparable across runs and must be re-taken on a verified-idle machine before publication.

  • Sequential config sweeps fake wins through page-cache warming. Interleave arms, discard a warmup per arm, and always re-measure an unchanged config as a control.

  • GPU contention looks exactly like a regression. A background game halved decode once. Check ioreg before believing any regression.

  • A full swift test can starve a task for tens of seconds. Wall-clock waits are wrong by construction: a test that sleeps for a fixed interval and then asserts is a flake, so wait on a signal or inject the clock instead.

  • Top-k A/B is chaotic. Compare sparse-selection paths at the first layer on shared input, never on downstream logits.

  • Dispatch-count arithmetic predicts cost badly here. Folding two gates removed 192 encoders per token and recovered 0.59 ms, not the 3.8 ms that the usual ~20 µs per encoder implies — the real figure is nearer 3 µs.

  • Family constants silently carry over. Four of five Qwen 3.8 porting bugs were Qwen 3.6 constants reused unchanged. Audit every baked function constant when adding a family.

  • The parity harness reads the same weights as the runtime. Quantization is common-mode by design, which is a feature — but so is any weight-mapping or shared-architecture error, and those are then invisible. Ground-truth against transformers' own modules and against the source checkpoint, not only against the runtime.

  • Two false alarms from the Qwen 3.8 debug, both measurement error, both worth not re-chasing: a "layer 0 GDN cosine 0.803" that compared a first-token reference against a position-1 dump, and a "PLE conv 2.6x magnitude" that compared a pre-silu value against a post-silu buffer.

  • A model install in a synced folder can be online-only, and then the golden gate fails with a runtime-looking error. Found cutting 5.3. Dropbox had left seven installs with zero blocks allocated (fileproviderctl evaluate <path>isDownloaded = 0), and every packed-expert read returned parallel expert read failed: Operation timed out, which release.sh reports as golden baseline mismatch (<target>). It is neither a mismatch nor a runtime fault: cat on one of the files fails the same way, in 0.6–12 s. Two things make it look like a bug in the model — the message names a read failure inside the streaming pool, and the first target in the list still passes because that install happened to be local. Diagnose with find models -type f -size +1M -exec stat -f "%b %z %N" {} \; | awk '$1*512 < $2*0.9' before touching the runtime. Materializing is the fix (cat <file> > /dev/null, which is what the provider hydrates on, at ~2–15 MB/s); it needs the full install's worth of free disk, and a volume that is too full makes the provider refuse the fetch outright, so the failure mode appears with no disk-space message at all. Seven installs cost 24 GB to hydrate; the 125B 8-bit install would have needed 134 GB against 123 GB free, so qwen38-8 was skipped by name for the 5.3 release rather than deleted from the gate's list — see TINYTITAN_RELEASE_SKIP_GOLDENS in docs/release-process.md.


  • A model that loads and generates plausible text is not evidence it was built correctly. KAT's first install passed every structural check and produced nonsense, because the converter filed routed experts by arrival order instead of by index: the checkpoint's index sorts lexicographically, so layer 0's experts arrive 0, 1, 10, 100, …, and 12 of 40 layers also split them across two shards. Every expert's bytes matched the checkpoint, so nothing downstream could see it. Two things stand in the way now — tools/lint.sh converter asserts the fused axis is index-ordered, and the converter's --plan prints whether the converted experts land under switch_mlp — but the general lesson is the one to keep: had the manifest cap been permissive, the wrong install would have loaded, written plausible prose, and silently ignored SSD streaming.

  • A package update replaces the files under a running harness, and can take it down. Confirmed the hard way on 2026-09-18: npm install -g @deepseek-ai/dsh@0.1.6-alpha.2 changed 488 packages while node /opt/homebrew/bin/dsh web was serving a live session, and that process (pid 15900) ended inside that window. It left no crash report, so it was a signal or a clean error exit rather than a segfault — the exact trigger is not known, and it should not be guessed at. What is not in doubt is the shape: a Node process whose node_modules is replaced beneath it has nowhere to load its next not-yet-imported module from. The session came back only because the harness was restarted, and DSH resumes a session from its log — so restarting the harness you are talking through costs the session, not the conversation. The rule: replace the files, then restart, and do not update the harness that is hosting the work you are doing. A source tree is no different — pnpm install && pnpm build under a live dsh web rewrites apps/cli/lib and node_modules exactly the same way.

  • npm install -g can exit 0, report "changed N packages", and still be an incomplete install. npm 11 blocks lifecycle scripts unless they are allow-listed, and DSH needs five of them. The one that matters is silent: node-pty's spawn-helper — the 0.1.6 sidebar terminal — keeps its prebuilt binary but loses the executable bit, and @deepseek-ai/dsh-subprocess-local's postinstall is what restores it. The version reports correctly and the harness boots either way, so nothing looks wrong until someone opens a terminal. Finish it with npm install -g --allow-scripts=@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs @deepseek-ai/dsh@<version> and check that node-pty/prebuilds/darwin-arm64/spawn-helper is -rwxr-xr-x.

  • A hand-set baseURL can 404 every model call, because the harness defaults to the Messages protocol. Found on 2026-09-18: all four fleet nodes (node1node4) were failing every turn with DeepSeek Messages request failed (404), which the UI shows as HTTP_404 with the agent preset loading fine and the system prompt and context injections succeeding — so it reads as a model problem, not a configuration one. Each node carried llm-deepseek: { baseURL: https://api.deepseek.com/v1 }. dsh-llm-deepseek defaults to protocol: messages (lib/index.js:3004), whose official root is https://api.deepseek.com/anthropic (MESSAGES_BASE_URL, line 2952); https://api.deepseek.com/v1 is the chat-completions root, and messagesApiRoot() leaves a path already ending in /v1 alone — so the requests went to /v1/messages, a path DeepSeek does not serve. Settled by request rather than by reading: /v1/messages404, /anthropic/v1/messages200 with deepseek-flash. All four were corrected to the anthropic root (each beside a settings.yaml.bak-*) and verified end to end — dsh headless "Reply with exactly: OK"OK on every one. MacBook-AB was never affected, and that is the part worth keeping: its DeepSeek config is generatedtools/dsh_route.sh writes an llm-pi-ai route and no llm-deepseek block at all, so it inherits the correct default. The nodes were provisioned by hand in one sitting (all four settings.yaml mtimes fall within 11 seconds of each other, 2026-09-15 16:27), which is exactly why one wrong value reached all four. A baseURL copied from generic DeepSeek/OpenAI docs is wrong here unless protocol: chat-completions is set alongside it; generated config does not make that mistake, and config typed once per machine does. One member is still unaudited: node5 (100.99.92.66) is up with sshd listening but refuses the key for every username tried, so what it runs is not known.

Renaming an install invalidates its receipt, and re-issuing loses the source

Two facts about verified-install.json that cost time on 2026-09-01 and will confuse a provenance audit later.

The receipt binds to an absolute path. Renaming or moving an installed model makes it refuse to load: "the receipt was issued for <old> but the model is now at <new>". The runtime is right to refuse -- it will not run an unverified payload -- and the error names both paths and the exact fix. Repair is TinyTitanRepack --verify-install --input-gturbo &lt;dir&gt;, which re-hashes in place with no re-download: a few minutes for a 162 GiB install, seconds for a 1.4 GiB draft head. Plan the re-issue as part of any rename, not as a discovery at load.

A re-issued receipt drops sourceRepoID. After re-issue the field reads None and toolVersion becomes TinyTitanRepack verify-install. This is not damage from the re-issue path being wrong -- Ornith installs nobody has touched show the same, having been verified at some point too -- but it means a re-issued receipt records what the payload hashes to and no longer where it came from. Provenance survives in sourceRevision, which keeps the snapshot hash, and in manifest.json's own sourceSnapshotHash.

Practical consequence: do not read sourceRepoID as evidence of origin. For the Qwen3.8 pair it is None on both the target and the draft head, and both were nevertheless built from Qwen's own bf16 release -- the snapshot hashes are what prove it.


8-bit for Qwen3.8-Flash-Next, and the seven families kept at 16-bit

Shipped. 8-bit was refused at load from 3e86f78 until the kernels were given a path for it; it is now the model's golden build.

Why it was refused, and what fixed it

HyperConnection, PLEBlock and QSAIndexer each constructed a DequantInt4GEMV with no affine variant and no bit-width parameter, so an 8-bit install handed them 8-bit weights that they read as nibbles. It did not throw: the install built, passed the snapshot verifier, loaded, answered " Paris" and then degenerated. Each kernel now dispatches on width.

The build pipeline was verified by that exercise, which was the point of running 8-bit before the 4-bit rebuild. It also found five defects, four latent in code predating the day: an argument-parser loop that never terminated, a tokenizer requirement that refused draft heads, an entryKind double-optional that made a "file exists" test true for absent files, and a converter that had never fetched tokenizer files -- every snapshot it ever produced would have been rejected at repack, hidden because the 4-bit one was hand-repaired.

The lesson is not "be more careful". These paths had one caller and one hand-held run; every assumption in them went unchallenged until a second consumer arrived. The same shape recurred four more times during the promotion work below.

The 16-bit promotion, and why

8-bit exists for a different user than 4-bit does: someone with a correctness- sensitive, time-insensitive job -- reviewing a design, fixing a subtle bug -- who will run the model overnight or on a dedicated machine and needs to trust the output. For that user, speed is the cheap currency and quality is the dear one. So eight tensor suffixes across seven families are carried at bf16 even in the 8-bit build, chosen by per-tensor error from tools/precision_probe.py -- the tensors where quantisation error changes a decision rather than nudging a value:

family params what it decides
mlp.gate 62.91M which experts run
ple.key_proj 26.21M n-gram retrieval keys
indexer.index_q_proj / index_k_proj 19.66M which KV entries QSA attends to
linear_attn.in_proj_a / in_proj_b 8.85M GDN gating
*_hyper_connection.block_inject_weight 3.93M the write gate on every layer
mlp.shared_expert_gate 0.12M 1 row; highest measured error
total 121.7M

Every one of these is a resident weight. model_weights.bin grows by about 114 MB and the streamed expert files are byte-for-byte unchanged, so this does not touch the decode I/O floor in Decode throughput has a hard ceiling at all -- the cost is RAM and a little compute, not bytes.

Promotion is decided in prepare_qwen38.py:PROMOTE_TO_BF16_AT_8BIT, checked before the slot rules, because the runtime reads a promoted tensor's width from its own dtype rather than from the slot it nominally belongs to.

What promotion cost in defects

Four separate consumers had to be taught the same lesson, each found only after the previous fix: encodePrimaryGEMV (8-bit builds skip the fused GDN path entirely), encodeAffineProjection's batched MPP/QMM, the prefillGateProjection closure, and the PLE batched projection closure. Each one read scale and bias pointers out of a struct that a promoted tensor does not populate.

Three of the four surfaced as NaN at L0_attn_out and were quick. The fourth was not: it produced fluent output on most prompts and truncated on some. "What is a mutex?" and a haiku both answered correctly while "Explain what a mutex is and when you would use one." ended the turn after one token -- a corrupted PLE injection does not always overwhelm a strong prior. Probe count did not find it; prompt diversity did. Sequential prefill was correct throughout, which localised it to the batched path both times.

Verification standing behind the golden

  • install audit: 497 8-bit entries, 582 bf16, 0 problems
  • full-stack parity against the numpy reference, all 48 layers, stack-out cosine 0.99999
  • 10-prompt sweep on the batched path, all coherent (arithmetic, code, translation, factual recall)
  • qwen38-8 golden captured and re-checked identical; qwen38-4 golden still reproduces, so the shared-kernel changes did not regress 4-bit

Not measured: the decode throughput cost of the promotion, and the end-to-end quality gain. The first is predicted small (compute-only, no extra bytes) but the machine has been contended for days and this project has a standing rule against timings taken under contention. The second was argued from per-tensor error, not benchmarked -- there is no coding-quality harness here yet, and this page's own rule is to label that rather than imply a number.


Decode throughput has a hard ceiling

docs/qwen38-decode-20tps-concept.md. Decode reads 480 expert records of 2.77 MB per token -- 1.33 GB if every read missed. The cache measured saturating at 78% around 128 slots, and effective read bandwidth measured 3.6 GB/s, so 292 MB / 3.6 GB/s = 81 ms of I/O alone: a 12.3 tok/s ceiling for perfect engineering at the shipped geometry.

Overlap, prefetch depth, dispatch fusion and kernel work hide latency. None reduces bytes, and the floor is bytes over bandwidth. Engineering is worth 6.82 -> ~12 tok/s (+75%); 20 tok/s needs an 86.5% hit rate at that bandwidth, or 7.2 GB/s at that hit rate, and only three things cross it: more resident memory, faster storage, or reading fewer experts -- the last of which changes the model's output.

Provisional. The decode split for this model has never been measured; everything but the cache curve and the 3.6 GB/s is derived. The concept states what would falsify it, and this project has twice designed against estimates wrong by more than an order of magnitude.


The expert-cache wiring is a tri-state now (TT-008, 2026-09-18)

TINYTITAN_KEEP_WIRED used to be read as == "1" in three places — ModelProfile.resolve, RealForwardRunner.keepExpertCacheWired and Model.keepExpertCacheWired — so the only thing it could express was "on". Every table row that streams experts already sets keepWired, so on this 24 GB Mac the 12 GiB cache could not be paged out at all: 14.72 GB RSS, 11% system memory free, CoreAudio glitching while a model was loaded.

It is a tri-state with one reader now. ExpertCacheWiring.override(environment:) returns true for 1, false for 0 and nil for anything else; resolve folds it over the row in ModelProfile.resolve, and the resolved profile carries the answer. The two duplicate statics are deleted rather than kept in step — the runner reads profile.keepExpertCacheWired, and Model.openLayerLocked reads the flag setKeepExpertCacheWired already set from that profile — so the drift class is gone, not just this instance. releasePrefillCacheWiring is what makes =0 pay: nothing pins at allocation, prefill's release is a real munlock, and the first decode token pins exactly as before — the trade the variable was supposed to offer.

Verified. =0 on qwen3.6-35b-a3b 4-bit — a row that wires it — logs keep_wired=false under TINYTITAN_RUNNER_STATS, =1 logs true, and an unrecognised value leaves the row alone. 1,407 package tests pass (two new ones pin the tri-state vocabulary and both directions), tools/lint.sh is clean, and the golden check is byte-identical on qwen38-4 (the broad gate: hyper-connections, PLE and QSA), qwen36-4 and agentworld-4. ornith-8 is reported not checked — its install is absent and nothing is fetched to change that. The default path is provably unchanged: with the variable unset the old env == "1" || profile reduced to profile, which is exactly what the call sites read now.


The goldens only ever drove the CLI

Found 2026-09-02 while checking whether the 4-bit MTP head pairs with the promoted 8-bit target. The pairing failed with

tensor ...layers.0.mlp.gate.weight size 2621440 does not match expected 1310720

which is exactly the promoted bf16 router -- 512 x 2560 at 2 bytes against the 8-bit slot's 1. It looked like a fifth unpatched consumer of a promoted tensor.

It was a stale binary. TinyTitanServer was 15 hours older than the last promotion fix: the whole four-bug loop in the 8-bit Qwen3.8 work had rebuilt TinyTitanCLI and nothing else. On a rebuild the server loads the promoted model, and the 4-bit sidecar attaches to it and speculates correctly (mtp=on:384MiB, emitted_per_pass=2.000). Mixed-width pairing is supported by design -- sharingTargetWeights checks family, hidden size, vocab and lineage, never width.

The reason it went unnoticed is structural, not careless. golden-baseline.sh ran TinyTitanCLI exclusively, and its contention guard refuses to run while a server is up, so no gate in the repository had ever executed a line of the server path. It could drift arbitrarily far behind and every check would still be green.

tools/golden-baseline.sh --server now closes it, and deliberately stores nothing. A recorded server baseline would need re-capturing after every intentional numerics change and could go stale exactly as the binary did. Instead it renders one message list through both front ends at the same settings and requires the text to match, so:

mode catches
the stored file the runtime changed, against its own past output
--server the two front ends diverged, on one build

Both legs go through --messages-file: the server has no raw completions route and always applies the chat template, so comparing it against raw --prompt output would have compared two different prompts. Verified on qwen38-8 -- agrees, 439 bytes. The comparator was checked separately to actually fail on differing text, because a gate that can only print "ok" is worse than none.

The generalisation, third instance now: verification that drives one caller proves one caller. It was true of the family kernels, of the four promoted-tensor call sites, and now of an entire front end.


The ThreadSanitizer gate has one suppression, scoped to the top frame (TT-001, 2026-09-18)

The thread-sanitizer CI job had been red on some commits and green on others. It was never diagnosed from a CI log here, so the first job was to make it reproduce: six instrumented TinyTitanServerTests processes at once, which report the same race in several of them. A single process is almost always clean, which is why the gate read as flaky rather than broken.

The report is always this shape:

Write of size 8 … by thread T3:
  #0 closure #1 in SSEOutbox.next() HTTPServerSupport.swift
Previous read of size 8 … by thread T72:
  #0 UnsafeContinuation.resume(returning:) <compiler-generated>
  #1 closure #1 in closure #1 in EventLoopFuture.get() AsyncAwaitSupport.swift:90
  … SelectableEventLoop.run …
Location is heap block of size 1016 allocated by thread T2:
  #2 closure #1 in closure #3 in ServerHTTPHandler.handleResponses(…) Responses.swift:108

It is not a race in SSEOutbox. The racy block is the drainer task's own heap allocation — the Task { … } closure context created at Responses.swift:108, the allocation the async frame is carved from — not the outbox, which is a separate small object. Every SSEOutbox field (frames, pendingDrain, closed, overflowed, abandoned, closeAfterDrain, drainCancelled) is only touched under its NSLock, and all four resume sites were read looking for a double-resume path: there is none. What the two stacks show is the NIO event loop resuming the drainer (EventLoopFuture.get(), from writeSSEChunk) and that task, woken on a GCD worker, writing its own frame: the happens-before edge of a swift_task_switch that TSan does not see when the resumer is not a Swift task. Contention is what makes it land, which is why CI — and six local processes — expose it while one run does not.

Decision: one suppression, race_top:UnsafeContinuation.resume, in tools/tsan-suppressions.txt, wired into the CI job through TSAN_OPTIONS. race_top matches only the top frame of one of the two stacks, so the rule fires on Swift's own continuation-resume helper and nothing else: a real race in TinyTitan code still has a TinyTitan top frame and still fails the job. Measured: with the file, a six-process storm reports nothing and TSan prints Matched 1 suppressions on the runs that would have fired; with a pattern that matches nothing, the same storm reports the race three times out of six.

tools/tsan-storm.sh is the reproduction, kept so the decision can be re-checked — --no-suppressions runs the raw storm — and the entry should be deleted if a toolchain or swift-nio update makes it clean. This is a toolchain false positive worth reporting upstream; it is not in this repository's gift to fix without giving up the async→NIO bridge the whole streaming path is built on.


Steady-state decode after ANE prefill costs nothing (TT-007, 2026-09-18)

Every ANE decode figure in this project was a ~60-token window, because the pinned qualification prompt asks for a 40-word summary and hits end-of-turn at about that point; --max-new never bound. Such a window contains the one-time handover cost, so those figures were upper bounds and the steady-state cost had never been measured at all.

The prompt fix. The qualification body plus "Continue … write at least 700 more words … without stopping early" generates to the cap instead of stopping: stop=maxTokens, 448 tokens asked for and 448 delivered. That one prompt change is what makes the measurement possible.

The measurement. benchmark/ane_steady_state_decode.py runs the same arm at two generation lengths and differences them, (new_big − new_small) / (decode_big − decode_small), so every one-time cost both runs pay — first token, wiring walk, expert-cache re-warm — cancels exactly. Arms alternate gpu ane ane gpu. On AgentWorld 35B-A3B 4-bit, an 11,948-token prompt (three ANE chunks), greedy, two runs per arm and length:

prefill median steady state [64, 448] window ≤64 tok window ≤448 tok
GPU 622.5 s 7.21 tok/s 6.66 tok/s 7.17 tok/s
ANE 187.7 s 7.26 tok/s 6.79 tok/s 7.19 tok/s

ANE prefill costs +0.7% of steady-state decode — nothing, inside the run-to-run spread. The 64-token windows show no penalty either (+1.9%). The ~60-token upper-bound worry does not survive a generation long enough to leave the window: there is no steady-state decode cost to find on this install, at this prompt length, with the shipped wiring.

One new trap: the first ANE prefill of a process pays a compile warm-up. It measured 390.44 s against 187.83 s for the identical second run — 2.1x — and the two later ANE runs sat at 185–188 s. Any single ANE prefill figure taken from a cold process overstates it, and a benchmark that does not discard a warm-up per arm will report the compile cost as the sidecar's speed. The qualification harness already discards one warm-up per arm, which is why its numbers never showed this.

Caveats, so this is not over-read. Two runs per cell, one block (medians of 2). The GPU arm's decode follows a ~10-minute GPU prefill, the ANE arm's a ~3-minute ANE one, so a thermal advantage for the ANE arm cannot be excluded — the earlier Ornith figures (8.70 against 8.68 tok/s) showed no such asymmetry. Ornith, the install every earlier ANE number was taken on, is not installed; this uses AgentWorld 4-bit, the same qwen36 family and geometry. And AgentWorld's row wires the expert cache through prefill, so the expert re-warm that TT-005 is about is not present in the shipped configuration measured here — whether a pageable cache reintroduces a transient is TT-005's question, and TINYTITAN_KEEP_WIRED=0 (TT-008) is the switch that can now ask it.


The Qwen3.8 expert-cache budget stays at 96 slots (TT-011, 2026-09-18)

The proposal was to raise this family's routed-expert budget to 128 slots (15.84 GiB), on the strength of a simulated LRU trace that put the hit rate at 65% for 64 slots and 78% at 128, "where it saturates". The runtime's own counters say something different, so benchmark/expert_cache_slots.py swept the count directly (--expert-cache-slots), 64/96/128 then 128/96/64, on a 7,550-token prompt, 256 new tokens, greedy, two runs per point:

slots cache hit tok/s MiB/token max RSS
64 7.92 GiB 67.7% 3.44 408.0 12.0 GiB
96 (shipped) 11.88 GiB 76.5% 3.66 296.7 15.4 GiB
128 15.84 GiB 82.6% 1.21 220.0 15.8 GiB

The hit-rate case for 128 is real, and the throughput case against it is decisive. From 96 to 128 the hit rate gains 6.1 points and per-token expert reads fall 26% — the curve is still climbing, so the simulated "saturates at 128" is wrong — but decode falls from 3.66 to 1.21 tok/s, reproducibly (1.22 and 1.21). At 15.84 GiB the slot cache plus the mapped weights no longer fit a 24 GiB machine and the runtime pages: the win from fewer misses is spent several times over on swap. 64 slots is cheaper still (12.0 GiB RSS) and also slower (3.44), so the shipped 96 is the right point for this machine class — which is what affordableExpertCacheBudget's half-of-RAM cap already encodes.

No code change. Raising the row's wanted budget to 15.84 GiB would be inert on a 24 GiB Mac (the cap clamps it back to 12 GiB) and would change behaviour only on machines this repository cannot measure. The escape hatch for those is --ram-budget 16G or --expert-cache-slots 128, and this curve is what says when to reach for it.


Where the MTP verify pass goes (TT-006, 2026-09-18)

A width-2 verify measured 1.965x a scalar token where the union model predicted well under 1.3x, and 0.4–0.7x of that had never been attributed. The B1 work in docs/v4.4-decode-width-plan.md measured the phases on Ornith 8-bit; that install is gone, so this re-does the attribution on the pair that is installed — Qwen3.8-Flash-Next 4-bit with its own draft head (benchmark/tinytitan_mtp_phases.py, which now takes --target/--sidecar instead of the uninstalled Ornith map).

256 greedy tokens, 86.9% acceptance, 1.869 emitted per pass, 137 passes, two runs per arm agreeing to 0.05%:

component ms/pass × a 214.45 ms scalar token
verify backbone (wall) 417.35 1.946
— its GPU kernels 217.2 1.013
·· routed pair (the union) 51.9 0.242
·· non-expert prefill kernels 165.3 0.771
— host + commit + wait 200.2 0.933
verify head 22.17 0.103
proposal (draft layer) 23.59 0.110
commit (accepted draft) 15.13 0.071
pass total 480.03 2.238

The union is not the problem. verify_routed_pair at 51.9 ms/pass is 1.45x the scalar routed GPU time (35.9 ms), at or under the 1.585x the union model predicts. What the model assumes is ~1.0x is the non-expert path, and it measures 165.3 ms against the scalar's 97.3 ms — 1.70x — because the two rows run the 32-token prefill kernels, not the decode ones. The largest single piece is not a kernel at all: the backbone's wall exceeds its kernel time by 200 ms/pass, 0.93x a whole scalar token, which is per-tile MTLArgumentBuffer construction, a command buffer per tile, the cache plan, and the sequential fetch awaits that have nothing to overlap at width 2. TINYTITAN_MTP_VERIFY=pair — the default, and what these numbers describe — recovers only 3–5% of it (v4.4 B2).

End to end this is why acceptance is not the lever: 86.9% acceptance emits 1.869 tokens per pass and the pass costs 2.238, so MTP decodes 16.5% slower than scalar (3.892 against 4.663 tok/s), with byte-identical output. The floor analysis in v4.4 says a decode-style two-row attention and perfect overlap still lands near 1.6x — above what 2.0x-max acceptance can buy once the scalar baseline is fast. MTP stays off by default.

Caveats. A browser renderer was on the CPU (GPU reported 3% busy); the two MTP-on runs agreed to 0.05% and their kernel roles to within 0.1%, so the ratios are reproducible, but the 200 ms host figure could be inflated by that contention. It is also what v4.4 B1 found independently on a different model, so the conclusion does not rest on it. Kernel role sums overlap by design, so the GPU split is a role measurement, not an additive budget.


The ANE window penalty is a re-warm, and the pin removes it (TT-005, 2026-09-18)

The residual −14.7% at 4-bit, after the allocation-time pin, had three candidate explanations: a first-token re-warm still inside the ~60-token window, a remaining Core ML arena footprint, or drift. They are separated now, and the separation is the wire trace rather than a rate comparison.

TINYTITAN_WIRE_TRACE=1 records every setExpertCachePinned call that takes more than 2 ms. AgentWorld 35B-A3B 4-bit, 4,043-token prompt, two runs per arm and length, fresh process each:

arm (TINYTITAN_KEEP_WIRED) init pin unpin at prefill re-pin at first decode token
1 — shipped row, pinned at allocation 8.8–15.2 ms none none (early return)
0 — pageable through prefill under 2 ms walked, under 2 ms 888 / 1,588 / 2,084 / 22,077 ms

Correction, later the same day: the prompt above was too short to engage the sidecar. The ANE serves chunks of exactly 4,096 tokens, and 4,043 tokens is one partial chunk, so both "ANE" arms above prefilled on the GPU — the runtime logs ane-prefill fallback: chunk at 0 (+4043) outside sidecar coverage, which was not checked at the time. The wiring result stands, because it is cache state rather than engine state, but it was not measured after an ANE prefill. Re-run on an eligible 11,948-token prompt (two full chunks, no fallback line):

arm first decode pin decode, 8 tokens
KEEP_WIRED=1 (shipped) 13.4 ms, walked 1 (already pinned) 2.72 s → 2.95 tok/s
KEEP_WIRED=0 (pageable) 10,776.5 ms, walked 40 12.04 s → 0.66 tok/s

After a real ANE prefill the pageable cache re-pins all 40 layers in 10.78 s — 810x the pinned arm's 13 ms, and about half of a 60-token window at this model's ~3 tok/s. That is the window penalty the pre-pin and phase-scoped figures recorded, now measured with the sidecar engaged. The GPU-prefill numbers above (0.9–22 s, memory-state dependent) remain what the same mechanism costs when the eviction pressure comes from elsewhere.

1. First-token re-warm. It cannot exist in the pinned configuration: the cache is never unpinned, so nothing is evicted and nothing faults back. When the cache is pageable it is real, and it is the size of the old window penalties — 0.9–2.1 s against a 64-token window of 7–9 s is 13–24%, and the one run that met memory pressure paid 22.1 s, longer than the whole window. The pre-pin and phase-scoped window figures (−27.9%, −53.6%) are this mechanism; the spread across those GPU-prefill runs is the machine's memory state, and with the sidecar engaged the pageable re-warm measured 10.78 s (the correction table above).

2. Remaining arena footprint. Not measurable at steady state. The wired arm's ANE-vs-GPU decode here is −1.1% (window ≤64: −1.0%), reproducing TT-007's +0.7% on a 12K prompt. TT-004 has since tested the arena directly: it is ~152 MiB and releaseModels() returns it synchronously, with nothing surviving into decode — so this candidate is dead, not merely unmeasurable.

3. Drift. What is left, and already retracted once: the −14.7% comparison came from a session whose GPU arm drifted 8.369 → 7.389 tok/s, and v4.6 says only within-run comparisons are load-bearing there. This session demonstrates how large that can be — prefill for one identical configuration ranged 96–162 s, and the pageable arm's decode ranged 2.2–11.7 tok/s — while the wired arm's four runs agreed to 1%. With the pin, the interleaved A/B shows no residual to explain.

Verdict. The residual −14.7% was drift, not a mechanism. The window-contained re-warm belongs to the pageable wiring, which no longer ships, and the pin is what removed it; TINYTITAN_KEEP_WIRED=0 (TT-008) is how to get that configuration back deliberately.

Caveats. This session ran with a browser renderer holding CPU and ~19% GPU, so the rate comparisons are directional — one identical prefill ranged 96–162 s. The wire-trace times are direct measurements of the pin calls and do not depend on that; the pageable arm's decode rates are not usable and are not quoted.


TT-003: the AgentWorld unrequested thought is prompt-dependent, not width-dependent (2026-09-18)

What was recorded. On 2026-09-11, with --reasoning off, Qwen AgentWorld 35B-A3B 8-bit answered "Capital of Paris" by opening a <think> block of its own and spending the whole budget inside it — 477 characters, three repeats, byte-identical, and at a 512-token cap it closes and answers at 482. Its own control prompt was clean, and the 4-bit install answered in 8 tokens, so the tracker carried it as "the 8-bit does, the 4-bit does not". Raw rows: benchmark/benchmark-results/capital-of-paris-20260911T1935/.

The install was restored and the comparison re-run with more than one prompt. The 8-bit install (converted and repacked 2026-09-18, byte-identical to its stored golden) turns the single observation into this, at --reasoning off, greedy, 128-token cap, both widths:

prompt 8-bit reasoning chars 4-bit reasoning chars
Capital of Paris 477 (length, empty answer) 0 (answered, 31 chars)
What is the capital of Paris? 470 (length) 471 (length)
Who is the president of Paris? 0 (answered) 0 (answered)
How many capitals does Paris have? 487 (length) 504 (length)
What is the capital of France? 0 0
Name the capital city of Japan. 0 0

Both widths reopen a closed block, on different prompts. The 4-bit install thinks on two of the four ambiguous prompts and not on the one the original row used; the 8-bit thinks on three. So this is not a property of the width — it is a false-premise prompt pulling a deliberation out of a family whose weights treat the closed <think> as a weak cue — and the original "8-bit does, 4-bit does not" was one prompt over-generalised. The controls are clean on both, and --reasoning on changes the thought's content (1,025 characters against 1,860) rather than deciding whether there is one: the level is a request, not a gate.

What is still true from the earlier analysis. The template renders enable_thinking: false as a closed block (models/qwen-agentworld_35B_A3B_4Bit/tokenizer/chat_template.jinja:154), and the two installs' templates were checked byte-identical; C90 (c7fca53) routes an unrequested thought to reasoning_content and logs its size, which it did on all five of these runs (thinking off, but the model wrote N characters of reasoning).

The catalogue decision: advertise nothing differently. /v1/models offering off describes what the server asks for, and the server does ask for it. Special-casing the 8-bit width would encode a single-prompt artefact, and removing off from the family would be worse than useless because the level does not gate the behaviour in either width. What a client needs to know is that off is not a promise about the weights, that an ambiguous prompt can produce a thought, and that a small max_tokens can therefore return an empty content; C90's split and log line are the honest surface for that, and the wiki page for the matrix now says so.


TT-002: the 2B's two answers are one near-tie, measured (2026-09-18)

Qwen 3.5 2B 4-bit answers "Capital of Paris" degenerately on the GPU path ("The capital of France, and the capital of the country France, is Paris…") and cleanly on the CPU path ("The capital of France is Paris…") from the same weights, deterministically, three repeats each. The port's equivalence gate compares layer dumps, so the argmax of the final logits was never compared and the divergence had no name.

TINYTITAN_LOGIT_TRACE=1 — added for this, on both sampling paths: sampleOnce for the GPU and CPUSampler.pick for the CPU — prints each step's top-2 and margin. One server, the same chat request, temperature 0, 64-token cap:

generated step GPU top-1 GPU top-2 GPU margin CPU top-1 CPU top-2 CPU margin
3 France 23.33 Paris 22.52 0.81 France 23.71 Paris 23.14 0.57
4 , 24.67 is 24.27 0.41 is 25.71 , 24.30 1.40

The engines agree for the first four generated tokens and diverge at the fifth, on the same two candidates with the ranking swapped: the GPU continues with a comma, the CPU writes "…France is Paris". That is not a tie-break on an exact tie — the two engines' logits for the same token differ by 1.44 for is and 0.37 for ,, so the CPU's ordering is a genuinely different score, not a wobble.

How close a tie has to be. Over the shared top-2 candidates of the first 17 steps, the cross-engine |logit difference| is min 0.007, median 0.90, max 4.02. A step can therefore flip whenever its top-2 margin is below that spread: step 4 flipped at a GPU margin of 0.41, while steps 11 and 34 held margins of 0.45 and 0.16 and did not flip, because the error's sign happened to favour the same token. Margins under about 1 are the danger zone on this install; nothing above the observed 4.0 spread can flip at all.

Consequence. Neither engine is wrong — each computes the 4-bit model correctly and they disagree about a continuation those weights make nearly equiprobable. The GPU's text is a second, lower-probability continuation (a comma after "France" is the start of the degenerate repetition) and nothing in the equivalence gate could see it. A third opinion would need the numpy reference, which needs the .build/qwen35-2b-affine-4bit snapshot; it is not present, and re-converting is a separate job. This is a 4-bit quality finding, not a defect in either engine.


TT-004: the E5RT arenas are returned — the fourth hypothesis is dead (2026-09-18)

The ANE decode gap had one untested candidate: that Core ML's E5RT arenas are not actually returned at releaseModels() and keep costing residency and bandwidth through decode. TINYTITAN_ANE_MEMORY_TRACE=1 now reports phys_footprint at the points that decide it — when a layer's MLModel becomes resident, at releaseModels() before and after the drop, and at the prefill→decode boundary.

AgentWorld 35B-A3B 4-bit, 11,948-token prompt (two full 4,096-token chunks, so the sidecar really serves it — no fallback line), --max-new 8:

point footprint
releaseModels(), last model resident 14,893.8 MiB
immediately after residentModel = nil 14,741.6 MiB
prefill→decode boundary, ANE on, cache pinned 14,741.6 MiB
prefill→decode boundary, ANE on, cache pageable 14,184.6 MiB
prefill→decode boundary, ANE off (GPU prefill) 14,423.5 MiB

The arena is ~152 MiB and it is returned synchronously. The footprint falls by exactly that (152.2 MiB pinned, 158.9 MiB pageable) at the moment the last MLModel reference is dropped, and 498 MiB more when our own masks and shadow rows are freed. Nothing arena-sized survives into decode: the ANE arm's boundary footprint sits −239 to +318 MiB from the GPU arm's, which is run-to-run spread, not the ~1 GB a retained arena would need. (The ~1 GB figure in ANEPrefillAttention's comment is about the h4096 score tensors on the larger variant; on this install the last resident model's contribution at release measured 152 MiB.)

So the fourth hypothesis is dead: the 3.6x longer expert-read awaits with ANE prefill (859 ms against 3,129 ms on identical bytes — the reads themselves are the same) are not caused by an E5RT arena staying resident. TT-004 closes with that negative result; the awaits remain unexplained, and TT-005 shows what else was measured around them.


Closed by measurement — do not re-propose

Three cheap-looking ways to raise 4-bit quality, each measured and each rejected. The numbers are here so the ideas are not re-derived from first principles a third time. A fourth, per-tensor bit widths inside a 4-bit install (TT-025), is now closed the same way: its runtime half ships, and the two end-to-end checks under Does precision buy quality? below both find no measurable quality case for the converter policy.

Reference points

Quantizing real checkpoint tensors with TinyTitan's own quantiser, affine group-64:

4-bit 8-bit
weight error (mean|err|/mean|w|) 10.8-12.0% 0.64-0.72%
cosine vs bf16 0.9936-0.9949 0.99990-0.99998

The shipped "4-bit" build is already mixed precision -- router and embedding (which carries lm_head) are 8-bit, attention, routedExpert and sharedExpert are 4-bit. The parts whose error does not average away are already protected.

The QSA selections on the GPU · REJECTED, 2.75% ceiling (TT-010)

Both QSA key selections are host computations behind a barrier. The decode one already has a GPU implementation (encodeSelectKeys, opt-in through TINYTITAN_QSA_GPU_SELECT) and it measured a wash, which is why the profile row keeps qsa_select off. The prefill one (selectKeysPrefill) has no GPU path, so before building one this measured what it could be worth. TINYTITAN_QSA_SELECT_TRACE=1 times it per layer per chunk on qwen3.8-flash-next 4-bit, GPU prefill:

prompt prefill QSA host selection share
2,410 tok 95.64 s 185.7 ms — 12 calls, mean 15.5 0.19%
11,948 tok 864.90 s 23,748.7 ms — 36 calls, mean 660, max 1,461 2.75%

The cost is superlinear in the visible window: on the long prompt the first chunk's twelve layers cost 1,775 ms, the second 9,222 ms and the third 12,752 ms, because a row past the ~2,051-key exactness window pays a block sort plus an O(visible) compaction, and later chunks have more such rows and more blocks to rank. (Both runs shared the machine with a 70 GB model conversion, so the host times are upper bounds.)

Rejected on the ceiling, not on the implementation. A GPU prefill path would need a multi-row version of the existing single-row mask kernel and GPU compaction — the ascending index list and counts the prefill attention loops over, for which the decode path has no equivalent. It could recover at most the 2.75% above, on one family, on the longest prompts, minus whatever the GPU spends doing the same work; the decode half of the identical idea shipped and was a wash. The mask semantics are also exact: any difference in a tie-break changes which keys are attended, so the implementation carries a numerics risk a ≤2.7% TTFT ceiling does not pay for. TINYTITAN_QSA_SELECT_TRACE=1 stays, so the number can be re-taken if the window, the compress ratio or the prompt-length mix changes.

A smarter quantisation range · REJECTED, 3.9%

Min/max per group wastes levels when one outlier stretches the range, so search a clip factor that minimises MSE instead. Zero runtime cost, same format, same kernels -- a change to quantize_affine alone.

Measured across five real tensors: 10.92% -> 10.49%, a 3.9% reduction. Real weights inside a 64-group are well-conditioned enough that min/max is already near-optimal. Not worth the change.

8-bit QSA indexer · REJECTED, 2.4 points on marginal keys

The indexer is the only ranking still at 4 bits. A synthetic test on random Gaussian input suggested a large effect -- 90.0% top-8 agreement at 4-bit against 99.3% at 8-bit -- which is what made this look worth building.

On real activations it is much smaller. Driving the numpy reference through layer 3 for 110 real tokens at budget 32 (keeping 32% of keys, a reasonable proxy for a ~6k-token context at the production 2048 budget):

keep-set agreement with bf16 mean worst position
8-bit indexer 99.77% 91.43%
4-bit indexer (shipped) 97.41% 85.71%

2.4 points, and the keys that differ are by construction the marginal ones at the selection boundary -- the lowest-scoring blocks, where swapping one for another is close to a no-op. Real activations separate the block scores far better than random input does, which is why the synthetic number was misleading.

The cost side is not small either. QSAIndexer unconditionally constructs DequantInt4GEMV with no bits parameter, where every other call site uses the weightBits == 4 ? int4 : affine pattern -- so this is a kernel-selection change, a new manifest slot to carry the width, a converter change, and a rebuilt install. And a second install does not fit: 128 GB free against a 165 GB install, so it would need the unimplemented hardlink sharing.

The lesson worth keeping: measure ranking stability on real activations, never on synthetic input. The two answers differed by 7 points and pointed to opposite decisions.

8-bit attention slot · NOT REJECTED, but constrained by RAM

The one option that needs no code change: the runtime already supports the slot at 8 bits (Ornith 8-bit runs attention=8), so it is a converter policy change. It is also the largest lever, because dense is resident rather than streamed:

active params per token
routed experts (10 of 512, 48 layers) 2.36 B (39%)
dense / attention slot 3.68 B (61%)

Taking 61% of the active computation from 11% error to 0.7% costs no SSD bandwidth, which is what bounds decode. It costs +2.10 GB resident.

The measured RAM is what constrains it. Qwen3.8 4-bit on the 24 GB M3, 3093-token context: RSS 3.66 GB after load, peak RSS 15.12 GB, phys_footprint peak 18 GB. That is 3.2 GB of resident weights plus a 12 GiB expert-cache budget, so the cache is essentially all of it. Adding 2.10 GB pushes peak to ~20 GB of 24 GB, and the only way to claw it back is shrinking the expert cache -- which is what buys the throughput.

Worth revisiting on a machine with more memory, or against a measured cache-size/quality trade. Not free on this one.

CPU co-execution · REJECTED, the CPU makes the GPU 45% slower (TT-024)

The premise was that decode leaves seven of eight cores idle while the GPU is busy 39.5 to 48.5 percent of the token, so a share of the routed experts could be computed on the CPU in the gaps. A streaming-read probe supported it: 45–60 GB/s during inference without slowing the GPU. The probe was wrong about the workload — a sequential read is prefetch-friendly and low-power, a dequant kernel is neither.

Running the actual CPU expert kernel (8 threads, int4 dequant plus GEMV) as a load generator during decode:

CPU load tok/s GPU busy/token
off 19.886 27.335 ms
8 threads of real dequant work 15.387 39.608 ms

GPU-busy rose 44.9% and throughput fell 22.6%: the dequant kernel competes with the GPU for the memory controller and the package power budget. The arithmetic closes it rather than deferring it — at best the CPU absorbs ~32% of the work, but the GPU then does the remaining 68% at 0.69x, which is slower than the GPU doing all of it alone. There is no split ratio that wins.

CPUExpertFFN and the TinyTitanKernelsC target stay in the tree because they are correct, tested and cheap to keep; nothing may call them from the decode path. docs/cpu-coexecution-plan.md carries the full argument, including what is left for a 2x: the idle is the only slack (a 1.71x ceiling), and during compute the bus already runs at 83% of what a pure CPU streaming read achieves.

Per-tensor precision at key locations · CLOSED, no measurable quality (TT-025)

The conversion-time question was which precision-sensitive tensors to keep at 8 bits rather than 4. tools/precision_plan_qwen35.py found k_proj/v_proj on the full-attention layers, where 8 bits removes 94% of their error for 16 MB resident — the smallest sensible promotion, against +2.10 GB for the whole attention slot. It was measured end to end twice and is closed: 18/20 against 18/20 on the twenty-prompt suite, and −0.009727 ± 0.006665 nats (t −1.46) on the paired held-out perplexity A/B, with the whole-slot 8-bit build at −0.001249 nats (t −0.09). Both point estimates are small and in precision's favour, and neither clears the instrument's ~0.013-nat floor. The converter policy and the GDN a/b kernel's int8 branch are therefore not built. The runtime half — per-tensor widths in the resident index — ships and is tested (3af08ee). Do not revive without a machine that can hold the 125B bf16 source; the effect is bounded below what this one can resolve.

What none of this establishes

These numbers are weight-space fidelity and selection stability: they bound how much the model could differ, not how much the answers do. The two TT-025 measurements below are the end-to-end answer, and they close the question at this machine's resolution.


Does precision buy quality? A first end-to-end check (TT-025, 2026-09-18)

TT-025's premise is that a few megabytes of precision-sensitive tensors are worth promoting inside a 4-bit install. The weight-space case is measured (tools/precision_plan_qwen35.py: k_proj/v_proj at 8 bits removes 94% of their error for 16 MB), and the note in prepare_qwen35.py says plainly that the 4B keeps the rule unmeasured end to end. So this measures it, with benchmark/quant_quality_ab.py: twenty prompts whose answers a script checks — arithmetic including multi-step, a counting task, a syllogism, a format constraint, a reversal, a calendar step, a fact — greedy, identical prompts, identical system message, one generation each.

installs compared what differs first second
Qwen 3.5 4B 4-bit vs 8-bit ~2x the bytes, whole model 18/20 17/20
Qwen 3.5 4B 4-bit vs a --no-promote 4-bit build the 16 MB k_proj/v_proj promotion, nothing else 18/20 18/20
Qwen 3.5 9B 4-bit vs 8-bit ~2x the bytes, whole model 18/20 18/20

A wash, everywhere. The two 4B configurations miss the same two cases and produce the same wrong answers — "if today is Wednesday, what day is it in 10 days?" → Tuesday, and "the sum of the first five prime numbers" → 17, which is the first four. Both are reasoning failures that precision does not touch. The 9B gets both right at either width, which is a capacity effect, not a precision one, and its two misses differ by configuration (4-bit fumbles "stressed" → "destrets", 8-bit fumbles Wednesday+10 → Monday) — noise rather than an advantage.

What this establishes. At this instrument's resolution, and for this family, there is no end-to-end quality case for a per-tensor promotion: the whole-slot 8-bit build doubles the bytes for nothing the check can see, so a ~10 MB subset of the same change will not show either. TT-025's mechanism ships and is tested; its policy has no measured justification, and the 125B rebuild that would test it there is not affordable on this machine (the bf16 source is 360 GB, and the install's 95.4 GiB table is the only part that could have been reused).

What it does not establish, and what the next section answers. Twenty short prompts are a coarse instrument and a small perplexity effect could sit below its floor. That is why the held-out perplexity A/B below was run — it is the sharper instrument, and it agrees: the promotion is 0.0097 ± 0.0067 nats ahead of its control, which is inside the ~0.013-nat floor rather than above it.

The controls are what make it readable: the promotion pair differs only in those tensors — the shipped manifest carries k_proj/v_proj at 8 bits on the eight full-attention layers, the control at 4 everywhere else — and the 4-bit and 8-bit installs are the shipped ones, not rebuilds. The control takes five minutes:

python3.13 tools/prepare_qwen35.py --size 4b --bits 4 --no-promote \
    --output .build/qwen35-4b-uniform --work .build/qwen35-4b-shards
.build/release/TinyTitanRepack --input-snapshot .build/qwen35-4b-uniform \
    --model-id qwen3.5-4b-uniform --output .build/qwen35-4b-uniform.gturbo

Both come from Qwen/Qwen3.5-4B at its pinned revision — the official repo.

Does precision buy quality? Held-out perplexity, the sharper check (TT-025, 2026-09-19)

The twenty-prompt check above is a floor: both 4B configurations scored 18/20 and missed the same two reasoning cases, so it cannot see a small perplexity effect. This is the sharper instrument. TinyTitanBench cpu35ppl scores a fixed text through the CPU forward pass — step returns the whole vocabulary's logits for every position, so scoring needs no sampling and no generation — and records one negative log-likelihood per token:

.build/release/TinyTitanBench cpu35ppl <install> <text> [tokens] [nll-out]
python3.13 benchmark/quant_perplexity_ab.py models/qwen3.5_4B_4Bit \
    .build/qwen35-4b-uniform.gturbo models/qwen3.5_4B_8Bit --tokens 1024

Every install scores the same token positions (token hash 5d5dc2a7b319ad43 for all three), so the comparison is paired: mean dNLL, its standard error, and t over the 1,023 positions. Text: the four repository documents benchmark/quant_perplexity_ab.py names by default.

install mean nll perplexity dNLL vs the promoted 4-bit t ppl ratio
4B 4-bit, k_proj/v_proj promoted 2.666170 14.384774
4B 4-bit, --no-promote control 2.675897 14.525374 −0.009727 ± 0.006665 −1.46 0.990320
4B 8-bit, whole slot 2.667420 14.402758 −0.001249 ± 0.014438 −0.09 0.998751

A sharper instrument, the same answer. dNLL is baseline minus install, so a negative number means the promoted install has the lower NLL. Both point estimates are small and in precision's favour — the 16 MB promotion is 0.0097 nats (about 1% perplexity) ahead of its uniform control, and the whole-slot 8-bit build is 0.0012 nats ahead — but neither is distinguishable from zero at this length (|t| 1.46 and 0.09). The instrument resolves roughly 0.013 nats, so the promotion is bounded at about 1% perplexity rather than shown to buy it. That is what "the policy has no measured justification" means here: the effect is either absent or below a floor this machine can reach for a few minutes of CPU, and the 125B that would resolve it needs a 360 GB bf16 source.

What it does not establish. The paired t treats token positions as independent, which they are not, so treat |t| as indicative rather than exact. The text is English prose from this repository; a code-heavy or multilingual held-out set could move the estimate. And no downstream task — the book scenario's continuity quiz, a coding pass@k — was run, so this bounds weight-space quality, not task quality.

TT-025 closes here. The mechanism ships and is tested; the quality case is absent at both resolutions this machine can reach, and the 125B rebuild that would sharpen it further needs a 360 GB bf16 source there is no disk for. The converter policy and the GDN a/b kernel's int8 branch are not built, and this entry is the reason not to re-propose them.

T7 has a caller it can afford: a ranking hint from the idle window (TT-033, 2026-09-19)

T7 ranked better than the token match from the first measurement — recall@1 4 of 4 against 1 of 4 on the authored paraphrase set — and had no caller, because every question-shaped caller is a request the person is waiting on and a judgement is 15.2 s on the 4B. The inverse-document-frequency weight then took the token ranking to recall@1 3 of 4 (878cea6), leaving exactly one miss no weighting reaches: "How often does the boat cross the water?" against rules/ferry = runs only on Sundays, which share no term in any form.

The caller is MemoryRetrievalHinter, and it is deliberately off the request path. memory_search answers from the token ranking immediately and registers its question; one background task walks the scope's facts while the server is idle — ServerCoordinator.generating read as isIdle — and records each YES as a ranking hint keyed by the fact plus a fingerprint of the value it judged. A later search for the same question puts the hinted facts first, including a fact the token ranking never returned, which is the semantic case. Nothing awaits a judgement, so the 15.2 s stays off the turn.

The bounds are what make it safe to leave running. The sweep covers at most 64 facts a question (MemoryRetrievalHinter.coverageLimit, and never more than a search itself returns), keeps hints for at most 16 questions (least recently asked evicted), and a verdict carries the value's FNV-1a fingerprint, so a fact whose value changed is not promoted on an answer about the old one. A hint is filtered by the query's own prefix, tags and importance, so it cannot return a fact the caller excluded. Without an engine, or while a client is generating, the path is byte-for-byte the token ranking it was.

MemoryRetrievalTests pins the seven behaviours the design rests on: never-waiting, promotion of a dropped fact, reordering within the returned set, staleness by fingerprint, the query filters, the coverage bound, and a log line that carries counts but never the question or a value. The real model's ranking is unchanged and already measured above; what was missing was a caller, and this is one.

The side-engine model policy is settled (TT-009, 2026-09-19)

The 2B is not used. The 4B 4-bit is the side-engine: the install ServerSideEngineFactory loads by default, and the smallest that decides contradiction, duplication, retrieval and — with the third durability draft — durability on the shipped prompts. The 9B is optional and chosen on quality benchmark results, not size for its own sake: it adds the reply check (T6) and is worse at durability (65% against the 4B's 95%), so a deployment that wants T6 takes it and one that wants durability does not. docs/side-engine-tasks.md carries the matrix these follow from.

An override at the attention slot's width is honoured too (issue #16, 2026-09-19)

The report. A qwen38flash 4-bit install would not serve: unsupported architecture: qwen38flash declares model.language_model.layers.0.linear_attn.in_proj_a at 4 bits; the GDN a/b kernel reads that pair at the attention slot's width or as bf16, so a quantized override there is not honoured. The install verified clean; only serving failed.

What the check got wrong. The rule in the message is right; the code compared against 16 alone. Model.validateRoleUniformity refused every quantized in_proj_a/b override, including one that names the attention slot's own width — the width the kernel already reads. A manifest carrying such an override was therefore refused before any weight was read. The shipped install carries no override at all, which is why this checkout never hit it; the reporter's manifest did, and any manifest built with explicit per-tensor widths (naming even the widths that match the slot) would.

Reproduced and fixed on this checkout. Adding just that override to our qwen3.8-flash-next_125B_A6B_4Bit manifest — with the receipt's manifestSha256 updated so the load is not refused for the edit — produced the reported error verbatim. With the fix the same install answered Paris for "The capital of France is" (8 tokens, 2.8 tok/s), and both files were restored byte-identically afterwards. The check now takes the attention slot width and accepts that width or bf16; RoleUniformityTests pins the slot-width case (the regression), both mismatch directions, and bf16. The full package suite and all six lint gates pass, and the qwen38-4 golden is identical to its baseline.

What to carry forward. The validator was written from the comment — "bf16 or the slot's width" — and implemented as "bf16 only": the parameter the rule needs was never passed in. A rule that names a slot has to be given that slot. And a manifest may name a width that matches the slot; equality is a description, not an override that needs honouring.

Parked ideas

Nothing is parked. TT-025, the one idea that stood here, is closed by measurement: both the twenty-prompt floor and the paired perplexity check put its quality case below this machine's floor, so the converter policy and the GDN a/b int8 branch are not built. TT-033 closed when its caller landed above.

Three ideas were closed here earlier instead of revived. TT-026 (sharing ngram_table.bin) and TT-027 (a streaming path for KAT-Coder and AgentWorld) were already implemented when their rows were checked — TinyTitanRepack --share-ngram-table with prepare_qwen38.py --reuse-ngram-table on one side, prepare_agentworld.py's three-way shard fetcher on the other — and TT-024 (CPU co-execution) is closed by measurement, in the entry above.

Closed elsewhere — do not re-propose

  • MTP expert union — closed on both models: on Qwen3.8 a width-2 verify costs about 2.8x against a 2.0x ceiling, and 71% acceptance does not close that.

Clone this wiki locally