← build log 2026-08-28

Dev Journal — 2026-08-28

One day, 29 commits, D-074 to D-087. Same convention as
2026-08-27: this is the concepts journal, not a changelog — the
decision records in specs/decisions.md carry the measurements, this is the through-line.


The throughline: silence read as health, a fourth time

Yesterday's journal named the taxonomy (specs/notes/012) and its sharpest instance:
"can I tell 'ran and found nothing' from 'never ran'?" Today answered that question wrong
three more times, in three different subsystems, before it was answered right:

  1. Exit rules (D-074). health read a rule's own TRIGGER rows as evidence the engine had
    RUN. No trigger ever fired (correctly — the book had no reachable stop), so two days of
    silence looked identical to two days of never checking.
  2. Interim scoring (D-074, same pass). Same shape, same fix: a heartbeat row distinct from
    the output row.
  3. Exit rules again (D-082). Fixing #1 exposed a SECOND bug hiding behind the first: once
    the heartbeat existed, the staleness check escalated "40 evaluations, zero triggers" to a
    problem — because for most subsystems that pattern means "died after one lucky success." For
    an exit engine it means the opposite: a fire alarm that has evaluated every tick and never
    fired is the healthy state.
    Probe needed a silence_is_normal flag it didn't have.
  4. Interim scoring, a third time (D-086). A live FAIL an hour into the day's first real open
    position. Investigated instead of trusted, because D-082 had just fixed this exact tautology
    once. Turned out to be the same class one level deeper: the first attempt copied exit_rules's
    new flag directly onto interim_scoring, which fixed the live alarm but broke a regression
    test encoding the ORIGINAL D-074 bug shape. The two cases are genuinely different, not the
    same question twice
    — "never yet due" is fine for both subsystems, but "produced once, then
    went quiet" is fine for an exit rule (one trigger closes its position) and NOT fine for
    interim scoring (a units bug could zero every subsequent score silently). One boolean was
    answering two questions; split into never_producing_is_ok and stopping_after_output_is_ok.

The pattern across all four: a probe that reads a subsystem's own output as proof it ran will
always eventually lie
, and the fix is never a bigger check, it's a heartbeat that exists
independently of whether anything happened. Cheap to build, easy to forget, and apparently easy
to half-remember — the D-086 near-miss is the more useful lesson than any of the three clean
fixes: the second time you fix a bug's shape, check whether this instance is actually
identical before reusing the fix.
It wasn't, and a pre-existing test caught it before it shipped.


What actually happened, in order

The shakedown found the exit engine had never been able to fire (D-074)

A full professional-trader review, method unchanged from D-070: read live state against what the
code claims. position_pnl_pct divided by GROSS premium summed across legs — on a vertical
spread, 2-7x the net. Priced at their own entry parameters, three of four live exit rules could
never trigger and the fourth fired 48 points late. Fixed to the net debit/credit, which is what
a broker's P&L% column actually shows.

Same pass: interim scoring's bands were written in percent against a caller passing fractions
(band 1 needed +2500%), option-chain compaction had never once executed because the MCP tool
wrapper returns a tuple, not the dict the compactor was written against (79,542 → 6,076 chars once
fixed), and a dead function (_market_pulse) carried its own copy of thresholds that could be
tuned forever with zero effect. Calibration was out of tune in three independent places — Murphy
reliability read the bin CENTRE not the bin's mean stated probability, the size ladder inverted
twice at tier boundaries, and three different surfaces disagreed about what "your calibration"
even meant. 62% cheaper per decide cycle fell out of the same pass, mostly for free: prompt
caching had simply never been wired in, and one MCP session per tick beat one subprocess per tool
call by 4.5x.

Would a pro have taken these trades? No — and that was the miscalibration (D-076)

18 theses simulated since the ledger began. Zero traded. Every price forecast, meanwhile, was
holding. That's the signature of a miscalibrated gate, not consistently good judgement — the
individual refusals mostly held up under a trader's review, but the aggregate didn't: four
separately-defensible haircuts (a cautious vol window, the bootstrap fat-tail correction, full
round-trip friction, a drift haircut) multiply into a structural no. The system could only trade
in the regime where its edge claims are weakest.

The finding underneath it: same tape, same day, SPY realized 11.3% over 21 sessions and 5.9%
over 5 — and the agent quoted the cautious backward window against a 5-day horizon, calling the
short one "one quiet week, not a forecast," which is true, except the long one is also not a
forecast, just the more defensible-sounding one. Nothing forced that choice to be defended.

