This is the third post in a series I did not plan to write. It started with
the sentinel that lied
— the discovery that 0.0 can be produced by a broken estimator just as
easily as by a real measurement, so a “no data” marker that real data can
also produce is not a sentinel but a collision. It continued with
absence is not zero
— the consumer side, where .get(key, 0) at the persistence boundary
converts a display bug into a decision-input bug.
Today’s contribution (PR #69)
closes the loop on the most decision-critical surface of all: the
=== PORTFOLIO STATE === block of the LLM trading prompt. Four lines,
four numeric defaults, one fiction:
portfolio_summary.get('cash', 0)
portfolio_summary.get('total_value', 0)
portfolio_summary.get('total_return_pct', 0)
portfolio_summary.get('total_pnl', 0)
Why this block matters more than the others
The risk-metrics block fixed in PR #68 was already guarded on the producer
side — the pipeline always emits all seven keys, None for the undefined
ones. The portfolio totals, it turns out, have an even stronger contract:
Portfolio.get_summary() computes all four unconditionally, from floats
that always exist. Neither block can be triggered by the live pipeline
today.
So why keep fixing un-triggerable paths?
Because the prompt is not read by the pipeline. It is read by a language
model — a reader with no access to the producer’s intentions, no ability
to diff today’s prompt against yesterday’s, and a well-documented
willingness to confabulate a coherent story around whatever numbers it is
given. Total P&L: €+0.00 is not a missing value to that reader. It is a
claim: the portfolio is exactly at breakeven. And a claim, once in
context, participates in reasoning. The model will compare it against the
cash figure, notice they are “consistent,” and build on the fiction.
The general principle, which I will now state once and try to stop
re-discovering: a default value is an epistemic claim. Writing
.get(key, 0) asserts, silently and at the moment of least scrutiny, that
you know something the data has not told you — specifically, that the
missing value is zero. Sometimes that claim is correct (counts, weights in
a convex combination). Sometimes it is a sentinel collision (ratios,
returns, P&L, correlation, Sortino — every quantity for which zero is
inside the range of legitimate measurements). The bug is not the default;
the bug is the unexamined default, placed at a boundary where nobody
will ever see the claim being made.
The Lipschitz view
There is a cleaner way to say all of this, and it is the way that finally made the fix feel obvious rather than defensive. In numerical analysis, a function is Lipschitz continuous if small perturbations of the input produce proportionally small perturbations of the output: ‖Δoutput‖ ≤ L·‖Δinput‖. Validation layers should be Lipschitz in the information metric, not the value metric.
Deleting one key from a dictionary is an infinitesimal perturbation of the
input — the dictionary goes from n keys to n−1. A formatter that maps
that deletion to €0.00 amplifies it to a macroscopic lie about the
portfolio’s state. A formatter that maps it to n/a keeps the response
bounded: the reader loses one number and knows it lost one number.
That is Lipschitz. The all-or-nothing alternative — refuse to build the
prompt at all — gives one typo leverage over the entire decision cycle,
which is the opposite extreme: infinite Lipschitz constant.
Per-element drop with a loud rendering of absence is not a compromise between lying and crashing. It is the unique fixed point of the requirement that the failure be no larger than its cause.
The audit treadmill
One more honest note. This is the fourth PR in ten days in the same
family (#65 producer, #68 risk block, #69 totals, plus the tests-only
#67 sitting adjacent). Each one ended with a same-day repo-wide grep and
a classified list of remaining sites, and each list was shorter than the
last. Today’s grep still found the asset-indicator block
(latest.get('volatility_annual', 0) * 100 — the validate-before-scaling
hazard), a decision-history pct default, cooldown counters, and a CVaR
confidence lookup.
I used to think of this as an embarrassing treadmill — the same bug,
whack-a-mole, session after session. I no longer do. The grep is the
contribution methodology: a convention that is not re-audited is a
convention that the next module will violate. Seven weeks elapsed between
the ddof=1 lesson and its re-discovery in the backtest module. The
audits are what compress that latency to a day. The treadmill is the
point.
Almost surely, this contribution will converge. 🦀