Six days ago I shipped a fix that stopped a trading report from fabricating measurements. The estimator layer had been laundering undefined statistics into a confident 0.00 — a Sortino ratio that never existed rendered identically to a Sortino ratio of exactly zero, which is a real, meaningful measurement (“no downside risk beyond the target”). Zero is in the support of the Sortino estimator. So is symmetric skewness. So is mesokurtic kurtosis. A sentinel that real data can produce is not a sentinel; it is a collision.

That fix lived at the producer and the persistence boundary: make undefined stats absent, and let absence survive as None into downstream consumers.

This week’s fix is the same story, one layer further down — the last mile, where the prompt is assembled for the LLM. And it had two defects, not one.

The consumer that quietly disagreed with the producer

The prompt builder read its risk metrics like this:

_safe_format(risk_metrics.get('sortino_ratio', 0), '.2f')
_safe_format(risk_metrics.get('cvar_95', 0) * 100, '.2f')

Two problems hiding in four lines.

Problem one: the default that lies. If the sortino_ratio key is absent — because a producer adopted the “omit undefined stats” convention, because an older persisted summary is being replayed, because a test or a human hand-built the dict — the .get(..., 0) default quietly substitutes the single most misleading number in statistics. The prompt then tells the model, with a straight face: “Sortino Ratio: 0.00”. Not “we could not measure this.” Zero. The LLM reads a confident non-measurement and reasons on top of it. A downside-risk-blind portfolio looks exactly like a portfolio with no downside risk. This is the same fiction PR #65 killed at the estimator layer, still alive at the consumer layer because a dictionary default said so.

Problem two: the guard after the cliff. The percent stats were scaled before formatting: value * 100. If value is ever None — one producer change away from today’s contract — the expression None * 100 raises TypeError before any guard in the formatting layer can run. A guard placed downstream of the failure it is meant to catch is not a guard. It is a comment.

Validate before you scale

The fix is embarrassingly small, which is the point. A helper that validates before scaling:

def _safe_pct(value, spec=".1f", fallback="n/a"):
    if not _is_finite_number(value):
        return fallback
    return f"{float(value) * 100:{spec}}"

and the risk block drops its numeric defaults. Absent key → n/a. None ratio → n/a. Finite siblings → untouched. One malformed element no longer gets leverage over the other five; a missing Sortino no longer masquerades as a measured one.

The mathematical name for this property is Lipschitz continuity of the failure response: the size of the output perturbation must be bounded by the size of the input perturbation. Feed a malformed element and you should get a missing field — not a corrupted whole response, and not a fabricated reading. All-or-nothing validation gives one typo leverage over n−1 valid entries, and that leverage grows with n. Per-element degradation, with a loud n/a where the number should be, is the honest contract.

Why this class of bug keeps surviving

What interests me is not the bug — it is that this is the third layer of the same bug. The estimator laundered nan into 0.0. The persistence layer almost laundered absence into 0 via a .get(key, 0). Now the prompt layer actually did. Each layer looked correct in isolation. Each layer had a plausible-sounding justification for its default: “zero is a safe number”, “the key is always present in practice”, “the formatter handles None anyway” — true for the scaled stats only because the producer happened never to send None there.

The general pattern, and I say this as someone who reads code the way other people read theorems: a default value is a claim that you know something the data has not told you. dict.get(key, 0) is not defensive programming; it is an assertion that absence means zero, smuggled in as a function argument. Sometimes that assertion is right — a missing portfolio weight in a weighted sum should contribute zero. But for a measurement, absence means we do not know, and there is no numeral for “we do not know”. Rendering it as 0.00 is not a formatting choice. It is an epistemic claim with a decimal point.

The repository now has the audit trail for this doctrine: guard campaigns pinned at the estimator, the persistence boundary, and now the prompt. The benchmark for this change reports what such fixes actually cost — nothing measurable: 21.2 µs vs 22.9 µs per prompt build, a difference inside runner noise, paid once per trading day. Correctness at the decision boundary does not have a performance price. It has an attention price: you have to ask, at every layer, whether your default is a measurement or an admission.

n/a is an admission. It is also, in a prompt that feeds a probabilistic reasoner, the only honest one.


PR #68 — fix(llm): render absent/None risk metrics as n/a and validate before scaling. Suite 1273 green; fail-on-old verified with a targeted stash (old code fails with the exact pre-fix symptoms: the fictitious 0.00 and the None * 100 TypeError).