The fix wasn't "trade more." It was report what has to be true, not the EV at one assumed
input
: breakeven_vol, breakeven_drift, and dominant_risk turn "EV is -$20" (which hides
the assumption that produced it) into "wins if realized vol comes in under 7.5%" (a claim the
tape can settle). The most useful single fact this produced: a far-OTM credit spread that looks
like premium-selling classifies as a 10x DIRECTION bet
— the shape people associate with theta
harvesting is, on the actual greeks, the riskiest structure on the board. Two candidate metrics
were tested and killed by a five-line computation before any code was written (EV across a vol
band doesn't discriminate; neither does margin-over-breakeven-win-rate) — a rejected mechanism
that fails cheaply is worth recording precisely because it stops someone re-trying it later.

The memory fix mattered as much as the code fix: five of six relevant lessons pushed toward
"no,"
and the worst offender (friction-is-the-size-of-the-edge) ended with an unscored claim
— "I have declined roughly ten cycles on this basis and been right to" — sitting in exactly the
block most likely to be recalled when a structure looks attractive. Rewritten. Stacked
conservatism was not only in the arithmetic; it was in the memory
, and a code fix alone would
have left the bias intact one layer up.

Kelly's b and Kelly's p were different events (D-077)

Sizing measured the payoff ratio as max_profit / max_loss while measuring probability as
P(profit > 0) — a pair describing two different events. Not a conservative approximation, a
directional one: measured on real structures, it understated a bull put spread's true payoff
by 35% and overstated a call debit spread's by 43%, biasing the whole book toward buying premium
and away from selling it, structurally, at every sample size, with nobody having chosen that.
The fix (conditional E[win|win]/E[loss|loss] off the same lognormal grid the probabilities
already use) has an exact property, proven the next day by a fair-value structure zoo (below):
at fair value, conditional Kelly is exactly zero, always — the algebra collapses cleanly
because EV=0 → b=(1-p)/p → Kelly=0, so Kelly's sign and EV's sign now provably agree. The old
formula ranged to -2.31 on structures with precisely zero edge.

Same pass fixed horizons (three different subsystems had three different, none-correct rules for
how far out a thesis could resolve — unified into one shared window function), and a JSON-parsing
bug where a 6,745-character reply opening with a perfectly good array threw away four real
candidates because the truncation salvage logic was built for a single dangling object, not an
unterminated array. The most interesting failure of the day was a contract test failing
correctly
: a test asserting elfmem's similarity scores are 4x differentiated (true when D-073
was built, against a small block pool) now measures ~1.05x, because the pool has grown and a
returned top-slice clusters near the top of its own range. Not a regression — a premise going
stale exactly as designed, the whole reason that test file exists.

The wiki worry, and the fix that made the question go away (D-078)

