Day 105 — the chat was broken and the chat was fine
A trip story came back a marketing card, and nothing was wrong with the chat lane — the box was nine commits behind with a library entirely absent. Code drift breaks loudly; data drift produces an abstain, which is what a healthy honest system looks like. Fixed the deploy checks (three tries to stop lying), made cards into conversational fuel that never quotes a fact, built the autopsy that says whether an abstain was an honest gap or a card we own and cannot reach — zero honest gaps on the adversarial set — and cut the first reply from 128 seconds to 7.6.
“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_pybehind a PEP 578 audit hook, 16/16 attacks blocked. ood.pywired 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=ownedintercepts and returns beforeregistry.step(), so the persona was never consulted and werbos’s social tier replied as Cleo — in 1ms (1740a85). - The grader never ran.
verify_fncalledasyncio.runinside a running loop; the guard correctly returnedUNGRADEDand 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_skillon 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?”