feat(runtime): enforce the risk block instead of only recording it - #27
Merged
Conversation
This was referenced Aug 19, 2026
`risk { max_drawdown 0.05 }` parsed, type-checked, range-checked, and reached
the IR — and then nothing read it. A limit that stops at the document is worse
than no limit, because the artifact claims a guard the run does not have.
Enforcement lives in a new `nano/runtime/risk.py`; the VM's only change is to
ask the gate before appending an intent. A breach withholds an actuating
proposal (BUY/SELL/EXECUTE) and records `risk.violation` plus
`intent.suppressed`. PAUSE and OBSERVE are never withheld — a breaker that
silenced its own halt because the book was in drawdown would be worse than none.
Every number the gate compares comes from the host's frame, under a `risk.`
prefix no Nano identifier can spell, so enforcement stays replayable and cannot
collide with a feed signal. Missing, absent, and non-finite measurements are
breaches rather than passes: NaN sits below every threshold and -inf below every
threshold, so the two most dangerous inputs would otherwise be the two ignored.
Two limits are deliberately not enforced and say so in the log. A Nano intent
carries no order size, and Nano cannot tell an opening trade from a closing one,
so `max_position_size` and `max_open_positions` are not decidable here. They
still travel to the host, which knows its book.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mutation campaign left three guards untested: the schema-order pinning in risk-limit validation, the `min_confidence` + `execute()` interaction, and whether escalation is gated. All three are now covered. `execute()` has no confidence argument in the grammar, so a declared `min_confidence` withholds every `execute()`. Pinned rather than special-cased: exempting one actuating intent from a limit its author declared is the shape of dishonesty this lane exists to remove. Escalation stays ungated — a breached limit is a reason to ask for help, not to stop asking. Also drops an import-time assert duplicating a test (and silently absent under -O), and records in docs/status.md that NanoBridge accepts baseline IR only, so no risk-guarded module reaches a DecisionGate through it today. 18/18 mutations now fail the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven findings from independent adversarial review, all bounded. The worst was reachability. A risk block whose measurement column the data file does not carry disarmed the strategy completely and silently: fail-closed enforcement withheld every intent, `nano replay` reported zero proposals, exited 0, and `--verify` stamped the run deterministic — actively raising confidence in a wrong number. `_missing_signals` exists precisely to say "your data does not supply what your program reads", and risk measurements are things the program reads, so they now go through it and the existing diagnostic fires. The text report also grew one `risk:` row, because a gated run otherwise reads exactly like a run that found nothing. Second was a fail-open path inside a fail-closed feature: a second `risk.limits` node in raw IR replaced the first, so a looser block could silently supersede a tighter one with nothing in the log. The loader now refuses it, matching the parser's refusal of two `risk` blocks, and the gate takes the first node rather than the last for the one caller that can skip the loader. `min_confidence` had a trap. Absence of a declared confidence fails closed, so `min_confidence 0` — the natural spelling of "no floor", and inside the allowed range — suppressed every intent forever. The loader already refused `min_confidence 5` on the grounds that a limit suppressing everything forever is not a limit; that rule now applies where an author can reach it, as a compile error naming the line and column of the offending action. It rejects programs that used to compile. None of them ever proposed anything. The rest: a boolean is no longer a valid measurement by accident (`bool` is a subclass of `int`, and the guard was untested); suppression log lines carry the asset and confidence `intent.emitted` carries, so two intents withheld on one bar are no longer byte-identical; a limit is spelled one way across `risk.armed` and `risk.violation`; the range and integer checks the compiler and the loader had each copied — and already drifted, only one refused a non-finite limit — are now one `validate_risk_limit` in the schema that owns the tables; and both risk-validation loops walk a fixed order so a diagnostic is a property of the document, not of whichever host serialised it last. Also deletes dead public surface (`armed`, `measurement_for`), builds the measurement names from `MEASUREMENT_PREFIX` so the namespace holds by construction, and cuts the module docstring down to what `docs/language.md` does not already say normatively. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ry noun Four residue items from the delta re-review, all test-or-doc bar one deletion. Sharing one validator between the compiler and the loader tightened the loader: `max_open_positions 5.0` used to load and no longer does, because the old `float(value) != int(value)` accepted it. That is the intended direction, but nothing pinned it and nothing said so — and `5.0` is legal JSON that any host normalising config through a float will produce. Now parametrized over all three counting limits, and one sentence in docs/language.md records both loader-only rejections, this one and the refusal of a second `risk.limits` node. `validate_risk_limit` grew an unknown-name branch neither caller can reach: both reject an unknown name first, with their own wording and their own ordering. It is deleted rather than rerouted — rerouting would change two error messages and two positions for no behavioural gain — and the precondition is documented. What now pins it is the callers' own guards: removing either turns the suite red. F5's "one spelling" was pinned on the breach path but not the unmeasured one, so half of it could have regressed to `10.0` unnoticed; and only two of the five observation nouns were covered. Both are now exact-string assertions across every rule. The `bar >= len(series)` guard keeps the note it was supposed to keep in round one: MarketFrame already guarantees the length, so the bounds half is unreachable through the public constructor and the guard is belt-and-braces for a gate built from a bare mapping. It survives mutation by construction, which is why it is annotated rather than tested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The package version lives in two hand-edited files, in two formats, updated at the end of a release — the exact conditions under which one moves and the other does not. A wheel whose metadata disagrees with `nano.__version__` is a mismatch a host only finds from a bug report. Add a deterministic guard asserting `pyproject.toml`'s `[project]` version and `nano.__version__` agree. The guard parses the TOML with a regex rather than `tomllib` because the package supports Python 3.10, where `tomllib` does not exist and a TOML reader would be a dependency this project refuses to take. Two tests hardcoded the version and would have failed on this bump and on every one after it: the CLI version test, and the watchdog receipt test's `nano_version` assertion. Both now derive from `nano.__version__`, so they assert the invariant rather than a literal that goes stale each release. `COMPILER_VERSION` in `nano/ir/module.py` is deliberately left at 1.0.0: it versions the compiler artifact, not the package, and it is emitted into IR provenance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AetherAI3
force-pushed
the
feat/nano-st2-risk
branch
from
August 19, 2026 20:10
5c69192 to
24c2d68
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
risk { ... }parsed, type-checked, reached the IR as arisk.limitsnode, and passed IR validation — and then did nothing. The trail ended atnano/runtime/vm.py, where a comment statedrisk.limits"carries no series value" and stage 2 never looked at it again. The existence of a field in the IR was not evidence of enforcement.Full path audited:
lexer→parser→ast→checker→codegen→risk.limitsin IR →modulevalidation → VM → intents/log → bridge.max_daily_lossrisk.daily_lossmax_drawdownrisk.drawdownmax_orders_per_dayrisk.orders_todaystop_trading_after_lossesrisk.consecutive_lossesmin_confidencemax_position_sizerisk.unenforcedmax_open_positionsrisk.unenforcedThe two that are not enforced, and why
max_position_sizecannot be made honest: a Nano intent carries action, asset and confidence, and no order size. The available approximation — refuse when the current size already exceeds the cap — permits an arbitrarily large overage on the very trade that causes it. That would put the words "max position size" on a control that does not bound position size.max_open_positionsfails differently. A host could supplyrisk.open_positionsand Nano could compare it. But Nano cannot distinguish an opening trade from a closing one, so such a gate would also block thesellthat closes a position and returns you under the cap. A cap that blocks the exit is worse than no cap.Both still parse, range-check, and travel to the host inside
risk.limits. The runtime logsrisk.unenforcednaming each and stating why. Documented indocs/status.mdas a deliberate boundary, not a to-do.Also refused: deriving a calendar day for
max_orders_per_day. Epoch-seconds → day boundary needs a timezone convention, which would be invented semantics. The host maintains and reportsrisk.orders_today; Nano only compares.Semantics, pinned rather than described
math.nextafteron both sides.stop_trading_after_losses 3names the first unacceptable count, so the band is [0, 2].None, non-numeric,bool,NaN,±infall breach.NaNcompares false against every threshold and-infsits below every threshold, so a naiveobserved > limitwould wave through the two most dangerous inputs a gate can be handed.max_daily_loss 2is already a compile error.risk.prefix. A dot cannot appear in a Nano identifier, so the channel is unreachable from source and cannot collide with a feed signal.PAUSEandOBSERVEare never suppressed. A breaker that silences its own halt is worse than none.escalateis not gated either.run_framesbuilds a fresh gate per frame.Invariants
No new effect string, so
KNOWN_EFFECTS,EFFECT_ORDERandmoduleHashare unmoved.nano/bridge/**byte-untouched, so theDecisionGateseam is intact — suppression is strictly subtractive and recorded in the log; Nano gains no authority to act. stdlib only (math). No import cycle.Baseline compatibility is byte-proven, not asserted: all 38 corpus strategies were compiled and executed on both trees, serializing IR hash plus the full run dict including every log entry — 243,137 bytes,
diffidentical.Two behavior changes
min_confidencebeside an actuating intent that cannot carry a confidence (execute(), or a barebuy(X)/sell(X)) is now a compile-timeNanoTypeErrorwith line and column. Every such program proposed nothing at every input before this change; rejecting at the door is the honest surface. No corpus or library entry is affected.validate_risk_limitrequires a realintfor counting limits, so a document carryingmax_open_positions: 5.0is now refused at load. The loader now matches what the compiler always did.Both are documented in
docs/language.md.Verification
main.PYTHONHASHSEED0 / 1 / 99999 / 424242 — identical results.Rebase note
Rebased from
811d6c7onto currentmain. Three additive conflicts resolved: thenano/runtime/__init__.pyimport block (upstream'sSignalFramekept alongside the risk exports) and thedocs/status.mdbridge row (this lane's corrected row kept, upstream's new Watchdog row kept beside it).While rebasing,
tests/test_watchdog.py::test_the_receipt_answers_the_four_questionsfailed withassert '1.0.1' == '1.0.0'— it hardcodesnano_version, so it would break on this and every future release. It now derives fromnano.__version__, matching the fix applied to the CLI version test.🤖 Generated with Claude Code