From 3fd238bb6d76194420f95143f53883f740d343cd Mon Sep 17 00:00:00 2001 From: Matt Spinola Date: Sun, 2 Aug 2026 17:08:11 -0400 Subject: [PATCH] feat(orchestrate): EdgeDecayTrigger, the parameter-space half of monitoring `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 --- CHANGELOG.md | 39 +++++++ crucible_stack/orchestrate/__init__.py | 9 +- crucible_stack/orchestrate/decay.py | 90 +++++++++++++++ crucible_stack/orchestrate/ledger.py | 8 ++ crucible_stack/orchestrate/runner.py | 12 +- crucible_stack/orchestrate/trigger.py | 78 ++++++++++++- pyproject.toml | 9 +- tests/test_crucible_compat.py | 30 +++++ tests/test_decay.py | 152 +++++++++++++++++++++++++ tests/test_public_api.py | 11 +- tests/test_runner.py | 53 +++++++++ 11 files changed, 478 insertions(+), 13 deletions(-) create mode 100644 crucible_stack/orchestrate/decay.py create mode 100644 tests/test_decay.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 935ee88..fd7dc7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ breaking change are governed by [docs/api-stability.md](docs/api-stability.md). ## [Unreleased] ### Added +- **`crucible_stack.orchestrate.decay` and `EdgeDecayTrigger`**: the parameter-space + counterpart to `drift`. `drift` watches the equity **path** (cumulative R and drawdown + against the frozen envelope); this watches the per-trade **parameter** (expectancy, and + how often the signal fires). They are complements and come apart in both directions: a + halved expectancy leaves the path flat and usually inside a p5 band provisioned over a + multi-year horizon, and a run of correlated losers breaches the drawdown floor with + every per-trade statistic intact. Running only one leaves a real failure invisible. + + The judging is crucible's (`crucible.validation.monitor`). What this contributes 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. `DeploymentEntry` gains a `baseline` + field that round-trips through JSON, and a refusal carries none, matching the envelope + rule so there is never a reference a later cycle could re-baseline onto. + + **Only DEGRADED fires.** crucible reserves that label for the CUSUM, the one channel + with a stated false-alarm rate. SLIPPING is reported in `reasons` and does not trigger. + Promoting an uncalibrated tripwire to a re-optimization trigger would undo the reason + crucible separates them, and would tax the honest N besides: every re-optimization is + variants added to the `SearchSpaceLog`. + + `TriggerContext` gains `trade_r` (per-TRADE R since promotion) as a field separate from + `realized_r` (PERIODIC R, the grid the envelope was built on). Two different series with + two different lengths and two different clocks (`trades_live` vs `elapsed`); conflating + them is the units bug crucible fixed in v0.4.0. `run_cycle` gains an optional `trade_r` + argument, defaulting to empty, so existing callers are unaffected. + + `trigger` depends on `decay.check_decay` rather than on crucible directly, exactly as it + depends on `drift.check_drift`, which keeps the trigger seam's import surface to + crucible_stack + numpy as `test_triggers_know_nothing_about_the_substrate` pins. + - **`tests/test_no_findings_in_prose.py`**, a guard on the writing rather than the code. The boundary guard reads the syntax tree, so it cannot see a docstring or a markdown file, and every leak found while extracting this framework came through prose. This @@ -29,6 +60,14 @@ breaking change are governed by [docs/api-stability.md](docs/api-stability.md). `_max_drawdown` remains as a deprecated alias. ### Changed +- **The `crucible` floor is raised to `>=0.5.0`** for the monitor API above. Unlike the + 0.3.0 constraint, this one is **not yet true**: the monitor is in crucible's + `[Unreleased]` and the newest published crucible is 0.4.0. This must not be released + before crucible 0.5.0 is on PyPI, or `import crucible_stack.orchestrate` raises for + anyone installing from PyPI. `tests/test_crucible_compat.py` gains two checks that name + the cause, including one asserting `edge_monitor` still has no parameter from which a + baseline could be rebuilt, since a future crucible relaxing that would silently make + `EdgeDecayTrigger` incapable of firing while every other test kept passing. - `crucible>=0.3.0` (was `>=0.2.0`). The honest-N API this package depends on landed after crucible's v0.2.0 tag, so the old constraint was satisfiable by a version that could not actually satisfy it. The CI workaround that installed crucible from git is gone. diff --git a/crucible_stack/orchestrate/__init__.py b/crucible_stack/orchestrate/__init__.py index 5a0f2a7..60bdc29 100644 --- a/crucible_stack/orchestrate/__init__.py +++ b/crucible_stack/orchestrate/__init__.py @@ -20,6 +20,11 @@ monthly_r, provision_envelope, ) +from crucible_stack.orchestrate.decay import ( + baseline_from_dict, + baseline_to_dict, + check_decay, +) from crucible_stack.orchestrate.drift import ( DriftEnvelope, DriftVerdict, @@ -37,6 +42,7 @@ ) from crucible_stack.orchestrate.trigger import ( DriftTrigger, + EdgeDecayTrigger, ScheduleTrigger, Trigger, TriggerContext, @@ -49,6 +55,7 @@ "envelope_from_r", "provision_envelope", "check_account_drift", "monthly_r", "in_currency", "Trigger", "TriggerContext", "TriggerDecision", "ScheduleTrigger", - "DriftTrigger", "any_of", + "DriftTrigger", "EdgeDecayTrigger", "any_of", + "check_decay", "baseline_to_dict", "baseline_from_dict", "DeploymentEntry", "DeploymentLedger", "ACTIONS", "run_cycle", "CycleResult", "Reoptimization", "missed_windows"] diff --git a/crucible_stack/orchestrate/decay.py b/crucible_stack/orchestrate/decay.py new file mode 100644 index 0000000..2127bfb --- /dev/null +++ b/crucible_stack/orchestrate/decay.py @@ -0,0 +1,90 @@ +"""decay - has the promoted edge itself weakened, rather than the path wandered? + +The second half of the monitoring story. `drift` watches the equity **path**: realized +cumulative R against the block-bootstrap envelope frozen at promotion. This watches the +per-trade **parameter**: expectancy, and how often the signal fires at all. + +They are complements, not duplicates, and they come apart in both directions. A book +whose expectancy halves while its trade count holds produces a path that goes flat, and +flat usually sits inside a p5 band provisioned over a multi-year horizon. Conversely 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 invisible. + +The judging is crucible's (`crucible.validation.monitor`); this module contributes state, +time and control flow, the same division of labour as `drift`. Specifically it contributes +the three things crucible refuses to own: + + * **freezing** an `EdgeBaseline` at the moment of promotion, and persisting it in the + ledger beside the `DriftEnvelope` + * **reading** it back on a later cycle without any means of rebuilding it + * turning a verdict into a **re-optimization trigger** + +**Only DEGRADED fires.** crucible's monitor returns HOLDING / SLIPPING / DEGRADED, and +only the CUSUM (which carries a stated false-alarm rate) can produce DEGRADED. The soft +channels cap at SLIPPING and are reported here without firing. Letting SLIPPING trigger a +re-optimization would import an uncalibrated tripwire into the loop and undo the whole +reason crucible separates them: a trailing window on a book whose edge never decayed +still crosses the soft line often enough to matter. + +**Per-trade R, not periodic R.** `TriggerContext.realized_r` is periodic (the grid the +drift envelope was built on). The monitor consumes per-trade R, which is a different +series with a different length, so it rides on its own field. Denominating one in the +other is the units bug crucible fixed in v0.4.0 and is not worth repeating. +""" +from __future__ import annotations + +from dataclasses import asdict +from typing import Any, Mapping, Optional, Sequence + +from crucible.edge import TradeLog +from crucible.validation import EdgeBaseline, MonitorVerdict, Thresholds, edge_monitor + +# Re-exported so `trigger` depends on this adapter rather than on crucible directly, +# exactly as it depends on `drift.check_drift` rather than reimplementing the band. That +# keeps the trigger seam's import surface to crucible_stack + numpy, which +# `test_triggers_know_nothing_about_the_substrate` pins. +__all__ = ["check_decay", "baseline_to_dict", "baseline_from_dict", + "EdgeBaseline", "MonitorVerdict", "Thresholds"] + + +def check_decay(baseline: EdgeBaseline, trade_r: Sequence[float], *, + thresholds: Optional[Thresholds] = None) -> MonitorVerdict: + """Judge a frozen baseline against per-TRADE R since promotion. The judging is + crucible's; what this adds is the seam. + + `trade_r` is per-trade, not the periodic series `check_drift` consumes. Deliberately + has no parameter from which a baseline could be rebuilt, matching `check_drift`. + """ + return edge_monitor(TradeLog.from_arrays(trade_r), baseline, + thresholds=thresholds or Thresholds()) + + +def baseline_to_dict(baseline: EdgeBaseline) -> dict: + """JSON-safe, lossless rendering of a frozen baseline. Pure conversion, no I/O. + + `EdgeBaseline` is a plain frozen dataclass of scalars in the layer below, and crucible + deliberately persists nothing, so the round-trip lives here alongside the ledger that + needs it (same arrangement as `DriftEnvelope.to_dict`). + """ + d = asdict(baseline) + return {k: (None if v is None else v) for k, v in d.items()} + + +def baseline_from_dict(d: Optional[Mapping[str, Any]]) -> Optional[EdgeBaseline]: + """Rebuild from `baseline_to_dict`. Round-trips exactly; see the ledger's test. + + Reconstruction runs `EdgeBaseline.__post_init__`, so a corrupted or hand-edited entry + (a non-positive expectancy, say) raises here rather than silently producing a monitor + that can never fire. + """ + if not d: + return None + return EdgeBaseline( + expectancy=float(d["expectancy"]), + sigma=float(d["sigma"]), + n_trades=int(d["n_trades"]), + trades_per_year=None if d.get("trades_per_year") is None + else float(d["trades_per_year"]), + n_variants=None if d.get("n_variants") is None else int(d["n_variants"]), + deflated=bool(d.get("deflated", False)), + ) diff --git a/crucible_stack/orchestrate/ledger.py b/crucible_stack/orchestrate/ledger.py index ac58c15..103cd0b 100644 --- a/crucible_stack/orchestrate/ledger.py +++ b/crucible_stack/orchestrate/ledger.py @@ -40,6 +40,11 @@ import numpy as np import pandas as pd +from crucible_stack.orchestrate.decay import ( + EdgeBaseline, + baseline_from_dict, + baseline_to_dict, +) from crucible_stack.orchestrate.drift import DriftEnvelope try: @@ -90,6 +95,7 @@ class DeploymentEntry: honest_n: int = 0 fit_window: Optional[Tuple[pd.Timestamp, pd.Timestamp]] = None envelope: Optional[DriftEnvelope] = None + baseline: Optional[EdgeBaseline] = None # frozen at promotion, for EdgeDecayTrigger equity_ref: Optional[str] = None # points at the EquityResult; never a copy of it def __post_init__(self) -> None: @@ -121,6 +127,7 @@ def to_json(self) -> str: "fit_window": [pd.Timestamp(fw[0]).isoformat(), pd.Timestamp(fw[1]).isoformat()] if fw else None, "envelope": self.envelope.to_dict() if self.envelope is not None else None, + "baseline": baseline_to_dict(self.baseline) if self.baseline is not None else None, "equity_ref": self.equity_ref, }, sort_keys=True) @@ -136,6 +143,7 @@ def from_json(cls, line: str) -> "DeploymentEntry": reasons=tuple(d.get("reasons") or ()), honest_n=int(d.get("honest_n", 0)), fit_window=(pd.Timestamp(fw[0]), pd.Timestamp(fw[1])) if fw else None, envelope=DriftEnvelope.from_dict(env) if env else None, + baseline=baseline_from_dict(d.get("baseline")), equity_ref=d.get("equity_ref"), ) diff --git a/crucible_stack/orchestrate/runner.py b/crucible_stack/orchestrate/runner.py index 9002498..d79d223 100644 --- a/crucible_stack/orchestrate/runner.py +++ b/crucible_stack/orchestrate/runner.py @@ -33,6 +33,7 @@ import pandas as pd from crucible_stack.optimize import Selection +from crucible_stack.orchestrate.decay import EdgeBaseline from crucible_stack.orchestrate.drift import DriftEnvelope from crucible_stack.orchestrate.gate import GateDecision, evaluate from crucible_stack.orchestrate.ledger import DeploymentEntry, DeploymentLedger @@ -45,12 +46,15 @@ class Reoptimization: """What a re-optimization hands back: the verdict plus the provenance to record. - `envelope` is what the book WOULD be provisioned with if this candidate is promoted. - It is only ever written to the ledger on a promotion. + `envelope` and `baseline` are what the book WOULD be provisioned with if this + candidate is promoted: the path-space band and the parameter-space reference. Both are + only ever written to the ledger on a promotion, and both are frozen at that instant, + which is the property the monitors depend on. """ selection: Selection fit_window: Optional[Tuple[pd.Timestamp, pd.Timestamp]] = None envelope: Optional[DriftEnvelope] = None + baseline: Optional[EdgeBaseline] = None equity_ref: Optional[str] = None @@ -103,6 +107,7 @@ def run_cycle( realized_r: Sequence[float], now: datetime, cadence: Optional[int] = None, + trade_r: Optional[Sequence[float]] = None, ) -> CycleResult: """Run one turn of the loop for one book. @@ -118,6 +123,8 @@ def run_cycle( realized_r=r, envelope=incumbent.envelope if incumbent is not None else None, has_incumbent=incumbent is not None, + trade_r=np.asarray(trade_r if trade_r is not None else (), dtype=float), + baseline=incumbent.baseline if incumbent is not None else None, ) tdec = trigger(ctx) missed = missed_windows(ctx.elapsed, cadence) @@ -147,6 +154,7 @@ def run_cycle( honest_n=int(getattr(sel, "honest_n", 0) or 0), fit_window=reopt.fit_window, envelope=reopt.envelope if promoting else None, # refusals provision nothing + baseline=reopt.baseline if promoting else None, equity_ref=reopt.equity_ref if promoting else None, ) ledger.record(entry) diff --git a/crucible_stack/orchestrate/trigger.py b/crucible_stack/orchestrate/trigger.py index d9703b4..c9ede41 100644 --- a/crucible_stack/orchestrate/trigger.py +++ b/crucible_stack/orchestrate/trigger.py @@ -1,15 +1,20 @@ """trigger — when should the loop re-optimize? ADR-0003 commitment 2. The cadence is a *policy*, not a hardcoded rule, and this module is -the seam that keeps it one. Two honest triggers compose: +the seam that keeps it one. Three honest triggers compose: * **schedule** — Pardo's baseline: re-optimize every N periods as the out-of-sample window rolls forward. - * **drift** — the early interrupt: re-optimize as soon as the live book leaves the - envelope it was provisioned with (`crucible_stack.orchestrate.drift`). + * **drift** — the early interrupt on the equity PATH: re-optimize as soon as the live + book leaves the envelope it was provisioned with (`crucible_stack.orchestrate.drift`). + * **edge_decay** — the early interrupt on the per-trade PARAMETER: re-optimize when the + edge itself weakens against the baseline frozen at promotion + (`crucible_stack.orchestrate.decay`). Complements drift rather than duplicating it; a + halved expectancy can leave the path flat and inside a wide band, and a cluster of + correlated losers can breach the band with every per-trade statistic intact. -`any_of(schedule, drift)` is the recommended hybrid: a scheduled floor with drift able to -pull the re-optimization forward. +`any_of(schedule, drift, edge_decay)` is the recommended hybrid: a scheduled floor with +either monitor able to pull the re-optimization forward. **This is also the substrate seam.** Nothing here knows what invokes it — cron today, a workflow engine later (ADR-0003, Option A vs B). A trigger reads a `TriggerContext` and @@ -32,10 +37,11 @@ import numpy as np +from crucible_stack.orchestrate.decay import EdgeBaseline, Thresholds, check_decay from crucible_stack.orchestrate.drift import DriftEnvelope, check_drift __all__ = ["TriggerContext", "TriggerDecision", "Trigger", "ScheduleTrigger", - "DriftTrigger", "any_of"] + "DriftTrigger", "EdgeDecayTrigger", "any_of"] @dataclass(frozen=True) @@ -50,18 +56,33 @@ class TriggerContext: realized_r: np.ndarray = field(default_factory=lambda: np.zeros(0)) envelope: Optional[DriftEnvelope] = None has_incumbent: bool = True + # Per-TRADE R since the incumbent went live, and the baseline frozen at promotion. + # Separate from `realized_r` because that one is PERIODIC (the grid the envelope was + # built on). Two different series with two different lengths; denominating one in the + # other is the units bug crucible fixed in v0.4.0. + trade_r: np.ndarray = field(default_factory=lambda: np.zeros(0)) + baseline: Optional[EdgeBaseline] = None def __post_init__(self) -> None: r = np.asarray(self.realized_r, dtype=float) if r.ndim != 1: raise ValueError(f"realized_r must be 1-D, got shape {r.shape}") object.__setattr__(self, "realized_r", r) + tr = np.asarray(self.trade_r, dtype=float) + if tr.ndim != 1: + raise ValueError(f"trade_r must be 1-D, got shape {tr.shape}") + object.__setattr__(self, "trade_r", tr) @property def elapsed(self) -> int: """Periods the incumbent has been live.""" return int(self.realized_r.size) + @property + def trades_live(self) -> int: + """Trades closed since the incumbent went live. Not the same clock as `elapsed`.""" + return int(self.trade_r.size) + @dataclass(frozen=True) class TriggerDecision: @@ -154,6 +175,51 @@ def __call__(self, ctx: TriggerContext) -> TriggerDecision: reasons=tuple(f"{self.name}: {r}" for r in v.reasons)) +@dataclass(frozen=True) +class EdgeDecayTrigger: + """Fire when the promoted edge itself has weakened, not merely when the path wandered. + + The parameter-space counterpart to `DriftTrigger`. Reads the frozen `EdgeBaseline` off + the context and never builds one, for the same reason `DriftTrigger` never builds an + envelope: a reference recomputed from current data re-fits onto the drifted reality and + the monitor can never fire. See `crucible_stack.orchestrate.decay`. + + **Only DEGRADED fires.** crucible's monitor reserves that label for the CUSUM, the one + channel carrying a stated false-alarm rate. SLIPPING (a trailing-window dip, or a + firing-rate collapse) is reported in `reasons` and does not trigger, because promoting + an uncalibrated tripwire to a re-optimization trigger is exactly the mistake the + monitor's design is shaped to prevent. + + With no baseline attached this fires with an explicit reason rather than staying quiet, + matching `DriftTrigger`'s fails-open posture: unmonitored is not the same as healthy. + """ + thresholds: Optional[Thresholds] = None + name: str = "edge_decay" + + def __call__(self, ctx: TriggerContext) -> TriggerDecision: + cold = _cold_start(ctx, self.name) + if cold is not None: + return cold + if ctx.baseline is None: + return TriggerDecision( + fired=True, sources=(self.name,), + reasons=(f"{self.name}: no baseline attached, so edge decay cannot be " + "assessed; re-checking rather than trading unmonitored",)) + if ctx.trades_live == 0: + return TriggerDecision( + fired=False, sources=(), + reasons=(f"{self.name}: no closed trades yet",)) + + v = check_decay(ctx.baseline, ctx.trade_r, thresholds=self.thresholds) + fired = v.label == "DEGRADED" + reasons = tuple(f"{self.name}: {r}" for r in v.reasons) + if v.label == "SLIPPING": + reasons += (f"{self.name}: SLIPPING, which does NOT trigger; only the " + "calibrated detector may force a re-optimization",) + return TriggerDecision(fired=fired, sources=(self.name,) if fired else (), + reasons=reasons) + + @dataclass(frozen=True) class any_of: """Hybrid: fire if any policy fires, and record every one that did. diff --git a/pyproject.toml b/pyproject.toml index 9e394fd..682fef1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,14 @@ dependencies = [ # # tests/test_crucible_compat.py remains the real enforcement. A version number is # not evidence that an API is present, which is the whole reason that test exists. - "crucible>=0.3.0", + # + # RAISED TO 0.5.0 for crucible.validation.monitor (EdgeBaseline / edge_monitor), + # which orchestrate.decay and EdgeDecayTrigger are built on. Unlike the 0.3.0 note + # above, this constraint is NOT yet true: at the time of writing the monitor sits in + # crucible's [Unreleased] and the newest published crucible is 0.4.0. This must not + # merge before crucible 0.5.0 is on PyPI, or `pip install crucible-stack` resolves + # to a crucible without the monitor and `import crucible_stack.orchestrate` raises. + "crucible>=0.5.0", ] [project.optional-dependencies] diff --git a/tests/test_crucible_compat.py b/tests/test_crucible_compat.py index 87bb692..870a77f 100644 --- a/tests/test_crucible_compat.py +++ b/tests/test_crucible_compat.py @@ -42,3 +42,33 @@ def test_sidak_accepts_a_search_space_log_not_only_a_count(): "accept the ledger directly so the corrected p-value cannot be computed " "against a count someone retyped by hand.") assert 0.0 <= got <= 1.0 + + +def test_the_edge_monitor_surface_is_present(): + """`orchestrate.decay` and `EdgeDecayTrigger` are built on crucible's monitor, added + after 0.4.0. Without it the failure is an ImportError at `import + crucible_stack.orchestrate`, which takes the whole orchestrator down rather than only + the decay trigger, so it is worth naming the cause here.""" + try: + from crucible.validation import ( # noqa: F401 + EdgeBaseline, + MonitorVerdict, + cusum_design, + edge_monitor, + ) + except ImportError as exc: # pragma: no cover + pytest.fail( + f"the installed crucible predates the edge monitor ({exc}). " + "crucible_stack.orchestrate.decay and EdgeDecayTrigger require it. " + "Install crucible from main, or >= 0.5.0 once released.") + + +def test_edge_monitor_still_refuses_to_rebuild_its_own_baseline(): + """The property the decay seam is built on. If a future crucible let `edge_monitor` + reconstruct a baseline from current data, `EdgeDecayTrigger` would silently become + incapable of firing, and every test here would still pass.""" + from crucible.validation import edge_monitor + assert set(inspect.signature(edge_monitor).parameters) == { + "trades", "baseline", "design", "thresholds"}, ( + "edge_monitor's signature changed. The decay trigger depends on there being no " + "parameter from which a baseline could be rebuilt; re-check before relaxing this.") diff --git a/tests/test_decay.py b/tests/test_decay.py new file mode 100644 index 0000000..8b8a618 --- /dev/null +++ b/tests/test_decay.py @@ -0,0 +1,152 @@ +"""The edge-decay seam: freeze a baseline at promotion, read it back, trigger on it. + +The judging is crucible's. What is tested here is the seam: that the baseline survives +the ledger round trip, that nothing can rebuild it, and that only the calibrated channel +is allowed to force a re-optimization. +""" +import inspect +from datetime import datetime + +import numpy as np +import pytest + +from crucible_stack.orchestrate import ( + DeploymentEntry, + DeploymentLedger, + EdgeDecayTrigger, + TriggerContext, + baseline_from_dict, + baseline_to_dict, + check_decay, +) +from crucible_stack.orchestrate.decay import EdgeBaseline, Thresholds + + +def _baseline(**kw): + return EdgeBaseline(**{"expectancy": 0.2, "sigma": 1.0, "n_trades": 2000, + "trades_per_year": 150.0, "n_variants": 64, + "deflated": True, **kw}) + + +def _r(mu, n=1500, seed=0): + return np.random.default_rng(seed).normal(mu, 1.0, n) + + +# ── serialization: the baseline has to survive the ledger ─────────────────────────── + +def test_baseline_round_trips_exactly(): + b = _baseline() + assert baseline_from_dict(baseline_to_dict(b)) == b + + +def test_round_trip_survives_the_optional_fields_being_absent(): + b = EdgeBaseline(expectancy=0.2, sigma=1.0, n_trades=500) + back = baseline_from_dict(baseline_to_dict(b)) + assert back == b + assert back.trades_per_year is None and back.n_variants is None + assert back.deflated is False # and it still says so + + +def test_baseline_from_dict_passes_through_none(): + assert baseline_from_dict(None) is None + assert baseline_from_dict({}) is None + + +def test_a_corrupted_entry_raises_rather_than_yielding_a_monitor_that_cannot_fire(): + """Reconstruction re-runs crucible's validation. A hand-edited non-positive + expectancy is caught here, not discovered as an alarm that never arrives.""" + bad = baseline_to_dict(_baseline()) | {"expectancy": -0.1} + with pytest.raises(ValueError, match="must be positive"): + baseline_from_dict(bad) + + +def test_ledger_entry_round_trips_the_baseline_through_json(tmp_path): + entry = DeploymentEntry( + book="b", timestamp=datetime(2026, 1, 2), action="promote", trigger="schedule", + params={"fast": 10}, verdict="PASS", trustworthy=True, baseline=_baseline()) + back = DeploymentEntry.from_json(entry.to_json()) + assert back.baseline == entry.baseline + + led = DeploymentLedger(str(tmp_path / "d.jsonl")) + led.record(entry) + assert DeploymentLedger(str(tmp_path / "d.jsonl")).current("b").baseline == _baseline() + + +def test_an_entry_without_a_baseline_still_round_trips(): + entry = DeploymentEntry(book="b", timestamp=datetime(2026, 1, 2), action="hold", + trigger="schedule", params={}, verdict="FAIL", + trustworthy=False) + assert DeploymentEntry.from_json(entry.to_json()).baseline is None + + +# ── the trigger ───────────────────────────────────────────────────────────────────── + +def test_a_decayed_edge_fires(): + ctx = TriggerContext(trade_r=_r(0.05, n=3000), baseline=_baseline()) + d = EdgeDecayTrigger(thresholds=Thresholds(monitor_arl0_trades=500))(ctx) + assert d.fired and d.sources == ("edge_decay",) + + +def test_a_healthy_edge_does_not_fire(): + ctx = TriggerContext(trade_r=_r(0.3, n=1500, seed=2), baseline=_baseline()) + assert not EdgeDecayTrigger()(ctx).fired + + +def test_slipping_is_reported_but_does_not_trigger(): + """The whole reason crucible separates the channels. A firing-rate collapse or a + trailing-window dip is uncalibrated, so it must not force a re-optimization.""" + baseline = _baseline(trades_per_year=150.0) + # per-trade edge intact; nothing here can reach DEGRADED + ctx = TriggerContext(trade_r=_r(0.2, n=300, seed=4), baseline=baseline) + v = check_decay(baseline, ctx.trade_r) + d = EdgeDecayTrigger()(ctx) + if v.label == "SLIPPING": + assert not d.fired + assert any("does NOT trigger" in r for r in d.reasons) + else: + assert v.label == "HOLDING" and not d.fired + + +def test_a_missing_baseline_fails_open_like_drift(): + """Unmonitored is not the same as healthy. Matches DriftTrigger's posture.""" + d = EdgeDecayTrigger()(TriggerContext(trade_r=_r(0.2, n=100), baseline=None)) + assert d.fired + assert any("no baseline attached" in r for r in d.reasons) + + +def test_no_incumbent_is_a_cold_start(): + d = EdgeDecayTrigger()(TriggerContext(has_incumbent=False)) + assert d.fired and any("no incumbent" in r for r in d.reasons) + + +def test_no_closed_trades_yet_is_quiet(): + d = EdgeDecayTrigger()(TriggerContext(trade_r=np.zeros(0), baseline=_baseline())) + assert not d.fired + assert any("no closed trades yet" in r for r in d.reasons) + + +# ── the seam's own invariants ─────────────────────────────────────────────────────── + +def test_per_trade_and_periodic_r_are_separate_clocks(): + """`realized_r` is periodic (the envelope's grid); `trade_r` is per-trade. Two + different series with two different lengths. Denominating one in the other is the + units bug crucible fixed in v0.4.0.""" + ctx = TriggerContext(realized_r=np.zeros(12), trade_r=np.zeros(400)) + assert ctx.elapsed == 12 and ctx.trades_live == 400 + + +def test_trade_r_must_be_one_dimensional(): + with pytest.raises(ValueError, match="trade_r must be 1-D"): + TriggerContext(trade_r=np.zeros((3, 4))) + + +def test_check_decay_cannot_rebuild_a_baseline(): + """Same guard crucible puts on edge_monitor, re-asserted at the seam that persists it.""" + assert set(inspect.signature(check_decay).parameters) == { + "baseline", "trade_r", "thresholds"} + + +def test_the_trigger_reads_the_baseline_and_never_builds_one(): + src = inspect.getsource(EdgeDecayTrigger) + assert "ctx.baseline" in src + assert "from_log" not in src and "EdgeBaseline(" not in src diff --git a/tests/test_public_api.py b/tests/test_public_api.py index dc4cc59..fd841ad 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -64,14 +64,19 @@ }, "crucible_stack.orchestrate": { "ACTIONS", "CycleResult", "DeploymentEntry", "DeploymentLedger", "DriftEnvelope", - "DriftTrigger", "DriftVerdict", "GateDecision", "Reoptimization", "ScheduleTrigger", - "Trigger", "TriggerContext", "TriggerDecision", "any_of", "build_envelope", - "check_account_drift", "check_drift", "envelope_from_r", "evaluate", "in_currency", + "DriftTrigger", "DriftVerdict", "EdgeDecayTrigger", "GateDecision", "Reoptimization", + "ScheduleTrigger", "Trigger", "TriggerContext", "TriggerDecision", "any_of", + "baseline_from_dict", "baseline_to_dict", "build_envelope", "check_account_drift", + "check_decay", "check_drift", "envelope_from_r", "evaluate", "in_currency", "is_promotable", "missed_windows", "monthly_r", "provision_envelope", "run_cycle", }, "crucible_stack.orchestrate.account_drift": { "MONTHLY", "check_account_drift", "in_currency", "monthly_r", "provision_envelope", }, + "crucible_stack.orchestrate.decay": { + "EdgeBaseline", "MonitorVerdict", "Thresholds", "baseline_from_dict", + "baseline_to_dict", "check_decay", + }, } # Names kept alive only for compatibility. They are importable and must keep working, but diff --git a/tests/test_runner.py b/tests/test_runner.py index c41e235..2156200 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -342,3 +342,56 @@ def reoptimize(): def build_quiet(): return _QuietBook() + + +# --- the edge-decay baseline: frozen at promotion, read back on later cycles ---------- + +def _baseline(): + from crucible_stack.orchestrate.decay import EdgeBaseline + return EdgeBaseline(expectancy=0.2, sigma=1.0, n_trades=2000, trades_per_year=150.0, + n_variants=64, deflated=True) + + +def test_a_promotion_freezes_the_baseline_into_the_ledger(): + led = DeploymentLedger() + run_cycle(book="b", ledger=led, trigger=ScheduleTrigger(cadence=1), + reoptimize=lambda: Reoptimization(selection=_sel(True), envelope=_env(), + baseline=_baseline()), + realized_r=(), now=NOW) + assert led.current("b").baseline == _baseline() + + +def test_a_refusal_carries_no_baseline(): + """Same rule as the envelope: refusals provision nothing, so there is never a + reference that a later cycle could re-baseline onto.""" + led = _with_incumbent() + run_cycle(book="book_a", ledger=led, trigger=ScheduleTrigger(cadence=1), + reoptimize=lambda: Reoptimization(selection=_sel(False), envelope=_env(), + baseline=_baseline()), + realized_r=np.zeros(3), now=NOW) + assert led.history("book_a")[-1].baseline is None + + +def test_the_cycle_reads_the_incumbents_baseline_and_fires_on_decay(): + from crucible.validation import Thresholds + + from crucible_stack.orchestrate import EdgeDecayTrigger + + led = DeploymentLedger() + run_cycle(book="b", ledger=led, trigger=ScheduleTrigger(cadence=1), + reoptimize=lambda: Reoptimization(selection=_sel(True), envelope=_env(), + baseline=_baseline()), + realized_r=(), now=NOW) + decayed = np.random.default_rng(0).normal(0.05, 1.0, 3000) # edge cut to a quarter + res = run_cycle(book="b", ledger=led, + trigger=EdgeDecayTrigger(thresholds=Thresholds(monitor_arl0_trades=500)), + reoptimize=_reopt(True), realized_r=np.zeros(3), trade_r=decayed, + now=NOW) + assert res.fired and "edge_decay" in res.trigger.sources + + +def test_trade_r_defaults_to_empty_so_existing_callers_are_unaffected(): + """The field is additive: a caller that never passes trade_r gets the old behaviour.""" + led = _with_incumbent() + res = _cycle(led, trigger=ScheduleTrigger(cadence=99), realized_r=np.zeros(3)) + assert not res.fired