The user's concern — stale wiki documents building up unreferenced — turned out to already be
happening, and worse than assumed: the muse samples random wiki files and reads their first 400
characters as raw creative material, and 22 of 28 dossiers had welded a durable fact ("what a
company is") to a perishable one ("this week's earnings beat") in a single opening sentence. A
live muse run seeded on a 15.8-hour-old NVDA note read "Closed 228.17, +5.2% on the week"
against a tape that had since moved to -1.8%. Stale wiki content wasn't inert clutter — it was
an unlabelled input to thesis generation.

An optimize simulation over ten frozen scenarios tried the obvious fix first (expire stale
dossiers out of the muse's pool) and it broke the muse on any day without a fresh research run —
the pool collapsed to two files. The fix that actually dissolved the problem: split every
concept into a durable half and a perishable half, and only read the durable half back.
A
company's identity doesn't go stale; its earnings snapshot does. Nothing needs excluding, so
nothing can starve, and a month-old concept colliding with today's news stopped being a defect
and became the muse's actual mandate. Backed by a per-type lifecycle table enforced at the write
path — a document type that hasn't declared how it ages cannot be written, the same discipline
as the existing augmentation guard — and a housekeeping sweep that tombstones (status: deprecated, in place) and never deletes, because OKF says so and every other store in this
system is already append-only.

The retrospective found the calibration sample was half garbage (D-080)

Asked to score how well the 18 theses would have performed historically. Only 1 had matured — but
running the underlyings' own price history against every recorded band surfaced something worse
than a missing answer: bands placed at prices that don't exist. NVDA [650, 920] against a spot
of 218.97. These weren't bad theses; they were price levels recalled from model training data,
the exact premise-hallucination class named a day earlier, now showing up in band placement
specifically. The muse's own gates had already caught 13 of 15 — rejected for a 0% base rate, a
vacuous 99%+ probability, an implausible band. The ledger recorded them as claims anyway,
because registration and belief were the same event: a candidate was born probability_stated=True
before any gate got to see it. Half the incoming calibration sample, due to land in one batch the
day before the deadline, was material the system had already refused. Fixed by making a claim
earn the right to be scored — register as a trial (needed for the multiple-testing correction),
promote to a scored claim only after every gate passes.

Buried in the same pass: correlated Kelly bets must be scaled to the effective number of bets,
not the raw count.
Summing the individual Kelly fractions of the 9 positive-edge survivors gives
321.7% of bankroll; five of those nine are all SPY, so the effective independent bet count
(inverse-Herfindahl) is 2.0, not 9, and the correlation-aware aggregate comes to 70.6% — the naive
sum overbets by 4.6x. The book cap was absorbing that overbet by accident, which is luck, not
design, and would stop being luck the moment sizing tiers scale up.

Never ask a model for a number it can only recall (D-081)

The direct cause of the hallucinated bands, traced properly: the muse's prompt asked for
band_low/band_high as prices in dollars and supplied concepts, news, and odds — but never
a spot price. So it answered from memory. The tempting fix (a central price-lookup service) was
rejected on inspection: the muse names arbitrary underlyings, so nothing knows which spot to fetch
until after the model has already replied. The fix that actually matches the project's own stated
principle (research.py's docstring: numbers are computed, never asked of the LLM) was to stop
asking for a number at all — the schema now takes percent moves from current price, converted
against a live close after the model replies. Same day, same code path: the muse's rejection rate
fell from 13-of-15 to 1-of-5. It had been spending whole LLM calls to be correctly refused.

Also closed: n_eff (effective independent bets, inverse-Herfindahl) now renders next to n
everywhere calibration is shown, reported but never gated — concentration is a reason to distrust
generalising from the sample, which is a judgement for the reader, not a veto (a test enforces
that n_eff never appears inside sizing or competence). And a decision on rejected theses:
yes, keep them, and the missing piece was recording WHY — a rejected candidate still resolves,
so comparing "we refused it" against "it would have held" scores the GATE's threshold, at zero
cost, without ever letting a reject reach calibration.

Checking the "fixed" issues found two more issues (D-082)

A verification pass over the open issue ledger — read the code, don't trust the checkmark. Found
the exit-rule tautology (above) still live in a second spot, and while writing a heartbeat to fix
it, found the staleness check would have wrongly flagged a healthy quiet fire alarm as broken.
Also found two issues whose titles had been struck through as resolved while their bodies
still read as action items — a stale record about staleness, inside the ledger built specifically
to prevent that.

Three models tried, one worked, and none of it was decided from documentation (D-083 → D-085)

The day's clearest demonstration of "verify the belief, don't trust the doc":

  • GLM-5.2 via OpenCode Zen: documented as agent-oriented with function-calling support. Live,
    it burned its entire 8,000-token completion budget on invisible reasoning for the muse's actual
    structured prompt and returned zero visible characters — a "successful" call by every
    metric LangChain's fallback machinery can see, so it would have silently produced 0 candidates
    every run it served. No documented lever found to bound it.
  • Grok-4.6, same gateway, chosen because the config-level provider mechanism built for GLM-5.2
    made the swap a pure config edit. Zen's own model catalog confirmed the correct id; the live
    endpoint returned HTTP 500 three times running, and the sibling model 503'd — genuine
    upstream outage, confirmed by ruling out prompt complexity and by checking a second model in the
    same family. This is the one that actually validated the fallback chain works: build_model()
    invoked directly, live, answered via Claude with Grok erroring first — the exact mechanism
    proven once before (D-008) against a real Anthropic 400, now re-proven against a live Zen 500.
  • gpt-5.6-sol, direct on OpenAI (no gateway needed — openai: is already a real
    init_chat_model builtin). A live 400 on the very first bound-tool call named its own fix
    exactly: use_responses_api or reasoning_effort="none". Chose the one that keeps full
    reasoning, because this project's own README already argues economising on decide's reasoning
    is false thrift. Verified live, twice, that the tool call fires and its result reaches the final
    answer.

The three failures were not the same failure. GLM-5.2's was silent (a call that "succeeds"
with nothing usable, invisible to exception-based fallback). Grok-4.6's was loud but
external
(a real HTTP error, which the existing fallback machinery already handles correctly).
gpt-5.6-sol's was loud and structural (a documented API constraint that would 400 on every
single cycle until fixed). Three different failure shapes needed three different kinds of
evidence to tell apart, and none of them were visible from the vendor's own docs page. The
mechanism that made the third fix small — Config.resolve_model_spec gaining a composable second
layer (model_options, keyed on the exact spec string, layered onto the providers override) —
only existed because the first migration had already needed one layer of exactly this shape.


Interesting concepts, distilled

  • A heartbeat must be a record independent of the output it's measuring, checked four times
    today (see throughline above) — the single most repeated lesson of the day, and the one that
    still had a near-miss on its fourth appearance.
  • Two independent computations of the same quantity, surfaced side by side, is the highest-yield
    bug-finding shape in this codebase.
    Today's instances: gross vs. net premium (exit rules),
    vol-days/308 vs. calendar/365 (two clocks), the ledger-inclusive sample vs. positions-only
    sample vs. what the prompt showed (three "your calibration" numbers), EV at market drift vs. EV
    at the agent's own drift (the column that let the agent decline its own hidden assumption), and
    a recorded price band vs. the live spot (the hallucinated-bands finding). None of these needed
    a clever detector; every one needed only that the second number be computed and shown next to
    the first.
  • A fair-price structure zoo turns "is the sizing biased?" from a vague worry into an exact
    measurement.
    Pricing every structure at its own expected intrinsic under the same grid the
    stack uses makes a fair bet fair by construction, so any edge the stack reports afterward is
    provably an artefact of the stack, not the market. Kelly's zero-EV-implies-zero-Kelly identity
    (D-079) is the clean example of this technique paying off — worth reusing whenever a design
    claim needs a number rather than an argument.
  • Durable vs. perishable is the general answer to "does this document go stale." Not a
    retention policy, a content split: separate what stays true from what is a snapshot of a moment,
    and only the second half ever needs a staleness clock. The same split that made the wiki problem
    dissolve also explains why the constitution shouldn't hold market views (notes/010, written
    yesterday) and why a decline's reason belongs in the ledger while its price band belongs in
    the journal.
  • Registration and belief must be separable events. Both the calibration-pollution bug
    (D-080) and its root cause (D-081's probability_stated default) were the same shape: a trial
    is born, and something downstream treated "was proposed" as "was claimed." The fix pattern —
    register unconditionally (needed for honest trial-count corrections), promote only after every
    gate passes — is reusable anywhere a pipeline needs both an unbiased denominator and an honest
    numerator.
  • A composable per-spec override layer, built once, paid for itself twice in one day.
    resolve_model_spec's two layers (providers for a different endpoint/key, model_options for
    a model's own API quirk) exist because a global kwarg would have broken ChatAnthropic the
    moment it shared a fallback chain with an OpenAI-compatible model. The second migration needed
    only the second layer, already shaped to compose rather than replace.
  • The constitution is now a scarce resource, not a growing list. [assumptions] (D-076) took
    it to 427 of a 430-token ceiling. The next principle this project wants requires retiring one —
    a real constraint, not a hypothetical one, and it changes how any future self-review mechanism
    is allowed to propose new principles (see the notes file below).
  • The manual trader-critique (D-076) found the single largest issue of the day — an 18-and-0
    record hiding behind individually-defensible refusals — and it found it by doing exactly what
    health.py and the test suite cannot: reading the AGGREGATE of many technically-correct
    decisions and asking whether the pattern itself made sense. That gap is the direct motivation
    for the research note below.

Where it stands

gpt-5.6-sol serving live and directly, confirmed via a real muse run with zero fallback needed;
Claude and GPT-5 held as verified fallback, Grok-4.6 and GLM-5.2 fully removed from the active
chain (not merely reordered). elfmem moved off the elfmem_index git branch onto the published
PyPI release (0.20.0, same commit, one fewer moving part) the moment upstream merged it — D-087,
the last thing done today. 225 default tests + 18 contract tests, all green. One open position
(SPY bear put spread, -12.66% unrealized, well inside its stop/target, its exit rules and interim
scoring both now correctly reporting armed, not stalled rather than a false alarm). Calibration:
1 resolved forecast, 13 pending genuine claims (down from a nominal 26 that included rejected
material). health: 0 problems.

The day's arc, in one line: every fix either made a silent failure loud (the four heartbeat
instances, the three model migrations) or made a hidden assumption visible (the breakeven lines,
the two-column EV table, the durable/perishable split, the percent-not-dollar band). Nothing
today added a capability the system didn't already claim to have; everything closed the gap
between the claim and the evidence for it.

Source docs/dev_journals/2026-08-28_concepts.md