From 0e14a949be8aebad539485d5c569ad23622f2f06 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:39:08 +0800 Subject: [PATCH] fix: separate shadow and paper lifecycle evidence Co-Authored-By: Codex --- ...ward_observation_runtime_contract.zh-CN.md | 2 + docs/registry/strategy_lifecycle_matrix.json | 36 +++--- docs/strategy_plugin_runtime_contract.md | 16 ++- .../strategy_plugin_runtime_contract.zh-CN.md | 8 +- .../strategy-lifecycle-matrix.v1.schema.json | 3 +- .../strategy_lifecycle/forward_observation.py | 106 +++++++++++++----- .../lifecycle_matrix_runtime.py | 33 +++++- tests/test_forward_observation.py | 53 ++++++++- tests/test_lifecycle_matrix_runtime.py | 23 ++++ 9 files changed, 222 insertions(+), 58 deletions(-) diff --git a/docs/forward_observation_runtime_contract.zh-CN.md b/docs/forward_observation_runtime_contract.zh-CN.md index e272cb9e..8d3b9091 100644 --- a/docs/forward_observation_runtime_contract.zh-CN.md +++ b/docs/forward_observation_runtime_contract.zh-CN.md @@ -2,6 +2,8 @@ `quant_platform_kit.strategy_lifecycle.forward_observation` 是策略、插件和平台共用的纯决策层。它不连接券商、不写运行时目标、不部署服务,也不产生订单。 +`shadow` 是每个前瞻观察候选必须具备的非实盘通道;`paper` 是**目标平台可选能力**,不是 Shadow 或前瞻观察的前置条件。目标未声明 paper 能力时,policy 只能列出 `shadow`,snapshot 的 `paper_status=unsupported` 是正常能力事实,不能暂停 Shadow 或推导失败。 + 每个冻结候选必须显式提供自己的 `ForwardObservationPolicy`:候选 ID、策略 profile、无杠杆基准、前瞻交易日数量、复核里程碑、恢复前需要的连续健康周期、市场日历、固定或滚动窗口、窗口起点、窗口理由引用,以及精确的非 Live 证据模式。控制器没有“252 天”“20/60 天”或某个策略的隐性默认值;新候选缺少这些字段会被拒绝,不能继承 SOXL 的参数。没有已验证的 P3 历史证据和证据引用时,状态固定为 `PARKED`。 P3 通过后,控制器可以自动给出 `start_shadow`、`start_paper`、`continue_*` 和在短暂数据/运行故障恢复后的 `resume_*` 意图。证据模式必须明确为 `shadow_decision + simulated_replay` 或 `shadow_decision + broker_paper`,不能把模拟回放、订单预览和券商 Paper 混称为同一种 Paper。数据过期或 Shadow/Paper 不一致时,才会进入可自动恢复的 `PAUSED`;风险阻断、人工冻结、身份不匹配、撤销或被新候选替代,分别进入不可自动恢复的终止状态。 diff --git a/docs/registry/strategy_lifecycle_matrix.json b/docs/registry/strategy_lifecycle_matrix.json index 6f011913..f6bdfa5d 100644 --- a/docs/registry/strategy_lifecycle_matrix.json +++ b/docs/registry/strategy_lifecycle_matrix.json @@ -1,7 +1,7 @@ { "schema_version": "strategy_lifecycle_matrix.v1", "generated_at": "2026-08-23", - "source_policy": "Read-only inventory; statuses require repository evidence refs and never authorize promotion or trading.", + "source_policy": "Historical contract/reference inventory only. It never proves observed evidence, deployment health, promotion, runtime authority, or trading eligibility.", "entries": [ { "id": "tqqq_core_only_p2_v2", @@ -9,10 +9,10 @@ "kind": "strategy", "lineage": "p2_v2", "stages": { - "p0": {"status": "verified", "evidence_refs": ["config/tqqq_core_only_p2_v2.json"]}, - "p1": {"status": "verified", "evidence_refs": ["src/us_equity_snapshot_pipelines/lifecycle/tqqq_core_only_p1_binding.py"]}, - "p2": {"status": "verified", "evidence_refs": ["config/tqqq_core_only_p2_v2.json"]}, - "p3": {"status": "parked", "evidence_refs": ["scripts/run_tqqq_p3.py"], "note": "历史候选契约不满足可诚实回放的证据几何。"}, + "p0": {"status": "not_started", "evidence_refs": [], "evidence_class": "not_applicable"}, + "p1": {"status": "contract_ready", "evidence_refs": ["src/us_equity_snapshot_pipelines/lifecycle/tqqq_core_only_p1_binding.py"], "evidence_class": "contract"}, + "p2": {"status": "contract_ready", "evidence_refs": ["config/tqqq_core_only_p2_v2.json"], "evidence_class": "contract"}, + "p3": {"status": "parked", "evidence_refs": ["scripts/run_tqqq_p3.py"], "evidence_class": "not_applicable", "note": "历史候选契约不满足可诚实回放的证据几何。"}, "p4": {"status": "deferred", "evidence_refs": []}, "p5": {"status": "deferred", "evidence_refs": []}, "p6": {"status": "deferred", "evidence_refs": []} @@ -26,12 +26,12 @@ "kind": "strategy", "lineage": "p2_v5", "stages": { - "p0": {"status": "verified", "evidence_refs": ["config/tqqq_core_only_p2_v5.json"]}, - "p1": {"status": "verified", "evidence_refs": ["tests/test_tqqq_p3_synthetic_evidence.py"]}, - "p2": {"status": "verified", "evidence_refs": ["config/tqqq_core_only_p2_v5.json"]}, - "p3": {"status": "verified", "evidence_refs": ["tests/test_tqqq_p3_synthetic_evidence.py"]}, - "p4": {"status": "in_progress", "evidence_refs": ["docs/tqqq-validation-and-shadow-policy.md"]}, - "p5": {"status": "in_progress", "evidence_refs": []}, + "p0": {"status": "not_started", "evidence_refs": [], "evidence_class": "not_applicable"}, + "p1": {"status": "synthetic_verified", "evidence_refs": ["tests/test_tqqq_p3_synthetic_evidence.py"], "evidence_class": "synthetic"}, + "p2": {"status": "contract_ready", "evidence_refs": ["config/tqqq_core_only_p2_v5.json"], "evidence_class": "contract"}, + "p3": {"status": "synthetic_verified", "evidence_refs": ["tests/test_tqqq_p3_synthetic_evidence.py"], "evidence_class": "synthetic"}, + "p4": {"status": "contract_ready", "evidence_refs": ["docs/tqqq-validation-and-shadow-policy.md"], "evidence_class": "contract"}, + "p5": {"status": "contract_ready", "evidence_refs": [], "evidence_class": "contract"}, "p6": {"status": "not_started", "evidence_refs": []} }, "blocking_reasons": ["requires_approved_forward_observation"], @@ -43,7 +43,7 @@ "kind": "strategy", "lineage": "successor", "stages": { - "p0": {"status": "in_progress", "evidence_refs": []}, + "p0": {"status": "not_started", "evidence_refs": [], "evidence_class": "not_applicable"}, "p1": {"status": "in_progress", "evidence_refs": []}, "p2": {"status": "in_progress", "evidence_refs": []}, "p3": {"status": "in_progress", "evidence_refs": []}, @@ -60,10 +60,10 @@ "kind": "strategy", "lineage": "smart_dca", "stages": { - "p0": {"status": "verified", "evidence_refs": ["UsEquityStrategies/src/us_equity_strategies/manifests/__init__.py"]}, - "p1": {"status": "verified", "evidence_refs": ["UsEquityStrategies/tests/test_nasdaq_sp500_smart_dca.py"]}, - "p2": {"status": "verified", "evidence_refs": ["UsEquityStrategies/docs/research/nasdaq_sp500_smart_dca.md"]}, - "p3": {"status": "in_progress", "evidence_refs": ["UsEquityStrategies/tests/test_smart_dca_research.py", "UsEquityStrategies/tests/test_smart_dca_research_cli.py"]}, + "p0": {"status": "not_started", "evidence_refs": [], "evidence_class": "not_applicable"}, + "p1": {"status": "contract_ready", "evidence_refs": ["UsEquityStrategies/tests/test_nasdaq_sp500_smart_dca.py"], "evidence_class": "contract"}, + "p2": {"status": "contract_ready", "evidence_refs": ["UsEquityStrategies/docs/research/nasdaq_sp500_smart_dca.md"], "evidence_class": "contract"}, + "p3": {"status": "synthetic_verified", "evidence_refs": ["UsEquityStrategies/tests/test_smart_dca_research.py", "UsEquityStrategies/tests/test_smart_dca_research_cli.py"], "evidence_class": "synthetic"}, "p4": {"status": "deferred", "evidence_refs": []}, "p5": {"status": "deferred", "evidence_refs": []}, "p6": {"status": "deferred", "evidence_refs": []} @@ -77,8 +77,8 @@ "kind": "strategy", "lineage": "research_only", "stages": { - "p0": {"status": "verified", "evidence_refs": ["UsEquityStrategies/src/us_equity_strategies/entrypoints/__init__.py"]}, - "p1": {"status": "in_progress", "evidence_refs": ["UsEquityStrategies/src/us_equity_strategies/strategies/tecl_xlk_trend_income.py"]}, + "p0": {"status": "not_started", "evidence_refs": [], "evidence_class": "not_applicable"}, + "p1": {"status": "contract_ready", "evidence_refs": ["UsEquityStrategies/src/us_equity_strategies/strategies/tecl_xlk_trend_income.py"], "evidence_class": "contract"}, "p2": {"status": "parked", "evidence_refs": ["docs/strategy_portfolio_action_matrix.md", "docs/strategy_lifecycle_terminology.zh-CN.md"], "note": "当前策略保持 research_active:可继续自动研究与 shadow 准备,但尚未形成 successor 冻结配置。"}, "p3": {"status": "parked", "evidence_refs": ["docs/strategy_portfolio_action_matrix.md"], "note": "未注册统一 P3 证据包,不进入 promotion。"}, "p4": {"status": "deferred", "evidence_refs": []}, diff --git a/docs/strategy_plugin_runtime_contract.md b/docs/strategy_plugin_runtime_contract.md index 8365fbb9..b0789810 100644 --- a/docs/strategy_plugin_runtime_contract.md +++ b/docs/strategy_plugin_runtime_contract.md @@ -1,8 +1,18 @@ -# Strategy Plugin Runtime Contract +# Legacy Strategy Plugin Runtime Contract (Historical Compatibility Reference) [简体中文](./strategy_plugin_runtime_contract.zh-CN.md) -This document describes how platform runtimes consume sidecar strategy plugin +> **Status: HISTORICAL / no authority effect.** This document preserves the +> legacy `latest_signal.json` platform-mount format for replay and migration +> troubleshooting only. The current control plane has retired automatic +> name-based plugin mounts. Do not use this contract to add a platform mount, +> alter positions, enable Paper/Live, or infer any P4–P6 eligibility. Current +> V2 plugins only create constrained observation/notification evidence. A +> runnable object must bind the strategy revision, plugin revision, inputs, +> and portfolio rules into a distinct composite candidate that completes its +> own P1–P3 and target-channel evidence. + +This document historically describes how platform runtimes consumed sidecar strategy plugin artifacts, such as a Crisis Response plugin produced by an upstream snapshot or research pipeline. @@ -15,7 +25,7 @@ research pipeline. - Broker order placement remains in platform repositories. - Strategy formulas remain in strategy repositories. -## Platform Mount Config +## Historical Platform Mount Config (do not use for new deployments) Platform config should only decide which plugin artifacts are mounted for a strategy. It must not select the plugin mode. The mode lives inside the plugin diff --git a/docs/strategy_plugin_runtime_contract.zh-CN.md b/docs/strategy_plugin_runtime_contract.zh-CN.md index 964606c7..1fc2a9cc 100644 --- a/docs/strategy_plugin_runtime_contract.zh-CN.md +++ b/docs/strategy_plugin_runtime_contract.zh-CN.md @@ -1,8 +1,10 @@ -# 策略插件运行时契约 +# 旧策略插件运行时契约(历史兼容参考) [English](./strategy_plugin_runtime_contract.md) -本文档说明平台运行时如何消费侧车策略插件 artifact,例如上游 snapshot +> **状态:HISTORICAL / 不具权限效力。** 本文档保留旧 `latest_signal.json` 平台挂载格式,供历史回放和迁移排障使用。当前主线已退役按策略名称自动挂载插件 artifact 的路径;不得据此新增平台挂载、修改仓位、启用 Paper/Live 或推导任何 P4–P6 资格。当前 V2 插件只可产生受限观察/通知证据,真正可运行的对象必须是绑定策略 revision、插件 revision、输入和仓位规则的独立 composite candidate,并重新完成自己的 P1–P3 与目标通道证据。 + +本文档历史性地说明平台运行时如何消费侧车策略插件 artifact,例如上游 snapshot 或研究 pipeline 生成的 crisis response、macro risk governor 或统一 `market_regime_control` 信号。 @@ -13,7 +15,7 @@ - 券商下单仍属于平台仓库。 - 策略公式仍属于策略仓库。 -## 平台挂载配置 +## 历史平台挂载配置(不得用于新部署) 平台配置只决定某个策略挂载哪些插件 artifact,不选择插件模式。插件模式写在 artifact 内,并固定为通知/观察用途的 `shadow`。 diff --git a/schemas/strategy-lifecycle-matrix.v1.schema.json b/schemas/strategy-lifecycle-matrix.v1.schema.json index 17421f92..587f7b4a 100644 --- a/schemas/strategy-lifecycle-matrix.v1.schema.json +++ b/schemas/strategy-lifecycle-matrix.v1.schema.json @@ -19,8 +19,9 @@ "type": "object", "required": ["status", "evidence_refs"], "properties": { - "status": {"enum": ["not_started", "in_progress", "verified", "parked", "deferred", "inconclusive"]}, + "status": {"enum": ["not_started", "in_progress", "verified", "contract_ready", "synthetic_verified", "observed_incomplete", "observed_complete", "deployed_not_observed", "deployed_healthy", "parked", "deferred", "inconclusive"]}, "evidence_refs": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "evidence_class": {"enum": ["not_applicable", "legacy_unclassified", "contract", "synthetic", "observed", "deployed"]}, "digest": {"type": ["string", "null"]}, "note": {"type": "string"} }, diff --git a/src/quant_platform_kit/strategy_lifecycle/forward_observation.py b/src/quant_platform_kit/strategy_lifecycle/forward_observation.py index 7407e82e..dcfe9b7e 100644 --- a/src/quant_platform_kit/strategy_lifecycle/forward_observation.py +++ b/src/quant_platform_kit/strategy_lifecycle/forward_observation.py @@ -16,12 +16,13 @@ FORWARD_OBSERVATION_POLICY_SCHEMA_VERSION = "forward_observation_policy.v1" -_NON_LIVE_MODES = frozenset({"shadow", "paper"}) +_SUPPORTED_NON_LIVE_MODES = frozenset({"shadow", "paper"}) _NON_LIVE_EVIDENCE_MODES = frozenset( {"shadow_decision", "simulated_replay", "broker_paper"} ) _DATA_STATUSES = frozenset({"ready", "stale", "unavailable"}) -_MODE_STATUSES = frozenset({"healthy", "mismatch", "unavailable"}) +_SHADOW_STATUSES = frozenset({"healthy", "mismatch", "unavailable"}) +_PAPER_STATUSES = _SHADOW_STATUSES | frozenset({"unsupported"}) _RISK_STATUSES = frozenset({"pass", "blocked"}) _WINDOW_TYPES = frozenset({"fixed", "rolling"}) _CONTROL_STATUSES = frozenset( @@ -40,7 +41,6 @@ "superseded", } ) -_STOPPED_ACTIONS = ("keep_shadow_stopped", "keep_paper_stopped") _PERMANENT_PREVIOUS_STATES = frozenset( {"manual_hold", "identity_mismatch", "risk_blocked", "revoked", "superseded"} ) @@ -120,9 +120,14 @@ def __post_init__(self) -> None: ) modes = tuple(str(mode).strip().lower() for mode in self.automatic_non_live_modes) - if not modes or set(modes) != _NON_LIVE_MODES or len(modes) != len(_NON_LIVE_MODES): + if ( + not modes + or len(set(modes)) != len(modes) + or set(modes) - _SUPPORTED_NON_LIVE_MODES + or "shadow" not in modes + ): raise ForwardObservationPolicyError( - "automatic_non_live_modes must contain shadow and paper exactly once" + "automatic_non_live_modes must contain shadow exactly once and may contain paper once" ) milestones = tuple(self.review_milestones) if tuple(sorted(milestones)) != milestones or len(set(milestones)) != len(milestones): @@ -156,17 +161,29 @@ def __post_init__(self) -> None: evidence_modes = tuple( str(mode).strip().lower() for mode in self.non_live_evidence_modes ) + paper_evidence_modes = {"simulated_replay", "broker_paper"} & set(evidence_modes) if ( - len(evidence_modes) != 2 - or len(set(evidence_modes)) != 2 + len(set(evidence_modes)) != len(evidence_modes) or set(evidence_modes) - _NON_LIVE_EVIDENCE_MODES or "shadow_decision" not in evidence_modes - or not ({"simulated_replay", "broker_paper"} & set(evidence_modes)) + or len(paper_evidence_modes) > 1 + or (self.supports_paper and len(paper_evidence_modes) != 1) + or (not self.supports_paper and paper_evidence_modes) ): raise ForwardObservationPolicyError( - "non_live_evidence_modes must contain shadow_decision and exactly one paper mode" + "non_live_evidence_modes must contain shadow_decision and exactly one paper mode only when paper is enabled" ) + @property + def supports_paper(self) -> bool: + """Whether this target has an explicitly configured paper channel. + + Shadow is mandatory for every forward-observation policy. Paper is a + target capability, not a global lifecycle prerequisite. + """ + + return "paper" in self.automatic_non_live_modes + def to_dict(self) -> dict[str, object]: return { "schema_version": FORWARD_OBSERVATION_POLICY_SCHEMA_VERSION, @@ -226,9 +243,9 @@ def __post_init__(self) -> None: raise ForwardObservationPolicyError("unsupported previous_state") if self.data_status not in _DATA_STATUSES: raise ForwardObservationPolicyError("unsupported data_status") - if self.shadow_status not in _MODE_STATUSES: + if self.shadow_status not in _SHADOW_STATUSES: raise ForwardObservationPolicyError("unsupported shadow_status") - if self.paper_status not in _MODE_STATUSES: + if self.paper_status not in _PAPER_STATUSES: raise ForwardObservationPolicyError("unsupported paper_status") if self.risk_status not in _RISK_STATUSES: raise ForwardObservationPolicyError("unsupported risk_status") @@ -291,7 +308,7 @@ def evaluate_forward_observation( policy, snapshot, state="PARKED", - actions=_STOPPED_ACTIONS, + actions=_stopped_actions(policy), notifications=("historical_evidence_required",), reasons=("verified P3 historical evidence is required before P4",), ) @@ -303,7 +320,7 @@ def evaluate_forward_observation( policy, snapshot, state=state, - actions=_STOPPED_ACTIONS, + actions=_stopped_actions(policy), notifications=(notification,) if notification else (), reasons=(reason,), ) @@ -313,7 +330,7 @@ def evaluate_forward_observation( policy, snapshot, state="RISK_BLOCKED", - actions=_STOPPED_ACTIONS, + actions=_stopped_actions(policy), notifications=( ("forward_observation_risk_blocked",) if snapshot.previous_state != "risk_blocked" @@ -322,13 +339,13 @@ def evaluate_forward_observation( reasons=("risk_status=blocked; explicit human review is required",), ) - health_reasons = _health_reasons(snapshot) + health_reasons = _health_reasons(policy, snapshot) if health_reasons: return _decision( policy, snapshot, state="PAUSED", - actions=("pause_shadow", "pause_paper"), + actions=_paused_actions(policy), notifications=("forward_observation_paused",), reasons=tuple(health_reasons), ) @@ -341,7 +358,7 @@ def evaluate_forward_observation( policy, snapshot, state="PAUSED", - actions=("keep_shadow_paused", "keep_paper_paused"), + actions=_keep_paused_actions(policy), notifications=(), reasons=( "recovery observation is still collecting clean sessions " @@ -357,26 +374,18 @@ def evaluate_forward_observation( policy, snapshot, state="FORWARD_COMPLETE_HUMAN_REVIEW", - actions=_STOPPED_ACTIONS, + actions=_stopped_actions(policy), notifications=tuple(notifications), reasons=( "forward window is complete; non-live observation is stopped and live remains blocked pending explicit human approval", ), ) - actions = ( - ("resume_shadow", "resume_paper") - if snapshot.previous_state == "paused" - else ( - ("start_shadow", "start_paper") - if snapshot.previous_state == "not_started" - else ("continue_shadow", "continue_paper") - ) - ) + actions = _active_actions(policy, snapshot.previous_state) notifications = list(_crossed_milestones(policy, snapshot)) state = "FORWARD_ACTIVE" reasons = [ - "P3 evidence is verified; non-live shadow and paper observation may run automatically" + "P3 evidence is verified; configured non-live observation channels may run automatically" ] return _decision( policy, @@ -388,17 +397,54 @@ def evaluate_forward_observation( ) -def _health_reasons(snapshot: ForwardObservationSnapshot) -> list[str]: +def _health_reasons( + policy: ForwardObservationPolicy, snapshot: ForwardObservationSnapshot +) -> list[str]: reasons: list[str] = [] if snapshot.data_status != "ready": reasons.append(f"data_status={snapshot.data_status}") if snapshot.shadow_status != "healthy": reasons.append(f"shadow_status={snapshot.shadow_status}") - if snapshot.paper_status != "healthy": + if policy.supports_paper and snapshot.paper_status != "healthy": reasons.append(f"paper_status={snapshot.paper_status}") return reasons +def _channel_actions( + policy: ForwardObservationPolicy, *, shadow: str, paper: str +) -> tuple[str, ...]: + actions = [shadow] + if policy.supports_paper: + actions.append(paper) + return tuple(actions) + + +def _stopped_actions(policy: ForwardObservationPolicy) -> tuple[str, ...]: + return _channel_actions( + policy, shadow="keep_shadow_stopped", paper="keep_paper_stopped" + ) + + +def _paused_actions(policy: ForwardObservationPolicy) -> tuple[str, ...]: + return _channel_actions(policy, shadow="pause_shadow", paper="pause_paper") + + +def _keep_paused_actions(policy: ForwardObservationPolicy) -> tuple[str, ...]: + return _channel_actions( + policy, shadow="keep_shadow_paused", paper="keep_paper_paused" + ) + + +def _active_actions( + policy: ForwardObservationPolicy, previous_state: str +) -> tuple[str, ...]: + if previous_state == "paused": + return _channel_actions(policy, shadow="resume_shadow", paper="resume_paper") + if previous_state == "not_started": + return _channel_actions(policy, shadow="start_shadow", paper="start_paper") + return _channel_actions(policy, shadow="continue_shadow", paper="continue_paper") + + def _controlled_stop( snapshot: ForwardObservationSnapshot, ) -> tuple[str, str | None, str] | None: diff --git a/src/quant_platform_kit/strategy_lifecycle/lifecycle_matrix_runtime.py b/src/quant_platform_kit/strategy_lifecycle/lifecycle_matrix_runtime.py index f4e32ad5..bc46ce16 100644 --- a/src/quant_platform_kit/strategy_lifecycle/lifecycle_matrix_runtime.py +++ b/src/quant_platform_kit/strategy_lifecycle/lifecycle_matrix_runtime.py @@ -18,11 +18,25 @@ "not_started", "in_progress", "verified", + "contract_ready", + "synthetic_verified", + "observed_incomplete", + "observed_complete", + "deployed_not_observed", + "deployed_healthy", "parked", "deferred", "inconclusive", } _OBSERVED_STAGES = {"p1", "p3", "p4", "p5"} +_EVIDENCE_CLASSES = { + "not_applicable", + "legacy_unclassified", + "contract", + "synthetic", + "observed", + "deployed", +} class LifecycleMatrixInputError(ValueError): @@ -65,6 +79,18 @@ def _stage_record(payload: dict[str, Any], path: Path) -> tuple[str, dict[str, A if not refs: raise LifecycleMatrixInputError(f"{path}: evidence_ref(s) must be non-empty") record: dict[str, Any] = {"status": status, "evidence_refs": refs} + evidence_class = payload.get("evidence_class") + if evidence_class is None: + # Older terminal artifacts used a broad ``verified`` label. Preserve + # read compatibility, but make their weaker semantics explicit so a + # projection cannot present them as observed execution evidence. + evidence_class = "legacy_unclassified" if status == "verified" else "not_applicable" + evidence_class = _required_string(evidence_class, "evidence_class", path).lower() + if evidence_class not in _EVIDENCE_CLASSES: + raise LifecycleMatrixInputError( + f"{path}: unsupported evidence_class {evidence_class!r}" + ) + record["evidence_class"] = evidence_class digest = payload.get("digest", payload.get("evidence_digest")) if digest is not None: record["digest"] = _required_string(digest, "digest", path) @@ -105,7 +131,11 @@ def build_lifecycle_matrix( "kind": kind, "lineage": lineage, "stages": { - name: {"status": "not_started", "evidence_refs": []} + name: { + "status": "not_started", + "evidence_refs": [], + "evidence_class": "not_applicable", + } for name in _STAGES }, "blocking_reasons": [], @@ -133,4 +163,3 @@ def build_lifecycle_matrix( "source_policy": "Read-only aggregation of terminal artifacts; never authorizes promotion or trading.", "entries": list(entries.values()), } - diff --git a/tests/test_forward_observation.py b/tests/test_forward_observation.py index 003aa6ff..59b5365d 100644 --- a/tests/test_forward_observation.py +++ b/tests/test_forward_observation.py @@ -75,6 +75,33 @@ def test_transient_operational_failure_pauses_both_non_live_modes() -> None: assert result.live_authority_granted is False +def test_shadow_only_target_does_not_pause_for_unsupported_paper() -> None: + policy = _policy( + automatic_non_live_modes=("shadow",), + non_live_evidence_modes=("shadow_decision",), + ) + result = evaluate_forward_observation(policy, _snapshot(paper_status="unsupported")) + + assert policy.supports_paper is False + assert result.state == "FORWARD_ACTIVE" + assert result.non_live_actions == ("start_shadow",) + assert all("paper_status" not in reason for reason in result.reasons) + + +def test_shadow_only_target_pauses_only_shadow_for_transient_data_failure() -> None: + policy = _policy( + automatic_non_live_modes=("shadow",), + non_live_evidence_modes=("shadow_decision",), + ) + result = evaluate_forward_observation( + policy, _snapshot(data_status="stale", paper_status="unsupported") + ) + + assert result.state == "PAUSED" + assert result.non_live_actions == ("pause_shadow",) + assert result.reasons == ("data_status=stale",) + + def test_risk_block_requires_human_review_and_never_auto_resumes() -> None: blocked = evaluate_forward_observation(_policy(), _snapshot(risk_status="blocked")) still_blocked = evaluate_forward_observation( @@ -123,6 +150,25 @@ def test_milestones_and_full_window_never_promote_live() -> None: assert completed.live_authority_granted is False +def test_shadow_only_full_window_never_promotes_or_requires_paper() -> None: + policy = _policy( + automatic_non_live_modes=("shadow",), + non_live_evidence_modes=("shadow_decision",), + ) + completed = evaluate_forward_observation( + policy, + _snapshot( + observations_completed=252, + previous_observations_completed=251, + paper_status="unsupported", + ), + ) + + assert completed.state == "FORWARD_COMPLETE_HUMAN_REVIEW" + assert completed.non_live_actions == ("keep_shadow_stopped",) + assert completed.live_authority_granted is False + + def test_each_candidate_supplies_its_own_forward_window_without_soxl_defaults() -> None: policy = ForwardObservationPolicy( candidate_id="global-etf-monthly-v1", @@ -182,11 +228,16 @@ def test_non_transient_control_states_never_auto_resume( def test_policy_and_snapshot_reject_ambiguous_configuration() -> None: with pytest.raises(ForwardObservationPolicyError, match="automatic_non_live_modes"): - _policy(automatic_non_live_modes=("shadow",)) + _policy(automatic_non_live_modes=("paper",)) with pytest.raises(ForwardObservationPolicyError, match="review_milestones"): _policy(review_milestones=(60, 20)) with pytest.raises(ForwardObservationPolicyError, match="paper mode"): _policy(non_live_evidence_modes=("shadow_decision", "broker_paper", "simulated_replay")) + with pytest.raises(ForwardObservationPolicyError, match="paper mode"): + _policy( + automatic_non_live_modes=("shadow",), + non_live_evidence_modes=("shadow_decision", "broker_paper"), + ) with pytest.raises(ForwardObservationPolicyError, match="rolling"): _policy(observation_window_type="rolling", observation_start_session="2026-08-26") with pytest.raises(ForwardObservationPolicyError, match="cannot exceed"): diff --git a/tests/test_lifecycle_matrix_runtime.py b/tests/test_lifecycle_matrix_runtime.py index a5bbd65b..579e4b6e 100644 --- a/tests/test_lifecycle_matrix_runtime.py +++ b/tests/test_lifecycle_matrix_runtime.py @@ -44,7 +44,9 @@ def test_aggregates_read_only_terminal_artifacts_without_running_anything(tmp_pa entry = matrix["entries"][0] assert matrix["schema_version"] == "strategy_lifecycle_matrix.v1" assert entry["stages"]["p1"]["digest"] == "sha256:p1" + assert entry["stages"]["p1"]["evidence_class"] == "legacy_unclassified" assert entry["stages"]["p3"]["evidence_refs"] == ["p3/evidence.json"] + assert entry["stages"]["p4"]["evidence_class"] == "not_applicable" assert entry["stages"]["p4"]["status"] == "not_started" assert "promotion" in matrix["source_policy"] @@ -67,3 +69,24 @@ def test_rejects_duplicate_or_unattributed_artifacts_fail_closed(tmp_path): with pytest.raises(LifecycleMatrixInputError, match="evidence_ref"): build_lifecycle_matrix([missing_ref]) + +def test_retains_explicit_observation_class_without_promoting_contract_evidence(tmp_path): + artifact = _write( + tmp_path, + "p3.json", + strategy_id="soxl-soxx-v7", + kind="strategy", + lineage="v7", + stage="p3", + status="observed_incomplete", + evidence_class="observed", + evidence_ref="p3/forward-window.json", + ) + + matrix = build_lifecycle_matrix([artifact], generated_at="2026-08-27") + + assert matrix["entries"][0]["stages"]["p3"] == { + "status": "observed_incomplete", + "evidence_refs": ["p3/forward-window.json"], + "evidence_class": "observed", + }