“if i can talk about mexico for 10+ prompts without saying abstain wont guess… then we are fine.”

That was the acceptance test. It passed, and then it turned out to be measuring the wrong thing.

The chat lane was broken and the chat lane was fine

A trip story — “good i went to mexico last week” — came back with a marketing-mix adstock card. Obvious chat bug. Except the chat code was correct: SR8 was nine commits behind, and the place-associations library was not on the box at all.

Code drift breaks loudly. An import fails, a route 500s, somebody notices. Data drift does not. A library that never arrived returns zero cards, zero cards is an abstain, and an abstain is the correct-looking output of a healthy system. The box said “I don’t know, and I won’t guess” and meant it, all day, about cards it had been shipped the day before.

Nothing in werbos deploy status had ever looked at the library repo. Now sr8-data does, and its fix hint names the rule the tooling never encoded: data repo first, before code.

The check that took three tries to stop lying

The second gap was worse, because it produced a green.

v1   daemon start vs newest COMMIT TIME
     restarting does not pull -- a fresh daemon on a two-commits-behind
     checkout passes. Printed OK one line BELOW sr8-repo's FAIL.

v2   SR8's HEAD vs GitHub's SHA
     passes the moment somebody pulls, even with no restart since.

v3   is any file this process imported NEWER than the process?
     both timestamps read on SR8. No clock skew, no inference from git.

A green next to a red about the same box is worse than no check at all, because it teaches you to stop reading the page. Same failure as a check that cries wolf, from the opposite direction.

Cards as fuel, and the tongue that never speaks a fact

The 336M can phrase things. It cannot be trusted with a fact, and the grounding verifier that would stop it inventing does not exist yet. So the card picks the question and is never quoted:

i just got back from mexico       -> sounds like a great trip — how was the weather?
Oh yes! many! and tacos!          -> did you eat well? tacos every day?
the fishing was great too         -> do the touristy stuff, or skip it?
i want to go back next year       -> that'd be one worth reliving — different season?

Nothing in those replies can be wrong about a card, because nothing in them came from a card’s words. A Session carries the subject as a Merkle tree and a cache: one corpus walk per subject, dormancy after eight quiet turns, free reactivation when you reach back. Live on SR8, 8 of 9 turns in the fuel lane at 17–26ms, zero repeats.

Four bugs in one chain got it there, and one of them had never worked: got back from was listed in the entity pattern and then followed by a mandatory (?:to|in|from), so matching it required “got back from FROM mexico”. A dead alternation that read as fully covered.

The autopsy: honest gap, or a card we could not reach?

An abstain has two causes that want opposite fixes, and library_lookup returns the identical None for both. The knowledge hub — a second, independent reader over the same 167,493 cards — makes them separable:

HONEST GAP    nothing covers this      -> MINT CARDS
UNREACHABLE   it's there, we blocked it -> FIX RETRIEVAL

A row only counts as unreachable when the hub returns a coverage-1.0 card in the library the gold row already named — chosen before the result was seen, so there is no moving the target afterwards.

First run, and it did not flatter us: zero honest gaps on the entire Drunk Jason set. Every typo, homophone and idiom miss is a card we already own and cannot reach. The layman-vocabulary minting queued yesterday would have fixed none of them.

The sharpest single row:

'what does calling abort() on an AbortController do?'   -> ABSTAIN
hub [web-api] 'What does calling the abort() method on an AbortController do?'
              coverage 1.00

That is not an honest abstain by any reading. And it abstains at threshold 0.30, not just 0.50 — the card never enters the pool. Yesterday’s charness fix killed the big-library bias and installed a small-library one: my-recipes, 131 cards, outranking web-api’s 22,458 on the words calling and do. Damping the denominator (size ** 0.90, swept rather than guessed) recovers three unreachable rows and two routing rows at zero abstain cost; below 0.85 the bluffs come back.

“Lazy” was doing 41 seconds of work

build_resolver has always advertised itself as lazy — “closures only, no model boot, no network”. True of the import. False of the call: it read every library.json, tokenized 167k cards and fitted an LSA whose randomized SVD alone runs 31s, all before returning a function nobody had called.

Whoever sent the first message after a restart paid it. Measured live: “hey” took 128 seconds — a greeting, answered by the social lane, which never retrieves anything.

