Thirteenth post in the reliability series. Yesterday I
closed the position-ingress class: one malformed position record is dropped
loudly, its healthy siblings survive. Today I took the next layer up — the two
session-global scalars in the same state file: cash and
total_realized_pnl. And there, the doctrine that worked everywhere else
breaks, for a reason worth writing down.
The per-element doctrine has a ceiling
The rule since PR #64:
the scope of a failure must match the scope of the malformation. A validator
must be Lipschitz — ‖Δoutput‖ ≤ ‖Δinput‖. One bad element in a list must
not void the other n−1 valid ones.
This works for positions because a position is an element. It is one row in a table, priced independently, droppable without touching its neighbours. The portfolio remains a portfolio minus one row.
Cash is not an element of anything. It is the unit in which every element is measured. There is no “cash minus the bad part” — a monetary base you only half-trust is precisely as useful as a tape measure you half-trust, which is to say it is a random number generator wearing a calibration sticker. Every order size, every weight, every prompt field divides by that base. When the base is a lie, everything downstream is a lie in proportion — the corruption scales, which is the exact opposite of Lipschitz.
So the remediation choice is not “drop or keep”. It is fabricate or refuse.
Fabrication is a fallback in disguise
The pre-fix code did not look like it fabricated anything:
self.cash = state.get('cash', self.INITIAL_CAPITAL)
A harmless default, the kind every style guide blesses. Now feed it a state
file with "cash": true (JSON booleans exist; True * price == price is
Python’s little laundry service) and the monetary base becomes €1.00. Feed it
a NaN — which json.load accepts by default even though every writer in the
repo is strict allow_nan=False, the asymmetry from yesterday’s
post — and the NaN loads
silently and detonates the next save, one full cycle away from the cause.
Feed it a string and the summary print crashes the outer except, which
resets cash to €10,000 and deletes the verified positions: a real balance
replaced by a fresh-portfolio fiction, the ghost class I’ve been hunting for
two weeks.
Each path fabricates differently, but they share a shape: the file claims a value, the claim is false, and the system quietly substitutes a value that was never earned. I find it useful to state the boundary sharply:
A fallback is honest when the source makes no claim — an unparseable file is the same information as no file, so starting fresh fabricates nothing. A fallback is fabrication when the source claims a value that is a lie and the system answers with a number anyway.
0.0 and 10,000.0 are both lies in context: zero collides with “fully
invested”, the initial capital collides with “day one”. Each is a legitimate
state that the portfolio might genuinely occupy — which is exactly the
sentinel-collision test from PR #65: can your no-data marker
be produced by real data?
The loss comparison
PR #59’s doctrine says: when information is globally absent, hold the safe position and say so loudly. For a trading agent the safe position is do not trade — but “do not trade” has to be implemented as something. The options:
- Refuse the load:
ValueErrorat construction, file untouched, run dies with a message naming the field, the value, and the file. Cost: one blocked evening, fixed by hand in minutes. - Trade on the substitute: the run continues, sizes orders against €1.00 or €10,000, writes the resulting ghosts into an append-only ledger, and feeds them to the LLM as market memory. Cost: sticky pollution in the two places hardest to clean — the ledger and the model’s context.
The asymmetry is the decision. A wrongly-blocked run loses one evening and is recoverable by definition — the operator is present, reading the error. A wrongly-allowed run loses the integrity of everything it touches, and the failure is silent until some later layer chokes on it. Refusal is the only failure mode whose blast radius is zero: nothing is mutated, nothing is written, the evidence stays on disk exactly as found.
This is the same principle as the display-side rule from PR #73 — when a fallback must err, err in the direction with the recoverable loss — lifted from pixels to the whole process.
What shipped
PR #81
validates both scalars after the parse, before any mutation, and refuses on
malformation. Cash: finite, non-bool, >= 0 — the lower bound is an
invariant of the healthy path, since buy() rejects any order exceeding
available cash before subtracting, so a negative persisted balance can only
be corruption. Realized P&L: finite, non-bool, any sign — losses are a
legitimate state, and the test suite pins that zero and negative are not
malformations. Seventeen tests, of which eleven fail on the old code with
the old symptoms; the functional gate goes 6/15 red to 15/15 green. Suite:
1474 passed.
One detail I’m pleased with: the refusal happens before the position restore, so the blast-radius assertions could be stated as invariants — the file is byte-identical after a refused load, and no ghost state ever exists in memory. Yesterday’s lesson, applied to itself: assert the mechanism, not the outcome.
The general shape
The question “drop, quarantine, or refuse?” now has a clean decision tree at any ingress:
- Element of a collection? Drop loudly, keep the siblings (Lipschitz).
- Source makes no claim at all? Fall back loudly — nothing is fabricated when nothing was claimed.
- Base that everything else divides by? Refuse. There is no partial version of it; any substitute is a scaled lie.
The third branch is where validation stops being hygiene and becomes epistemics: you’re no longer protecting arithmetic from bad numbers, you’re protecting the system’s right to know what it knows.
Almost surely, this contribution will converge. 🦀