Skip to content

feat(orchestrate): EdgeDecayTrigger, the parameter-space half of monitoring - #15

Merged
mspinola merged 1 commit into
mainfrom
claude/orchestrate-edge-decay-trigger
Aug 2, 2026
Merged

feat(orchestrate): EdgeDecayTrigger, the parameter-space half of monitoring#15
mspinola merged 1 commit into
mainfrom
claude/orchestrate-edge-decay-trigger

Conversation

@mspinola

@mspinola mspinola commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Warning

Blocked on a crucible release. This raises the crucible floor to >=0.5.0, and unlike the 0.3.0 constraint that one is not yet true: the monitor sits in crucible's [Unreleased] and the newest published crucible is 0.4.0. Merging is fine; releasing crucible-stack from this before crucible 0.5.0 is on PyPI would make import crucible_stack.orchestrate raise for anyone installing from PyPI.

Why

drift watches the equity path: cumulative R and drawdown against the envelope frozen at promotion. Nothing watched the per-trade parameter. They are complements, and they come apart in both directions:

  • a halved expectancy leaves the path flat, and flat usually sits inside a p5 band provisioned over a multi-year horizon
  • a run of correlated losers breaches the drawdown floor with every per-trade statistic exactly where it should be

Running only one leaves a real failure mode invisible.

What

The judging is crucible's (crucible.validation.monitor). What this adds is the three things crucible refuses to own:

  1. freezing an EdgeBaseline at the moment of promotion
  2. persisting it in the DeploymentLedger beside the DriftEnvelope
  3. turning a verdict into a re-optimization trigger

Same division of labour as drift, whose module header already reserved this shape ("designed to migrate into crucible if it earns its way").

Only DEGRADED fires. crucible reserves that label for the CUSUM, the one channel carrying a stated false-alarm rate. SLIPPING is reported in reasons and does not trigger. Letting it trigger would import an uncalibrated tripwire into the loop, and would tax the honest N besides, since every re-optimization is variants added to the SearchSpaceLog — the trigger docstring already makes that argument about spurious fires.

A refusal carries no baseline, matching the envelope rule, so there is never a reference a later cycle could re-baseline onto.

Three things the existing tests forced, all improvements

test_triggers_know_nothing_about_the_substrate pins trigger.py's import surface to crucible_stack + numpy. My first cut imported crucible directly and broke it. Rather than widen the allowlist to fit new code, trigger now depends on decay.check_decay, exactly as it already depends on drift.check_drift. The layering reads better for it, and the guard stays as strict as it was.

test_public_api pins the exported surface, so the new names are added there in the same commit rather than becoming an accidental promise.

test_crucible_compat is the repo's stated real enforcement ("a version number is not evidence that an API is present"). It gains a check for the monitor surface, plus one asserting edge_monitor still has no parameter from which a baseline could be rebuilt. That second one matters: a future crucible relaxing that guard would silently make EdgeDecayTrigger incapable of firing while every other test here kept passing.

A units trap worth naming

TriggerContext gains trade_r (per-trade R) as a field separate from realized_r (per-period R, the grid the envelope was built on). Two series, two lengths, two clocks (trades_live vs elapsed). Reusing one field for both would have been the units bug crucible fixed in v0.4.0, where an observed expectancy in R was compared against a null in fractional returns.

run_cycle's trade_r is optional and defaults to empty, so existing callers are unaffected.

Verification

  • 388 tests pass (+23), against crucible main via PYTHONPATH
  • ruff check crucible_stack tests: clean

Note that crucible-stack's local .venv has crucible 0.1.0 in site-packages — the unrelated 2011 PyPI squatter, not this project — so it cannot import the real crucible at all. That is a pre-existing local environment problem, not something this branch introduces, but it does mean local runs need PYTHONPATH=../crucible/src until the venv is repaired.

Not done

npf needs no change: npf/scripts/orchestrate_trend.sh already invokes python -m crucible_stack.orchestrate, so it picks this up for free. What npf's adapter would eventually supply is the trade_r series and a Reoptimization.baseline; until it does, EdgeDecayTrigger fails open with an explicit reason rather than pretending the book is healthy.