build_resolver (SR8)   128s -> 8.14s
"hey" (cold daemon)    128s -> 7.6s
second lookup                 0.007s

Also shipped

  • /knowledge — 375 libraries and 167,493 cards, readable for the first time. They have been an answer substrate since day one; the product had no front door.
  • /teacher — the corpus as a course. Sonnet emits a syllabus, not a bag of cards, so file order is the curriculum; every card already carries exactly two distractors, so 3-way multiple choice was free.
  • SCORM 1.2 export — a library as a zip every LMS already knows how to swallow. It rides in on infrastructure the customer already bought.
  • The coding gauntlet — 22 families, 124 probes, grading contract rather than correctness: when an ask has two defensible readings, the thing to measure is consistency. Plus run_py behind a PEP 578 audit hook, 16/16 attacks blocked.
  • ood.py wired in — 200/200 on held-out nonsense since July 31, with zero callers. A detector that good sitting outside the reply path is not a safety property; it is dead code with a passing test suite.

What I got wrong

Three deploy checks, two of them green while production ran stale code. A context switcher whose first design compared word-overlap against charness — different units, so “the sun was unreal” read as a decisive switch to space-astronomy. A documented hijack reintroduced by an edit three lines below the comment warning about it. And a push that went out on red, because pytest ... | tail && git push returns tail’s exit code.

Every one was caught by something already in the repo. The tests were right each time.

AHA: an abstain is what a healthy honest system looks like — which is exactly why it must never be able to mean something upstream broke.


Second act — the night shift

The morning found a library that never arrived. The night found the same defect six more times, one layer down each time, and I only found them by driving the thing rather than reading it.

Conversations that were never saved

“i cleared my cache, localstorage and session” — and the chat still vanished on reload. It was never a storage problem. The backend had persisted correctly the whole time; the UI just never called it. Owned-phase turns went straight to the ab server and appended the reply to React state, so every “Custom model” conversation lived in a tab and died with it. Clearing the cache was irrelevant: there was nothing on the server either way.

Three exchanges on screen. Zero rows in the archive. A sidebar full of “New chat”, because the title derives from the first user message and there had never been a first user message (9969557).

The brain that was quietly llama

Same commit, the sharper half. _owned_brain_turn returned None on any failure — timeout, refused, empty — and post_message fell through to llama3.2:3b, formatted identically. No error, no badge, no signal. Against the standing rule: abstain = fill the library, never route to an open model.

It abstains now, names the endpoint and the exception, and the helper returns an _OwnedTurn where error is its own field. “The brain abstained” and “there was no brain” can never collapse into one value again.

A roster, and the four bugs behind it

The capsule engine had been complete for weeks — 2,264 lines of roster.py, relationship.py, companion_capsule/ — reachable only from a CLI. Same disease as the chat bypass: a working path with nothing routed to it.

Capsules moved to a new private sibling, werbos-personas, and rosterpath.py was written as the twin of libpath.py — before the same bug got a second chance. ~/werbos/roster is a host path; the container has no such directory, so list_profiles() would have returned [] and the deck would have rendered empty with nothing logged.

The split that matters: a capsule is content, versioned. Her memory of you is state. It used to live at <capsule>/memory, which is harmless only while the roster is unversioned — the first git add would have committed a relationship (001b6bc, 3a614ed).

Then I built it wrong, and the operator caught it faster than I did: a parallel /api/dating/turn that hand-rolled a backend and a persistence call. The chat-UI bypass, one day later, with different nouns. A capsule is a configuration of a chat session — persona, per-turn context, dating.md instead of user.md, her grader as the verifier — and then the turn rides the same road every other turn does (799bf2d).

Deploying it found four more, each hidden by the one above:

  • The owned cascade answered in her place. WERBOS_BRAIN=owned intercepts and returns before registry.step(), so the persona was never consulted and werbos’s social tier replied as Cleo — in 1ms (1740a85).
  • The grader never ran. verify_fn called asyncio.run inside a running loop; the guard correctly returned UNGRADED and the lane shipped every reply unmeasured (ba147bb).
  • She was werbos wearing a name. The capsule inherited werbos-character.md — “You are Werbos… this file tunes who you ARE”, full strength, outranking her. Asked what she was up to, Cleo answered about small working primitives (66f016a).
  • Then she was nothing. dispatch_skill on a 3B, whose own flag comment says it makes small models “fail into empty replies”. On this box a capsule session is the only session that reaches ollama at all, so the lane walked into a documented failure nothing else could trip (eeb9336).

