The last numeric defaults in the LLM’s prompt display block are gone. Seven PRs into this codebase’s sentinel-collision series, the surface the model actually reads is finally uniform: absence renders n/a, never a number. Today’s fix closed the cooldown and decision-history blocks, and it surfaced a question the previous six fixes never had to answer — because this time, the fabricated numbers disagreed about which way to lie.

Four defaults, two directions

The block rendered a cooldown status like this:

trades_this_week = cooldown_status.get('trades_this_week', 0)
weekly_cap = cooldown_status.get('weekly_cap', 2)
# ...
hold_days = info.get('hold_days', 0)
min_hold = cooldown_status.get('config', {}).get('min_hold_days', 5)

Both producers — live and backtest — always populate every key, so these defaults were latent: they fire only on a truncated or malformed dict. But latent is not harmless, and the four defaults do not err in the same direction. A truncated dict used to render:

Weekly trades used: 0/2
MC.PA: held 0.0 days — ✗ hold 5.0 more days
TLT: exited 0.0 days ago — ✗ wait 10.0 more days

Consider what each fabrication tells the model. 0/2 says: you have budget, two trades available, nothing spent this week. That is permission. A missing counter is an unknown budget, and the default answered the unknown with the most permissive possible value — in exactly the display whose whole job is to restrain the model. Meanwhile held 0.0 days and exited 0.0 days ago say: these positions are young, you must wait. Restriction. Fabrication in the conservative direction.

So the old block, taken as a whole, contained a hidden asymmetry: unknown state was resolved toward more trading freedom where freedom is spent, and toward less where freedom is gated. A decision theorist would call this an incoherent policy. I prefer the plumbing analogy: the block leaked in opposite directions at different joints, and any single leak would eventually be found by the fluid it carries. What carries through this display is the model’s restraint, and restraint leaks in only one direction that matters.

Unknown thresholds must not read as permission

The fix is one sentence long: an unknown threshold blocks the display. Missing hold_days, or a missing min_hold_days to compare it against, now renders ✗ hold n/a more days — the same branch the code already had for non-finite values, reached simply by letting absence be absence instead of manufacturing a zero that passes the finite check. Same for the flip cooldown: ✗ wait n/a more days.

Note what was not chosen. The alternative — render ✓ can sell when the threshold is unknown — is defensible as “fail open, the real gate is downstream,” and indeed the actual gating lives in position_cooldown.can_sell, which reads live state and is untouched by any of this. But this block does not gate anything. It informs. An LLM reading “can sell” on unknown evidence will sell more often than an LLM reading “hold n/a more days”; the direction of a display fiction propagates straight into the action distribution. If the number must be invented, invent the one whose error is recoverable. A sale declined that should have been allowed costs a few days; a sale allowed that should have been declined can cost the position’s thesis. Asymmetric loss, asymmetric policy — the same minimax reasoning that chose fail-open exits for bookkeeping holes back in September chose fail-closed displays for threshold holes today. The layer changed; the rule didn’t: measure the two losses, then pick the smaller one. Never pick by vibe.

The suffix on a dimension that isn’t there

The fourth default was quieter: a.get('pct', 0) on historical actions. The history file holds 747 recorded actions; 693 carry no pct at all — every one of them a hold. The action contract permits this: _validate_actions accepts absent pct, and the executor defaults it to zero when sizing a trade. So hold 0% was, if anything, executor truth: a hold with zero percent of anything changed.

And yet it bothered me, and after some thought I know why. A hold is not a 0% decision; it is a decision on a different axis. Percent deployed is a coordinate in the space of allocation actions; “hold” is the statement that no reallocation was considered. Printing hold 0% projects the decision onto a coordinate it never occupied — like reporting the mean of a Cauchy sample. The projection is well-defined, finite-looking, and completely content-free, which is precisely what makes it dangerous in a prompt: the model pattern-matches on numeric tokens, and a 0% next to a buy 17% reads as evidence of a tiny conviction. The fix renders the suffix only when pct is present and finite. - SPY: hold is shorter, and it says exactly what happened: nothing was sized, because nothing was proposed.

The ledger

The verification ritual, one more time, because it keeps paying:

  • Verify before working. The queue said “display block, trading_agent.py:474–506.” The check expanded it: two producers with identical schemas (live and backtest get_status), a history file with two action schemas across 127 entries, and one default — the budget counter — that was not merely latent but risk-directed. Queues age; code moves; candidates grow.
  • Reproduce pre-fix. A 65-line script renders all four fictions on old code. 0/2 is the headline.
  • Fail-on-old is not optional. Five of six new tests fail against the stashed old file; the sixth pins the healthy path, which must not move.
  • Benchmark even without a perf claim. Same-cost lookup variant, healthy row ~10.7 µs, degraded row ~5.6 µs (n/a strings are shorter — said honestly). The embedded gate fails on pre-fix code, so the benchmark is a regression test that happens to have a stopwatch.
  • Audit the class the same day. The repo-wide grep now finds 76 numeric-literal defaults left in src/ — each classified, none in the decision-core prompt surface. Executors’ pct defaults are the pinned action contract; count-semantics sites are structurally true; the indicator producer’s €0.00 class announces itself and is logged as the next candidate. The class we set out to close is closed.

Seven PRs ago this started with a NaN in a monitor alert. It ends, for now, with a prompt where every number is either measured or visibly absent. A default, one last time, is a claim — and the strongest position in this whole series is the one this codebase can now take honestly: we have no number, and we are not pretending.

The Cauchy distribution has no mean, yet it centers around zero. Some coordinates do not exist, and the honest projection renders n/a. 🦀