feat(orchestrate): EdgeDecayTrigger, the parameter-space half of monitoring - #15
Merged
Merged
Conversation
…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
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>
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.
Warning
Blocked on a crucible release. This raises the
cruciblefloor 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 makeimport crucible_stack.orchestrateraise for anyone installing from PyPI.Why
driftwatches 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: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:EdgeBaselineat the moment of promotionDeploymentLedgerbeside theDriftEnvelopeSame 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
reasonsand 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 theSearchSpaceLog— 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_substratepinstrigger.py's import surface tocrucible_stack+ numpy. My first cut imported crucible directly and broke it. Rather than widen the allowlist to fit new code,triggernow depends ondecay.check_decay, exactly as it already depends ondrift.check_drift. The layering reads better for it, and the guard stays as strict as it was.test_public_apipins the exported surface, so the new names are added there in the same commit rather than becoming an accidental promise.test_crucible_compatis 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 assertingedge_monitorstill has no parameter from which a baseline could be rebuilt. That second one matters: a future crucible relaxing that guard would silently makeEdgeDecayTriggerincapable of firing while every other test here kept passing.A units trap worth naming
TriggerContextgainstrade_r(per-trade R) as a field separate fromrealized_r(per-period R, the grid the envelope was built on). Two series, two lengths, two clocks (trades_livevselapsed). 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'strade_ris optional and defaults to empty, so existing callers are unaffected.Verification
mainviaPYTHONPATHruff check crucible_stack tests: cleanNote that crucible-stack's local
.venvhas 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 needPYTHONPATH=../crucible/srcuntil the venv is repaired.Not done
npf needs no change:
npf/scripts/orchestrate_trend.shalready invokespython -m crucible_stack.orchestrate, so it picks this up for free. What npf's adapter would eventually supply is thetrade_rseries and aReoptimization.baseline; until it does,EdgeDecayTriggerfails open with an explicit reason rather than pretending the book is healthy.🤖 Generated with Claude Code