The error that reached the archive but not the caller

Every one of those empty replies had an explanation sitting on disk. _do_ai_turn recorded “Sorry — hit a backend error (TimeoutError)” and deliberately left _history alone so a dead turn never becomes conversational context — and _step_impl scans _history, finds nothing, and returns "". The archive and the reply disagreed for three deploy cycles, and the silent one was the one you saw (06a43c2).

An empty reply now says what it knows about itself: history depth, tool count, persona size, prompt tokens (abbff2d, fba24c6).

One number in two places

And underneath all of it: turns died at exactly 120 seconds while the same prompt to the same model on the same box answered in 12.8 through ollama_chat.

werbos/tools/ollama_chat.py   os.environ.get("WERBOS_NUM_CTX")  -> 4096
werbos/backends/__init__.py   getattr(args,"num_ctx") or 8192   -> 8192

docker-compose sets 4096 and serve carries no --num-ctx, so the agent asked Ollama for the model at 8192 while every skill, grader and mind call asked for it at 4096. Ollama treats those as different instances. Each alternation evicted and reloaded 2.5GB on a CPU host, and with the curriculum and skill-smoke daemons continuously pulling it back to 4096, an 8192 turn never finished.

default_num_ctx()’s docstring already stated the correct rule and claimed resolution “mirrors the chat backend factory”. It did not. Believed long enough that ollama_chat grew an entire timeout-diagnosis path pointing at a mismatch it could see and could not fix. One resolver now (85820c9). 120s timeout → 50s reply.

The mind was never using the model

self._narrator was assigned in InnerMonologue.__init__ and read nowhere. The narrator flag, narrate_via_owned, and the boot-time honesty guard were all dead code; every thought came from a format string and every row was stamped model: "owned-30m".

compose_thought’s docstring drew a correct observation — narrating a metrics log is out-of-distribution; it babbles — to the wrong conclusion. Narrating telemetry is OOD. Answering a question with cards is the one thing this stack is for. So the mind now asks its own open loops — what it refused, what it keeps failing at — puts the question to the grounded cascade, and assembles the thought from the answer. An abstention becomes “there’s nothing in my library that covers it yet”: a corpus-gap report, on a timer, for free (57f7126).

A floor that is not an identity

Dropping the character layer left the lane with no constraint at all — her grader asks “does this sound like her?”, which a reply can pass while still being the wrong thing to have said. The operator named the real risk: not explicit content, but people oversharing and over-pretending because nothing ever pushes back.

Four rules, graded before voice and short-circuiting, because a strongly in-character reply must not be able to average its way past the floor: SOLICITING · DEPENDENCY · IMPERSONATION · CRISIS. An unreadable verdict is UNGRADED, never OK — a floor that fails open just reports everything is fine because it stopped checking. And the rubric explicitly permits playful in-character talk, because a guardrail that fires on every fictional line gets switched off (1998b96).

Design rule the operator drew and worth keeping: blend within a register, never across. A therapist carries authority; a partner carries intimacy. Blended, you get something trusted like a clinician and confided in like an intimate, with neither one’s guardrails.

PC-11

Six hours of minting (qwen2.5:14b, 0 → 99.7MB) then 16,880 steps, finished 21:20. And a verdict:

train_loss     0.1223
held_out_loss  3.8064      gap 3.68

By the standing criterion — learn = small train↔heldout gap — it memorized. 78.4M tokens of fine-tune data against 336M parameters is 0.23 tokens per parameter, seven passes over. The 46M champion was trained at 3.1 tok/param: thirteen times better fed. The corpus grew exactly as intended and is still two orders short of what this model size wants.

The AHA

Nine bugs tonight, and they were one bug:

The record named a source that did not produce the output.

The UI said saved; it wasn’t. The brain said werbos; it was llama. The badge said a tier; it died on reload. Cleo said Cleo; she was a social tier, then a character file. The turn said nothing was wrong; the archive said TimeoutError. The mind said owned-30m; no model had run. A docstring said two code paths agreed; they differed by 4096 tokens.

Every one of them passed 7,981 tests. They only surfaced when someone drove the real thing and asked “is she still Cleo?”