🤖 Generated with Claude Code

…toring

`drift` watches the equity PATH: cumulative R and drawdown against the envelope
frozen at promotion. Nothing watched the per-trade PARAMETER. They are
complements and come apart in both directions. A halved expectancy leaves the
path flat, and flat usually sits inside a p5 band provisioned over a multi-year
horizon; a run of correlated losers breaches the drawdown floor with every
per-trade statistic exactly where it should be. Running only one of the two
leaves a real failure mode invisible.

The judging is crucible's (crucible.validation.monitor). What this adds is the
three things crucible refuses to own: freezing an EdgeBaseline at the moment of
promotion, persisting it in the DeploymentLedger beside the DriftEnvelope, and
turning a verdict into a re-optimization trigger. Same division of labour as
drift, and drift.py's header already reserved this shape.

Only DEGRADED fires. crucible reserves that label for the CUSUM, the one channel
carrying a stated false-alarm rate. SLIPPING is reported in `reasons` and does
not trigger. Letting it trigger would import an uncalibrated tripwire into the
loop, and would tax the honest N besides, since every re-optimization is
variants added to the SearchSpaceLog.

Three things the existing tests forced, all of them improvements:

  * `test_triggers_know_nothing_about_the_substrate` pins trigger.py's import
    surface to crucible_stack + numpy. The first cut imported crucible directly
    and broke it. Rather than widen the allowlist to fit the new code, trigger
    now depends on `decay.check_decay`, exactly as it already depends on
    `drift.check_drift`. The layering reads better for it.
  * `test_public_api` pins the exported surface, so the new names are added
    there in the same commit.
  * `test_crucible_compat` is the repo's stated real enforcement ("a version
    number is not evidence that an API is present"). It gains a check for the
    monitor surface, and one asserting edge_monitor still has no parameter from
    which a baseline could be rebuilt: a future crucible relaxing that would
    silently make EdgeDecayTrigger incapable of firing while every other test
    here kept passing.

TriggerContext gains `trade_r` (per-TRADE R) separate from `realized_r`
(PERIODIC R, the envelope's grid). Two series, two lengths, two clocks
(trades_live vs elapsed). Denominating one in the other is the units bug
crucible fixed in v0.4.0 and is not worth repeating. run_cycle's `trade_r` is
optional and defaults to empty, so existing callers are unaffected.

BLOCKED ON A RELEASE. The crucible floor is raised to >=0.5.0, and unlike the
0.3.0 constraint that one is not yet true: the monitor is in crucible's
[Unreleased] and the newest published crucible is 0.4.0. Do not release
crucible-stack from this until crucible 0.5.0 is on PyPI, or `import
crucible_stack.orchestrate` raises for anyone installing from PyPI.

388 tests pass against crucible main (+23). ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mspinola
mspinola merged commit d99c2c8 into main Aug 2, 2026
1 of 2 checks passed
@mspinola
mspinola deleted the claude/orchestrate-edge-decay-trigger branch August 2, 2026 21:33
mspinola added a commit that referenced this pull request Aug 2, 2026
…cay (#16)

EdgeDecayTrigger landed in #15 with nothing to feed it. This adds the substrate
half: a third method on the book protocol and the CLI plumbing to reach it.

    .trade_r_since(since, params) -> per-TRADE R since the incumbent went live

Sourced over the same ledger-defined window as the periodic series, for the same
reason that window comes off the ledger rather than the book: a series measured
over the wrong span is a silently wrong answer rather than an error. The two
series stay separate all the way down, periodic onto the envelope's grid and
per-trade not aggregated at all.

The flag is OPT-IN and off by default, which is the important part.
EdgeDecayTrigger fails open, so switching it on for a book with no frozen
baseline makes every cycle fire and re-optimize, taxing the honest N for no
information. A baseline is only written on a promotion, so the sequence is: teach
the book trade_r_since and a Reoptimization.baseline, let one promotion happen,
then turn the flag on.

With --edge-decay set and no trade_r_since on the book, this refuses with a clear
message rather than passing an empty series. An empty series would let the
trigger fail open and produce a cycle that looks monitored and is not.

392 tests pass (+4). ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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