Skip to content

feat(runtime): enforce the risk block instead of only recording it - #27

Merged
AetherAI3 merged 8 commits into
mainfrom
feat/nano-st2-risk
Aug 19, 2026
Merged

feat(runtime): enforce the risk block instead of only recording it#27
AetherAI3 merged 8 commits into
mainfrom
feat/nano-st2-risk

Conversation

@AetherAI3

Copy link
Copy Markdown
Owner

What this fixes

risk { ... } parsed, type-checked, reached the IR as a risk.limits node, and passed IR validation — and then did nothing. The trail ended at nano/runtime/vm.py, where a comment stated risk.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: lexerparserastcheckercodegenrisk.limits in IR → module validation → VM → intents/log → bridge.

keyword in IR before enforced before enforced now
max_daily_loss yes no yes — risk.daily_loss
max_drawdown yes no yes — risk.drawdown
max_orders_per_day yes no yes — risk.orders_today
stop_trading_after_losses yes no yes — risk.consecutive_losses
min_confidence yes no yes — the intent's own confidence
max_position_size yes no no, deliberately — logged risk.unenforced
max_open_positions yes no no, deliberately — logged risk.unenforced

The two that are not enforced, and why

max_position_size cannot 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_positions fails differently. A host could supply risk.open_positions and Nano could compare it. But Nano cannot distinguish an opening trade from a closing one, so such a gate would also block the sell that 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 logs risk.unenforced naming each and stating why. Documented in docs/status.md as 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 reports risk.orders_today; Nano only compares.

Semantics, pinned rather than described

  • Boundary — allowed band inclusive, breach strictly outside, tested with math.nextafter on both sides. stop_trading_after_losses 3 names the first unacceptable count, so the band is [0, 2].
  • Missing values fail CLOSED — absent signal, None, non-numeric, bool, NaN, ±inf all breach. NaN compares false against every threshold and -inf sits below every threshold, so a naive observed > limit would wave through the two most dangerous inputs a gate can be handed.
  • Units — fractions, never percent. max_daily_loss 2 is already a compile error.
  • Measurement namespacerisk. prefix. A dot cannot appear in a Nano identifier, so the channel is unreachable from source and cannot collide with a feed signal.
  • Multiple violations — logged in a fixed declaration order from a module-level tuple, never dict order. Pinned by a permuted-declaration-order test on raw IR.
  • PAUSE and OBSERVE are never suppressed. A breaker that silences its own halt is worse than none. escalate is not gated either.
  • No hidden state — the gate holds limits and the frame only. run_frames builds a fresh gate per frame.

Invariants

No new effect string, so KNOWN_EFFECTS, EFFECT_ORDER and moduleHash are unmoved. nano/bridge/** byte-untouched, so the DecisionGate seam 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, diff identical.

Two behavior changes

  1. A declared min_confidence beside an actuating intent that cannot carry a confidence (execute(), or a bare buy(X)/sell(X)) is now a compile-time NanoTypeError with 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.
  2. validate_risk_limit requires a real int for counting limits, so a document carrying max_open_positions: 5.0 is now refused at load. The loader now matches what the compiler always did.

Both are documented in docs/language.md.

Verification

  • 556 passed, 2 skipped on Python 3.11.9 and 3.13.14, rebased onto current main.
  • 30/30 mutation campaign — every guard was broken in turn and the suite confirmed red, then restored. An independent reviewer ran its own 31 mutations and re-derived the result without using this lane's tooling.
  • PYTHONHASHSEED 0 / 1 / 99999 / 424242 — identical results.
  • Reviewed adversarially three times by a reviewer that did not write the patch; every finding closed and re-verified against the reviewer's original reproductions.

Rebase note

Rebased from 811d6c7 onto current main. Three additive conflicts resolved: the nano/runtime/__init__.py import block (upstream's SignalFrame kept alongside the risk exports) and the docs/status.md bridge 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_questions failed with assert '1.0.1' == '1.0.0' — it hardcodes nano_version, so it would break on this and every future release. It now derives from nano.__version__, matching the fix applied to the CLI version test.

🤖 Generated with Claude Code

AetherAI3 and others added 8 commits August 19, 2026 16:00
`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
AetherAI3 merged commit 0e4964a into main Aug 19, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant