diff --git a/README.en.md b/README.en.md
index 7127a5d..4acd0f5 100644
--- a/README.en.md
+++ b/README.en.md
@@ -55,7 +55,7 @@ It is not an automated money maker, promises no profitability, and does not prov
```mermaid
flowchart LR
Op["Operator / External Agent
Web · ht CLI · TUI · Desktop"] --> API["FastAPI Control Plane
Mission · TokenManager · Probes (/livez, /readyz, /healthz)"]
- API --> DB[("PostgreSQL + pgvector
50 migrations · Mission projections · HET tree · Memory")]
+ API --> DB[("PostgreSQL + pgvector
51 migrations · Mission projections · HET tree · Memory")]
Worker["Worker Loops (13 Loops)
Mission · AVO research · evolution · auto-review · meta-tuning"] --> DB
Worker --> Research["ARC Autonomous Research Loop (AVO + RD-Agent)
HET tree → Co-STEER synthesis → AST gatekeeper → sandbox → baseline"]
Worker --> Evolution["Evolution Engine (default on)
decay scan → budget admission → re-research → race duel → ledger"]
@@ -213,7 +213,7 @@ Single command executes all quality gates: Frontend lint, Vitest, and production
| **Control Plane** | FastAPI 0.122+ / Uvicorn, scoped security tokens, `/livez`, `/readyz`, `/healthz` container probes, cursor SSE |
| **Credential Security**| `TokenRotationService` (SHA-256 single-way hashing, zero plaintext storage, 24h grace period hot rotation, 7-day expiration audit) |
| **Domain & Runtime** | Python 3.12+, Pydantic v2 strict models, modular monolith + ports-and-adapters |
-| **Storage** | SQLAlchemy 2.0 + Alembic (50 migrations), PostgreSQL 14+ with `pgvector` |
+| **Storage** | SQLAlchemy 2.0 + Alembic (51 migrations), PostgreSQL 14+ with `pgvector` |
| **Workers** | asyncio + PostgreSQL SQL lease locking (13 concurrent background loops) |
| **Research Core** | ARC + RD-Agent Hypothesis Evolution Tree (HET) + Co-STEER synthesizer + `ASTGatekeeper` |
| **Target Adapters** | `market_target.v1` and `market-evolution.v1` MCP contracts; BitPro (Crypto) + QuantLab (A-Shares) |
diff --git a/README.md b/README.md
index 27248a1..51e3562 100644
--- a/README.md
+++ b/README.md
@@ -55,7 +55,7 @@ It is not an automated money maker, promises no profitability, and does not prov
```mermaid
flowchart LR
Op["Operator / External Agent
Web · ht CLI · TUI · Desktop"] --> API["FastAPI Control Plane
Mission · TokenManager · Probes (/livez, /readyz, /healthz)"]
- API --> DB[("PostgreSQL + pgvector
50 migrations · Mission projections · HET tree · Memory")]
+ API --> DB[("PostgreSQL + pgvector
51 migrations · Mission projections · HET tree · Memory")]
Worker["Worker Loops (13 Loops)
Mission · AVO research · evolution · auto-review · meta-tuning"] --> DB
Worker --> Research["ARC Autonomous Research Loop (AVO + RD-Agent)
HET tree → Co-STEER synthesis → AST gatekeeper → sandbox → baseline"]
Worker --> Evolution["Evolution Engine (default on)
decay scan → budget admission → re-research → race duel → ledger"]
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 20d2a2b..adc067f 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -55,7 +55,7 @@
```mermaid
flowchart LR
Op["操作员 / 外部 Agent
Web · ht CLI · TUI · Desktop"] --> API["FastAPI 控制面
Mission · TokenManager · Probes (/livez, /readyz, /healthz)"]
- API --> DB[("PostgreSQL + pgvector
50 项迁移 · Mission 投影 · 演进树 · 记忆库")]
+ API --> DB[("PostgreSQL + pgvector
51 项迁移 · Mission 投影 · 演进树 · 记忆库")]
Worker["Worker 循环 (13 个 Loop)
Mission · AVO 研究 · 进化扫描 · 自动评审 · 元学习"] --> DB
Worker --> Research["ARC 自主研究循环 (AVO + RD-Agent)
HET 假设树 → Co-STEER 合成 → AST 门禁 → 沙箱自测 → 基线对比"]
Worker --> Evolution["自主进化引擎(默认开启)
退化扫描 → 预算准入 → 再研究 → 赛马对决 → 效果账本"]
@@ -213,7 +213,7 @@ curl http://localhost:3334/api/portfolio/relay/handovers
| **控制面** | FastAPI 0.122+ / Uvicorn,作用域安全令牌,`/livez`, `/readyz`, `/healthz` 容器探活矩阵,游标可续传 SSE |
| **凭据安全** | `TokenRotationService`(SHA-256 单向散列,零明文存储,24h 宽限期热轮换,7天临期审计) |
| **运行时与领域** | Python 3.12+、Pydantic v2 严格合同,模块化单体 + ports-and-adapters |
-| **存储** | SQLAlchemy 2.0 + Alembic(50 个迁移),PostgreSQL 14+ 带 `pgvector` |
+| **存储** | SQLAlchemy 2.0 + Alembic(51 个迁移),PostgreSQL 14+ 带 `pgvector` |
| **Worker 集群** | asyncio + PostgreSQL SQL lease 租约(13 个并发后台循环) |
| **研究与演进中枢** | ARC + RD-Agent 假设演进树 (HET) + Co-STEER 代码合成器 + `ASTGatekeeper` 语法守卫 |
| **多市场目标适配** | `market_target.v1` 与 `market-evolution.v1` MCP 契约;BitPro (Crypto) + QuantLab (A股) |
diff --git a/backend/alembic/versions/0051_arc_ablation_runs.py b/backend/alembic/versions/0051_arc_ablation_runs.py
new file mode 100644
index 0000000..9e3bc64
--- /dev/null
+++ b/backend/alembic/versions/0051_arc_ablation_runs.py
@@ -0,0 +1,118 @@
+"""Persist per-change ablation of forward losers (development-window backtests only).
+
+Ablation records are observations, not causal verdicts. Neither table holds a
+foreign key to ``arc_forward_outcomes``: correcting a ledger row must not rewrite
+or delete the backtest evidence already produced for it.
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0051_arc_ablation_runs"
+down_revision: str | None = "0050_arc_forward_outcomes"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _timestamps() -> tuple[sa.Column[object], sa.Column[object]]:
+ return (
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ )
+
+
+def _tables() -> set[str]:
+ return set(sa.inspect(op.get_bind()).get_table_names())
+
+
+def _indexes(table: str) -> set[str]:
+ return {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)}
+
+
+def upgrade() -> None:
+ # Idempotent on purpose: developer databases already got these tables from
+ # ``create_all``, and a partially applied migration must be safe to re-run.
+ if "arc_ablation_runs" not in _tables():
+ op.create_table(
+ "arc_ablation_runs",
+ sa.Column("id", sa.String(64), primary_key=True),
+ sa.Column("outcome_id", sa.String(64), nullable=False),
+ sa.Column("target_id", sa.String(32), nullable=False),
+ sa.Column("parent_strategy_id", sa.String(64), nullable=False),
+ sa.Column("challenger_strategy_id", sa.String(64), nullable=False),
+ sa.Column("status", sa.String(16), nullable=False, server_default="pending"),
+ sa.Column("reason", sa.String(64), nullable=False, server_default=""),
+ sa.Column("message", sa.Text(), nullable=False, server_default=""),
+ sa.Column("research_id", sa.String(64), nullable=False, server_default=""),
+ sa.Column("window_json", sa.JSON(), nullable=False, server_default="{}"),
+ sa.Column("plan_json", sa.JSON(), nullable=False, server_default="{}"),
+ sa.Column("result_json", sa.JSON(), nullable=False, server_default="{}"),
+ sa.Column("next_check_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
+ *_timestamps(),
+ sa.UniqueConstraint(
+ "target_id",
+ "challenger_strategy_id",
+ name="uq_arc_ablation_run_challenger",
+ ),
+ sa.UniqueConstraint("outcome_id", name="uq_arc_ablation_run_outcome"),
+ )
+
+ run_indexes = _indexes("arc_ablation_runs")
+ for name, columns in (
+ ("ix_arc_ablation_runs_parent_strategy_id", ["parent_strategy_id"]),
+ ("ix_arc_ablation_runs_status", ["status"]),
+ ("ix_arc_ablation_runs_next_check_at", ["next_check_at"]),
+ ):
+ if name not in run_indexes:
+ op.create_index(name, "arc_ablation_runs", columns)
+
+ if "arc_ablation_steps" not in _tables():
+ op.create_table(
+ "arc_ablation_steps",
+ sa.Column("id", sa.String(64), primary_key=True),
+ sa.Column("run_id", sa.String(64), nullable=False),
+ sa.Column("step_key", sa.String(160), nullable=False),
+ sa.Column("ordinal", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("kind", sa.String(16), nullable=False),
+ sa.Column("change_json", sa.JSON(), nullable=False, server_default="{}"),
+ sa.Column("status", sa.String(16), nullable=False, server_default="pending"),
+ sa.Column("reason", sa.String(128), nullable=False, server_default=""),
+ sa.Column("request_hash", sa.String(64), nullable=False, server_default=""),
+ sa.Column("checkpoint_json", sa.JSON(), nullable=False, server_default="{}"),
+ sa.Column("result_json", sa.JSON(), nullable=False, server_default="{}"),
+ sa.Column("charged_at", sa.DateTime(timezone=True), nullable=True),
+ *_timestamps(),
+ sa.UniqueConstraint("run_id", "step_key", name="uq_arc_ablation_step"),
+ )
+
+ step_indexes = _indexes("arc_ablation_steps")
+ for name, columns in (
+ ("ix_arc_ablation_steps_run_id", ["run_id"]),
+ ("ix_arc_ablation_steps_status", ["status"]),
+ ("ix_arc_ablation_steps_request_hash", ["request_hash"]),
+ ("ix_arc_ablation_steps_charged_at", ["charged_at"]),
+ ):
+ if name not in step_indexes:
+ op.create_index(name, "arc_ablation_steps", columns)
+
+
+def downgrade() -> None:
+ tables = _tables()
+ if "arc_ablation_steps" in tables:
+ op.drop_table("arc_ablation_steps")
+ if "arc_ablation_runs" in tables:
+ op.drop_table("arc_ablation_runs")
diff --git a/backend/src/hypertrade/arc/ablation.py b/backend/src/hypertrade/arc/ablation.py
new file mode 100644
index 0000000..953ab7f
--- /dev/null
+++ b/backend/src/hypertrade/arc/ablation.py
@@ -0,0 +1,1692 @@
+"""Per-change ablation of forward losers on the development window.
+
+For a challenger the race judge recorded as a loser (``lost_futility`` / ``lost_exam``)
+this replays the parent, then the parent plus exactly one of the challenger's changes,
+on the frozen development window. The goal is a neutral observation record for the next
+research round: which single change looked harmful and which looked helpful.
+
+Boundaries:
+- Backtests only. No Paper, incubation or live call is reachable from this module.
+- Variants are created as stopped research rows without Paper review bindings, so BitPro
+ never enrolls them in the race.
+- Only the development window is requested; the final window is never read.
+- The daily budget is independent of the daily research quota.
+
+The conclusion is a single-factor effect inside one development window. It contains no
+interaction between changes and is not a causal claim.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from collections.abc import Callable, Sequence
+from datetime import UTC, date, datetime, timedelta
+from decimal import Decimal
+from typing import Any, NoReturn
+
+from sqlalchemy import case, func, select
+from sqlalchemy.exc import IntegrityError
+
+from hypertrade.arc.backtest_recovery import (
+ PENDING_BACKTEST_STATES,
+ BacktestDeferred,
+)
+from hypertrade.arc.backtest_recovery import (
+ digest as backtest_digest,
+)
+from hypertrade.arc.contracts import (
+ ARCCandidateAttemptV1,
+ ARCGoalV1,
+ ARCSuccessCriteriaV1,
+ ResearchWindowsV1,
+)
+from hypertrade.arc.controller import ARCMissionProjection
+from hypertrade.arc.evolution_models import (
+ ArcAblationRun,
+ ArcAblationStep,
+ ArcForwardOutcome,
+ EvolutionControl,
+)
+from hypertrade.arc.feedback import compare_backtests
+from hypertrade.arc.runtime_journal import failure_text, record_runtime_error
+from hypertrade.arc.self_test import ARCSelfTestService, _fraction, _number
+from hypertrade.arc.store import research_lock
+from hypertrade.db import ArcMission, Database
+
+LOSER_VERDICTS = ("lost_futility", "lost_exam")
+NEW_RUNS_PER_TICK = 5
+STEPS_PER_TICK = 2
+HOLD_LIMIT = timedelta(days=7) # parent seat / policy hold, not a job failure
+JOB_WAIT_LIMIT = timedelta(hours=24) # one job from creation to a terminal state
+MAX_RUNTIME_ERRORS = 5
+# Descriptive noise band in return/drawdown fractions (0.005 = 0.5 percentage points).
+# These are observation labels, not a significance test.
+HELPFUL_RETURN = 0.005
+HARMFUL_RETURN = -0.005
+HELPFUL_DRAWDOWN_SLACK = 0.005
+HARMFUL_DRAWDOWN = 0.01
+NOTE = (
+ "开发窗口内单因素效应:每次只把一处改动单独加到母体上测,"
+ "不含改动之间的交互,不是因果结论"
+)
+ABLATION_MEMORY_POLICY = "ablation_memory_v1"
+ABLATION_MEMORY_ENTRY_CHARS = 4000 # one projected row must stay a bounded context entry
+ABLATION_MEMORY_RULE = (
+ "Parent re-tested on one development window with exactly one of this loser's changes "
+ "at a time; single-factor observations without interactions, not causal conclusions."
+)
+
+
+class AblationBudgetDeferred(BacktestDeferred):
+ """Today's ablation backtest allowance is used up; a wait, not a failure."""
+
+
+def change_list(changes: Any) -> list[dict[str, Any]]:
+ """Flatten ``parameter_changes``/``structural_changes`` into stable ordered rows."""
+ source = changes if isinstance(changes, dict) else {}
+ parameters = source.get("parameter_changes")
+ structural = source.get("structural_changes")
+ rows: list[dict[str, Any]] = []
+ if isinstance(parameters, dict):
+ for key in sorted(parameters, key=lambda item: str(item)):
+ value = parameters[key]
+ if value is None:
+ continue
+ rows.append({"kind": "parameter", "key": key, "value": value})
+ if isinstance(structural, dict):
+ for key in sorted(structural, key=lambda item: str(item)):
+ rows.append({"kind": "structural", "key": key, "value": structural[key]})
+ return rows
+
+
+def change_count(changes: Any) -> int:
+ return len(change_list(changes))
+
+
+def run_id_for(target_id: str, challenger_strategy_id: object) -> str:
+ body = json.dumps(
+ [target_id, str(challenger_strategy_id)], separators=(",", ":")
+ ).encode()
+ return "abl_" + hashlib.sha256(body).hexdigest()[:32]
+
+
+def step_key_for(change: dict[str, Any] | None) -> str:
+ if change is None:
+ return "baseline"
+ kind = "param" if change.get("kind") == "parameter" else "struct"
+ return f"{kind}:{change.get('key')}"
+
+
+def step_id_for(run_id: str, step_key: str) -> str:
+ body = json.dumps([run_id, step_key], separators=(",", ":")).encode()
+ return "abs_" + hashlib.sha256(body).hexdigest()[:32]
+
+
+def change_label(change: dict[str, Any]) -> str:
+ key = change.get("key")
+ if change.get("kind") == "parameter":
+ return f"{key}={change.get('value')}"
+ encoded = json.dumps(change.get("value"), sort_keys=True, ensure_ascii=False)
+ return f"{key}:{encoded[:80]}"
+
+
+def _utc(value: datetime | None) -> datetime | None:
+ if value is None:
+ return None
+ return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
+
+
+def next_utc_midnight(now: datetime) -> datetime:
+ moment = _utc(now)
+ assert moment is not None
+ return moment.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
+
+
+def _is_sha256(value: Any) -> bool:
+ return isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) is not None
+
+
+def _spec_symbols(spec: dict[str, Any]) -> list[str]:
+ declared = spec.get("symbols")
+ if isinstance(declared, (list, tuple)):
+ values = [
+ value.strip() for value in declared if isinstance(value, str) and value.strip()
+ ]
+ if len(values) == len(declared) and values:
+ return list(dict.fromkeys(values))
+ return []
+ single = spec.get("symbol")
+ return [single.strip()] if isinstance(single, str) and single.strip() else []
+
+
+def _write_run(db: Database, run_id: str, **values: Any) -> None:
+ with db.session() as session:
+ row = session.get(ArcAblationRun, run_id)
+ if row is None:
+ return
+ for key, value in values.items():
+ setattr(row, key, value)
+
+
+def _write_step(db: Database, step_id: str, **values: Any) -> None:
+ with db.session() as session:
+ row = session.get(ArcAblationStep, step_id)
+ if row is None:
+ return
+ for key, value in values.items():
+ setattr(row, key, value)
+
+
+def _get_run(db: Database, run_id: str) -> ArcAblationRun | None:
+ with db.session() as session:
+ return session.get(ArcAblationRun, run_id)
+
+
+def _get_step(db: Database, step_id: str) -> ArcAblationStep | None:
+ with db.session() as session:
+ return session.get(ArcAblationStep, step_id)
+
+
+def _step_checkpoint(db: Database, step_id: str) -> dict[str, Any]:
+ step = _get_step(db, step_id)
+ return dict(step.checkpoint_json or {}) if step is not None else {}
+
+
+def _step_rows(db: Database, run_id: str) -> list[ArcAblationStep]:
+ with db.session() as session:
+ return list(
+ session.scalars(
+ select(ArcAblationStep)
+ .where(ArcAblationStep.run_id == run_id)
+ .order_by(ArcAblationStep.ordinal.asc())
+ )
+ )
+
+
+def _baseline_step(db: Database, run_id: str) -> ArcAblationStep | None:
+ return next((step for step in _step_rows(db, run_id) if step.kind == "baseline"), None)
+
+
+def _daily_charged(db: Database, now: datetime) -> int:
+ moment = _utc(now)
+ assert moment is not None
+ start = moment.replace(hour=0, minute=0, second=0, microsecond=0)
+ end = start + timedelta(days=1)
+ with db.session() as session:
+ count = session.scalar(
+ select(func.count())
+ .select_from(ArcAblationStep)
+ .where(
+ ArcAblationStep.charged_at.is_not(None),
+ ArcAblationStep.charged_at >= start,
+ ArcAblationStep.charged_at < end,
+ )
+ )
+ return int(count or 0)
+
+
+def _find_reusable(
+ db: Database, step_id: str, request_hash: str | None
+) -> dict[str, Any] | None:
+ """Another step's job for the same request is free; identity is the request hash."""
+ if not request_hash:
+ return None
+ with db.session() as session:
+ rows = list(
+ session.scalars(
+ select(ArcAblationStep)
+ .where(
+ ArcAblationStep.request_hash == request_hash,
+ ArcAblationStep.id != step_id,
+ )
+ .order_by(ArcAblationStep.created_at.asc())
+ .limit(50)
+ )
+ )
+ for row in rows:
+ checkpoint = dict(row.checkpoint_json or {})
+ if checkpoint.get("job_id") and checkpoint.get("input_snapshot_hash"):
+ return {
+ "id": row.id,
+ "job_id": checkpoint["job_id"],
+ "input_snapshot_hash": checkpoint["input_snapshot_hash"],
+ "created_at": checkpoint.get("created_at"),
+ }
+ return None
+
+
+def schedule_runs(db: Database, config: Any, now: datetime) -> dict[str, int]:
+ """Create one pending run per new linked loser; one bad row never rolls back the batch."""
+ counts = {"created": 0, "single_change": 0, "already_scheduled": 0}
+ with db.session() as session:
+ rows = list(
+ session.scalars(
+ select(ArcForwardOutcome)
+ .where(
+ ArcForwardOutcome.target_id == config.target_id,
+ ArcForwardOutcome.verdict.in_(LOSER_VERDICTS),
+ ArcForwardOutcome.lineage_status == "linked",
+ )
+ .order_by(
+ case((ArcForwardOutcome.ended_at.is_(None), 1), else_=0),
+ ArcForwardOutcome.ended_at.desc(),
+ ArcForwardOutcome.created_at.desc(),
+ )
+ .limit(200)
+ )
+ )
+ outcome_rows: list[dict[str, Any]] = [
+ {
+ "id": row.id,
+ "outcome_id": row.id,
+ "target_id": row.target_id,
+ "parent_strategy_id": row.parent_strategy_id,
+ "challenger_strategy_id": row.challenger_strategy_id,
+ "changes": dict(row.changes_json or {}),
+ }
+ for row in rows
+ ]
+ existing = {
+ (target_id, str(challenger))
+ for target_id, challenger in session.execute(
+ select(
+ ArcAblationRun.target_id, ArcAblationRun.challenger_strategy_id
+ ).where(ArcAblationRun.target_id == config.target_id)
+ ).all()
+ }
+ for row in outcome_rows:
+ pair = (row["target_id"], str(row["challenger_strategy_id"]))
+ if pair in existing:
+ counts["already_scheduled"] += 1
+ continue
+ if change_count(row["changes"]) < 2:
+ counts["single_change"] += 1
+ continue
+ if counts["created"] >= NEW_RUNS_PER_TICK:
+ break
+ run_id = run_id_for(row["target_id"], row["challenger_strategy_id"])
+ try:
+ with db.session() as session:
+ session.add(
+ ArcAblationRun(
+ id=run_id,
+ outcome_id=row["outcome_id"],
+ target_id=row["target_id"],
+ parent_strategy_id=row["parent_strategy_id"],
+ challenger_strategy_id=str(row["challenger_strategy_id"]),
+ status="pending",
+ next_check_at=_utc(now),
+ )
+ )
+ session.flush()
+ except IntegrityError:
+ # A concurrent scheduler inserted this pair first; the unique pair wins.
+ existing.add(pair)
+ counts["already_scheduled"] += 1
+ continue
+ existing.add(pair)
+ counts["created"] += 1
+ return counts
+
+
+def _finish(
+ db: Database,
+ run_id: str,
+ status: str,
+ reason: str,
+ *,
+ message: str = "",
+ now: datetime | None = None,
+ **extra: Any,
+) -> None:
+ values: dict[str, Any] = {
+ "status": status,
+ "reason": reason[:64],
+ "message": message[:2000],
+ "next_check_at": None,
+ }
+ if now is not None:
+ values["finished_at"] = now
+ values.update(extra)
+ _write_run(db, run_id, **values)
+
+
+def _skipped(
+ db: Database, run_id: str, reason: str, message: str, now: datetime
+) -> None:
+ _finish(db, run_id, "skipped", reason, message=message, now=now)
+
+
+def _ensure_steps(db: Database, run_id: str, changes: list[dict[str, Any]]) -> None:
+ entries: list[dict[str, Any] | None] = [None, *changes]
+ for ordinal, change in enumerate(entries):
+ key = step_key_for(change)
+ step_id = step_id_for(run_id, key)
+ try:
+ with db.session() as session:
+ if session.get(ArcAblationStep, step_id) is not None:
+ continue
+ session.add(
+ ArcAblationStep(
+ id=step_id,
+ run_id=run_id,
+ step_key=key,
+ ordinal=ordinal,
+ kind="baseline" if change is None else "single",
+ change_json=change or {},
+ status="pending",
+ )
+ )
+ session.flush()
+ except IntegrityError:
+ continue
+
+
+def _prepare(
+ db: Database, run: ArcAblationRun, config: Any, client: Any, now: datetime
+) -> None:
+ moment = _utc(now)
+ assert moment is not None
+ run_id = run.id
+ created_at = _utc(run.created_at)
+ with db.session() as session:
+ outcome = session.get(ArcForwardOutcome, run.outcome_id)
+ if outcome is None:
+ _finish(
+ db,
+ run_id,
+ "failed",
+ "outcome_missing",
+ message="forward outcome missing",
+ now=moment,
+ )
+ return
+ mission_id = outcome.mission_id
+ attempt_id = outcome.attempt_id
+ candidate_id = outcome.candidate_id
+ verdict = outcome.verdict
+ mission = session.get(ArcMission, mission_id) if mission_id else None
+ if mission is None:
+ _finish(
+ db,
+ run_id,
+ "failed",
+ "lineage_unavailable",
+ message="mission row missing for the recorded outcome",
+ now=moment,
+ )
+ return
+ try:
+ projection = ARCMissionProjection.model_validate(
+ {**dict(mission.projection_json or {}), "events": []}
+ )
+ except Exception as exc:
+ _finish(
+ db,
+ run_id,
+ "failed",
+ "lineage_unavailable",
+ message=f"mission projection unreadable: {failure_text(exc)[:300]}",
+ now=moment,
+ )
+ return
+ goal = projection.goal
+ context = goal.evolution_context if goal is not None else None
+ baseline = context.get("baseline") if isinstance(context, dict) else None
+ candidate = next(
+ (
+ attempt
+ for attempt in projection.attempts
+ if attempt_id is not None and attempt.attempt_id == attempt_id
+ ),
+ None,
+ )
+ if candidate is None:
+ candidate = next(
+ (
+ attempt
+ for attempt in projection.attempts
+ if candidate_id is not None and attempt.candidate_id == candidate_id
+ ),
+ None,
+ )
+ if (
+ goal is None
+ or not isinstance(baseline, dict)
+ or not isinstance(baseline.get("strategy_spec"), dict)
+ or not isinstance(baseline.get("strategy_code"), str)
+ ):
+ _finish(
+ db,
+ run_id,
+ "failed",
+ "lineage_unavailable",
+ message="mission goal, baseline or candidate is missing",
+ now=moment,
+ )
+ return
+ if candidate is None:
+ _finish(
+ db,
+ run_id,
+ "failed",
+ "lineage_unavailable",
+ message="candidate attempt is missing from the mission projection",
+ now=moment,
+ )
+ return
+ spec = dict(candidate.strategy_spec or {})
+
+ # The candidate must still be the same source-bound variant the race judged.
+ if spec.get("is_source_variant") is not True:
+ _skipped(db, run_id, "not_source_variant", "candidate is not a source variant", moment)
+ return
+ missing: list[str] = []
+ parent = spec.get("parent_strategy_id")
+ if parent is None or str(parent) != str(run.parent_strategy_id):
+ missing.append("parent_strategy_id")
+ manifest = spec.get("parent_manifest_sha256")
+ execution_identity = spec.get("parent_execution_identity_sha256")
+ if not _is_sha256(manifest):
+ missing.append("parent_manifest_sha256")
+ if not _is_sha256(execution_identity):
+ missing.append("parent_execution_identity_sha256")
+ timeframe = spec.get("timeframe")
+ if not isinstance(timeframe, str) or not timeframe:
+ missing.append("timeframe")
+ symbols = _spec_symbols(spec)
+ if not symbols:
+ missing.append("symbol")
+ if not goal.paper_initial_equity > 0:
+ missing.append("paper_initial_equity")
+ if missing:
+ _skipped(
+ db,
+ run_id,
+ "lineage_incomplete",
+ "candidate spec missing: " + ", ".join(missing),
+ moment,
+ )
+ return
+
+ changes = change_list(spec)
+ if len(changes) < 2:
+ _skipped(db, run_id, "single_change", "candidate carries fewer than two changes", moment)
+ return
+ required = len(changes) + 1
+ if required > config.ablation_max_backtests_per_loser:
+ _skipped(
+ db,
+ run_id,
+ "too_many_changes",
+ f"需要 {required} 次回测,超过每个输家上限 "
+ f"{config.ablation_max_backtests_per_loser}",
+ moment,
+ )
+ return
+
+ try:
+ parent_id = int(str(parent))
+ except (TypeError, ValueError):
+ _skipped(db, run_id, "lineage_incomplete", "parent_strategy_id is not numeric", moment)
+ return
+ try:
+ policy = client.strategy_research_variant_policy(strategy_id=parent_id)
+ except Exception as exc:
+ if created_at is not None and moment - created_at > HOLD_LIMIT:
+ _finish(
+ db,
+ run_id,
+ "failed",
+ "parent_policy_unavailable",
+ message=failure_text(exc)[:500],
+ now=moment,
+ )
+ else:
+ _write_run(
+ db,
+ run_id,
+ reason="parent_policy_unavailable",
+ message=failure_text(exc)[:500],
+ next_check_at=moment + timedelta(hours=1),
+ )
+ return
+ if policy.get("variant_creation_supported") is not True:
+ _skipped(
+ db,
+ run_id,
+ "parent_variant_creation_unsupported",
+ "parent does not support source variants",
+ moment,
+ )
+ return
+ if (
+ policy.get("parent_manifest_sha256") != manifest
+ or policy.get("parent_execution_identity_sha256") != execution_identity
+ ):
+ # The parent was redeployed after the race; the parent+change experiment moved.
+ _skipped(
+ db,
+ run_id,
+ "parent_changed_since_race",
+ "parent manifest or execution identity changed since the race",
+ moment,
+ )
+ return
+ relay = policy.get("relay")
+ if isinstance(relay, dict) and relay.get("blocks_evolution") is True:
+ reason = str(relay.get("reason") or "parent relay seat is held")
+ if created_at is not None and moment - created_at > HOLD_LIMIT:
+ _finish(
+ db,
+ run_id,
+ "failed",
+ "parent_relay_hold_timeout",
+ message=reason,
+ now=moment,
+ )
+ else:
+ _write_run(
+ db,
+ run_id,
+ reason="parent_relay_hold",
+ message=reason[:500],
+ next_check_at=moment + timedelta(hours=1),
+ )
+ return
+
+ as_of = moment.date() - timedelta(days=1)
+ windows = ResearchWindowsV1(as_of=as_of)
+ development = windows.window("development")
+ capital = str(goal.paper_initial_equity)
+ research_id = "abl_" + hashlib.sha256(
+ json.dumps(
+ [
+ run.target_id,
+ parent,
+ manifest,
+ execution_identity,
+ str(development[0]),
+ str(development[1]),
+ capital,
+ symbols,
+ timeframe,
+ ],
+ separators=(",", ":"),
+ ).encode()
+ ).hexdigest()[:24]
+ window_json = {
+ "windows": windows.model_dump(mode="json"),
+ "development": [str(development[0]), str(development[1])],
+ }
+ plan_json = {
+ "parent_strategy_id": parent,
+ "parent_manifest_sha256": manifest,
+ "parent_execution_identity_sha256": execution_identity,
+ "symbols": symbols,
+ "timeframe": timeframe,
+ "capital": capital,
+ "changes": changes,
+ "source_mission_id": mission_id,
+ "source_attempt_id": attempt_id,
+ "source_candidate_id": candidate_id,
+ "verdict": verdict,
+ "baseline": baseline,
+ "success_criteria": goal.success_criteria.model_dump(mode="json"),
+ }
+ _write_run(
+ db,
+ run_id,
+ window_json=window_json,
+ plan_json=plan_json,
+ research_id=research_id,
+ )
+ _ensure_steps(db, run_id, changes)
+ _write_run(
+ db,
+ run_id,
+ status="running",
+ started_at=moment,
+ reason="",
+ message="",
+ next_check_at=moment,
+ )
+
+
+def _goal_for(run: ArcAblationRun, plan: dict[str, Any]) -> ARCGoalV1:
+ windows = ResearchWindowsV1.model_validate((run.window_json or {})["windows"])
+ return ARCGoalV1(
+ objective="ablation: development-window single-change backtests of a forward loser",
+ research_mode="avo",
+ paper_review_required=True,
+ research_id=run.research_id,
+ research_windows=windows,
+ symbols=list(plan["symbols"]),
+ timeframes=[plan["timeframe"]],
+ paper_initial_equity=Decimal(str(plan["capital"])),
+ success_criteria=ARCSuccessCriteriaV1.model_validate(plan["success_criteria"]),
+ )
+
+
+def _attempt_for(
+ run: ArcAblationRun, plan: dict[str, Any], step: ArcAblationStep
+) -> ARCCandidateAttemptV1:
+ baseline = plan["baseline"]
+ base_spec = dict(baseline.get("strategy_spec") or {})
+ identity = {
+ "is_source_variant": True,
+ "parent_strategy_id": plan["parent_strategy_id"],
+ "parent_manifest_sha256": plan["parent_manifest_sha256"],
+ "parent_execution_identity_sha256": plan["parent_execution_identity_sha256"],
+ }
+ change = dict(step.change_json or {})
+ if step.kind == "baseline":
+ spec = {
+ **base_spec,
+ **identity,
+ "parameter_changes": {},
+ "structural_changes": {},
+ }
+ attempt_id = candidate_id = f"baseline_{run.id}"
+ hypothesis = "ablation baseline: unchanged parent"
+ else:
+ parameters: dict[str, Any] = {}
+ structural: dict[str, Any] = {}
+ if change.get("kind") == "parameter":
+ parameters = {str(change.get("key")): change.get("value")}
+ else:
+ structural = {str(change.get("key")): change.get("value")}
+ spec = {
+ **base_spec,
+ **identity,
+ "parameter_changes": parameters,
+ "structural_changes": structural,
+ "tunable_parameters": {
+ **(base_spec.get("tunable_parameters") or {}),
+ **parameters,
+ },
+ "baseline_config": {**(base_spec.get("baseline_config") or {}), **parameters},
+ "variation_operator": "ablation",
+ }
+ attempt_id = candidate_id = f"abl_{run.id[4:20]}_{step.ordinal}"
+ hypothesis = f"ablation: {change_label(change)}"
+ return ARCCandidateAttemptV1(
+ attempt_id=attempt_id,
+ candidate_id=candidate_id,
+ hypothesis=hypothesis,
+ strategy_code=baseline["strategy_code"],
+ strategy_spec=spec,
+ )
+
+
+def _summary(metrics: dict[str, Any]) -> dict[str, Any]:
+ drawdown = _fraction(
+ metrics, fractions=("max_drawdown",), percentages=("max_drawdown_pct",)
+ )
+ return {
+ "net_return": _fraction(
+ metrics,
+ fractions=("net_return", "total_return"),
+ percentages=("total_return_pct", "return_pct"),
+ ),
+ "max_drawdown": None if drawdown is None else abs(drawdown),
+ "trade_count": _number(metrics, "trade_count", "trades"),
+ "sharpe": _number(metrics, "sharpe", "sharpe_ratio"),
+ }
+
+
+def _bounded_metrics(metrics: dict[str, Any]) -> dict[str, Any]:
+ if len(json.dumps(metrics, default=str, ensure_ascii=False)) <= 20_000:
+ return metrics
+ kept: dict[str, Any] = {
+ key: value
+ for key, value in metrics.items()
+ if isinstance(value, (int, float)) and not isinstance(value, bool)
+ }
+ for key in (
+ "comparison_evidence",
+ "evaluation_window",
+ "cost_policy_hash",
+ "config_sha256",
+ ):
+ if key in metrics:
+ kept[key] = metrics[key]
+ return kept
+
+
+def _record_submitted(
+ db: Database, step_id: str, job: dict[str, Any], now: datetime
+) -> None:
+ """Charge once, in the same transaction that persists the frozen job identity.
+
+ Replaying the same idempotency key returns the same job, so a crash before this
+ write can be retried without creating a second experiment or a second charge.
+ """
+ with db.session() as session:
+ step = session.get(ArcAblationStep, step_id)
+ if step is None:
+ raise ValueError("ablation step missing")
+ checkpoint = dict(step.checkpoint_json or {})
+ checkpoint.update(
+ job_id=job["job_id"],
+ input_snapshot_hash=job["input_snapshot_hash"],
+ phase="submitted",
+ charged_at=now.isoformat(),
+ )
+ step.checkpoint_json = checkpoint
+ step.charged_at = now
+
+
+class AblationBacktestRunner:
+ """Resumable backtest driver whose checkpoint lives on ``ArcAblationStep``."""
+
+ def __init__(
+ self,
+ db: Database,
+ step_id: str,
+ *,
+ budget_ok: Callable[[], bool],
+ now: Callable[[], datetime],
+ ) -> None:
+ self.db = db
+ self.step_id = step_id
+ self.budget_ok = budget_ok
+ self.now = now
+
+ def _moment(self) -> datetime:
+ moment = _utc(self.now())
+ assert moment is not None
+ return moment
+
+ def _load(self) -> dict[str, Any]:
+ with self.db.session() as session:
+ step = session.get(ArcAblationStep, self.step_id)
+ if step is None:
+ raise ValueError("ablation step missing")
+ return dict(step.checkpoint_json or {})
+
+ def _save(self, **changes: Any) -> None:
+ with self.db.session() as session:
+ step = session.get(ArcAblationStep, self.step_id)
+ if step is None:
+ raise ValueError("ablation step missing")
+ checkpoint = dict(step.checkpoint_json or {})
+ checkpoint.update(changes)
+ step.checkpoint_json = checkpoint
+ if isinstance(changes.get("request_hash"), str):
+ step.request_hash = changes["request_hash"]
+
+ def defer_prerequisite(self, exc: Exception) -> None:
+ """Bound transient preflight retries; the step is retried, never re-charged."""
+ from hypertrade.bitpro.mcp import BitProMcpError
+
+ if not isinstance(exc, BitProMcpError) or exc.status_code not in {
+ None,
+ 408,
+ 429,
+ 502,
+ 503,
+ 504,
+ }:
+ return
+ failures = self._load().get("preflight_failures", 0) + 1
+ message = failure_text(exc)
+ if failures >= MAX_RUNTIME_ERRORS:
+ self._save(phase="blocked", preflight_failures=failures, last_error=message)
+ raise ValueError("ablation prerequisite recovery limit reached") from exc
+ self._save(
+ preflight_failures=failures,
+ last_error=message,
+ next_check_at=(
+ self._moment() + timedelta(seconds=min(300, 60 * failures))
+ ).isoformat(),
+ )
+ raise BacktestDeferred(
+ "ablation prerequisite temporarily unavailable", transient_error=True
+ ) from exc
+
+ def run(self, client: Any, **parameters: Any) -> dict[str, Any]:
+ request = {
+ key: value
+ for key, value in parameters.items()
+ if key not in {"wait_for_result", "timeout_sec", "poll_interval_sec"}
+ }
+ request_key = request.get("idempotency_key")
+ if not isinstance(request_key, str) or not request_key:
+ raise ValueError("durable backtest request key required")
+ request_hash = backtest_digest(request)
+ stored = self._load()
+ if stored.get("request_hash") and stored["request_hash"] != request_hash:
+ raise ValueError("ablation backtest request changed")
+ entry = dict(stored)
+ entry.setdefault("read_failures", 0)
+ entry.setdefault("resume_attempts", 0)
+
+ def save(**changes: Any) -> None:
+ entry.update(changes)
+ self._save(**changes)
+
+ def failed_read(exc: Exception) -> NoReturn:
+ status = getattr(exc, "status_code", None)
+ message = f"{type(exc).__name__}: {failure_text(exc)}"
+ if isinstance(exc, (ValueError, PermissionError, KeyError)) or (
+ status is not None and 400 <= status < 500 and status not in (408, 429)
+ ):
+ save(phase="blocked", last_error=message)
+ raise ValueError("ablation backtest recovery rejected: " + message) from exc
+ failures = entry.get("read_failures", 0) + 1
+ if failures >= MAX_RUNTIME_ERRORS:
+ save(phase="blocked", read_failures=failures, last_error=message)
+ raise ValueError("backtest read recovery limit reached") from exc
+ save(
+ read_failures=failures,
+ last_error=message,
+ next_check_at=(
+ self._moment() + timedelta(seconds=min(300, 60 * failures))
+ ).isoformat(),
+ )
+ raise BacktestDeferred(message, transient_error=True) from exc
+
+ if not entry.get("job_id"):
+ reused = _find_reusable(self.db, self.step_id, request_hash)
+ if reused is not None:
+ save(
+ request=request,
+ request_hash=request_hash,
+ job_id=reused["job_id"],
+ input_snapshot_hash=reused["input_snapshot_hash"],
+ phase="submitted",
+ reused_from=reused["id"],
+ created_at=entry.get("created_at")
+ or reused.get("created_at")
+ or self._moment().isoformat(),
+ )
+ else:
+ if not self.budget_ok():
+ raise AblationBudgetDeferred("daily ablation backtest budget exhausted")
+ save(
+ request=request,
+ request_hash=request_hash,
+ phase="planned",
+ created_at=self._moment().isoformat(),
+ read_failures=0,
+ resume_attempts=0,
+ )
+ try:
+ created = client.backtest_start_job(
+ **request, wait_for_result=False
+ )
+ except Exception as exc:
+ failed_read(exc)
+ job = created.get("job") or {}
+ if not job.get("job_id") or not job.get("input_snapshot_hash"):
+ save(phase="blocked")
+ raise ValueError(
+ "backtest creation receipt missing frozen job identity"
+ )
+ _record_submitted(self.db, self.step_id, job, self._moment())
+ entry.update(
+ job_id=job["job_id"],
+ input_snapshot_hash=job["input_snapshot_hash"],
+ phase="submitted",
+ charged_at=self._moment().isoformat(),
+ )
+
+ try:
+ response = client.backtest_get_job(job_id=entry["job_id"])
+ except Exception as exc:
+ failed_read(exc)
+ job = response.get("job") or {}
+ if (
+ job.get("job_id") != entry["job_id"]
+ or job.get("input_snapshot_hash") != entry["input_snapshot_hash"]
+ or str(job.get("strategy_id")) != str(request["strategy_id"])
+ ):
+ save(phase="blocked")
+ raise ValueError("backtest frozen job identity mismatch")
+ status = job.get("status")
+ if status == "interrupted":
+ attempts = int(entry.get("resume_attempts", 0))
+ if attempts >= 2:
+ save(phase="blocked")
+ raise ValueError("backtest resume limit reached")
+ save(phase="resume_requested", resume_attempts=attempts + 1)
+ try:
+ client.backtest_resume_job(
+ job_id=entry["job_id"],
+ idempotency_key=request_key + f"-resume-{attempts + 1}",
+ )
+ except Exception as exc:
+ if getattr(
+ exc, "status_code", None
+ ) == 409 and "backtest_source_or_deployment_changed" in str(exc):
+ save(phase="blocked")
+ raise ValueError("ablation backtest execution version changed") from exc
+ failed_read(exc)
+ save(
+ phase="waiting",
+ next_check_at=(self._moment() + timedelta(seconds=60)).isoformat(),
+ )
+ raise BacktestDeferred("backtest resumed")
+ if status in PENDING_BACKTEST_STATES:
+ started = entry.get("charged_at") or entry.get("created_at")
+ started_at = (
+ _utc(datetime.fromisoformat(started.replace("Z", "+00:00")))
+ if isinstance(started, str)
+ else None
+ )
+ if started_at is not None and self._moment() - started_at > JOB_WAIT_LIMIT:
+ save(phase="blocked")
+ raise ValueError("backtest wait limit reached")
+ save(
+ phase="waiting",
+ next_check_at=(self._moment() + timedelta(seconds=60)).isoformat(),
+ )
+ raise BacktestDeferred(f"backtest {status}")
+ if status not in {"completed", "failed", "cancelled"}:
+ save(phase="blocked")
+ raise ValueError("unknown backtest state")
+ save(phase="terminal", status=status, next_check_at=None, read_failures=0)
+ return dict(response)
+
+
+def _execute_step(
+ db: Database,
+ run: ArcAblationRun,
+ step: ArcAblationStep,
+ client: Any,
+ config: Any,
+ now: datetime,
+) -> None:
+ moment = _utc(now)
+ assert moment is not None
+ limit = config.ablation_max_backtests_per_day
+ checkpoint = dict(step.checkpoint_json or {})
+ if not checkpoint.get("job_id"):
+ request_hash = checkpoint.get("request_hash")
+ reusable = _find_reusable(
+ db, step.id, request_hash if isinstance(request_hash, str) else None
+ )
+ if reusable is None and _daily_charged(db, moment) >= limit:
+ _write_run(
+ db,
+ run.id,
+ reason="daily_budget_exhausted",
+ next_check_at=next_utc_midnight(moment),
+ )
+ return
+ _write_step(db, step.id, status="running")
+
+ data_reference: dict[str, str] | None = None
+ if step.kind != "baseline":
+ base = _baseline_step(db, run.id)
+ reference = (base.result_json or {}).get("data_reference") if base else None
+ if (
+ not isinstance(reference, dict)
+ or not reference.get("verified_data_snapshot_id")
+ or not reference.get("verified_data_manifest_sha256")
+ ):
+ _write_step(
+ db, step.id, status="failed", reason="baseline_data_reference_unavailable"
+ )
+ _write_run(db, run.id, reason="", message="", next_check_at=moment)
+ return
+ data_reference = {
+ str(key): str(value) for key, value in reference.items()
+ }
+
+ plan = dict(run.plan_json or {})
+ service = ARCSelfTestService(
+ client,
+ backtest_runner=AblationBacktestRunner(
+ db,
+ step.id,
+ budget_ok=lambda: _daily_charged(db, moment) < limit,
+ now=lambda: moment,
+ ),
+ )
+ try:
+ result = service.run(
+ _attempt_for(run, plan, step),
+ _goal_for(run, plan),
+ purpose="development",
+ data_reference=data_reference,
+ )
+ except AblationBudgetDeferred:
+ _write_step(db, step.id, status="pending")
+ _write_run(
+ db,
+ run.id,
+ reason="daily_budget_exhausted",
+ next_check_at=next_utc_midnight(moment),
+ )
+ return
+ except BacktestDeferred:
+ saved = _step_checkpoint(db, step.id)
+ next_check = saved.get("next_check_at")
+ try:
+ next_at = _utc(datetime.fromisoformat(str(next_check).replace("Z", "+00:00")))
+ except (TypeError, ValueError):
+ next_at = None
+ _write_step(db, step.id, status="running")
+ _write_run(
+ db,
+ run.id,
+ reason="backtest_pending",
+ next_check_at=next_at or (moment + timedelta(seconds=60)),
+ )
+ return
+
+ if result.terminal_failure or not result.backtest_id:
+ reason = (result.reasons[0] if result.reasons else "backtest_failed")[:128]
+ _write_step(
+ db,
+ step.id,
+ status="failed",
+ reason=reason,
+ result_json={
+ "reasons": list(result.reasons[:10]),
+ "message": result.message[:500],
+ "terminal_failure": result.terminal_failure,
+ "bitpro_strategy_id": result.bitpro_strategy_id,
+ },
+ )
+ _write_run(db, run.id, reason="", message="", next_check_at=moment)
+ return
+
+ metrics = dict(result.metrics or {})
+ result_json: dict[str, Any] = {
+ "backtest_id": result.backtest_id,
+ "bitpro_strategy_id": result.bitpro_strategy_id,
+ "metrics": _bounded_metrics(metrics),
+ "summary": _summary(metrics),
+ "criteria_reasons": list(result.reasons[:10]),
+ }
+ if step.kind == "baseline":
+ evidence = metrics.get("comparison_evidence")
+ manifests = (
+ evidence.get("data_manifests") if isinstance(evidence, dict) else None
+ )
+ digest = manifests[0] if isinstance(manifests, list) and len(manifests) == 1 else None
+ if not isinstance(digest, str) or not _is_sha256(digest):
+ _write_step(
+ db,
+ step.id,
+ status="failed",
+ reason="baseline_data_snapshot_unavailable",
+ result_json=result_json,
+ )
+ _write_run(db, run.id, reason="", message="", next_check_at=moment)
+ return
+ result_json["data_reference"] = {
+ "verified_data_snapshot_id": "vbs_" + digest,
+ "verified_data_manifest_sha256": digest,
+ }
+ _write_step(db, step.id, status="completed", reason="", result_json=result_json)
+ _write_run(db, run.id, reason="", message="", next_check_at=moment)
+
+
+def _result_skeleton(run: ArcAblationRun, plan: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "schema_version": "ablation_result.v1",
+ "outcome_id": run.outcome_id,
+ "verdict": plan.get("verdict"),
+ "parent_strategy_id": run.parent_strategy_id,
+ "challenger_strategy_id": run.challenger_strategy_id,
+ "window": (run.window_json or {}).get("development"),
+ "capital": plan.get("capital"),
+ "baseline": {},
+ "effects": [],
+ "harmful": [],
+ "helpful": [],
+ "neutral": [],
+ "unknown": [],
+ "incomparable": [],
+ "scope": "development_window_single_factor",
+ "note": NOTE,
+ "causal_claim_verified": False,
+ }
+
+
+def _summarize(
+ run: ArcAblationRun, steps: list[ArcAblationStep]
+) -> tuple[str, str, dict[str, Any]]:
+ plan = dict(run.plan_json or {})
+ result = _result_skeleton(run, plan)
+ base = next((step for step in steps if step.kind == "baseline"), None)
+ if base is None or base.status == "failed":
+ reason = (base.reason if base is not None and base.reason else "baseline_backtest_failed")
+ result["baseline"] = {
+ "status": base.status if base is not None else "missing",
+ "reason": reason,
+ "backtest_id": (base.result_json or {}).get("backtest_id") if base else None,
+ }
+ return "failed", reason[:64], result
+
+ base_result = dict(base.result_json or {})
+ base_metrics = dict(base_result.get("metrics") or {})
+ base_summary = dict(base_result.get("summary") or {})
+ result["baseline"] = {
+ "backtest_id": base_result.get("backtest_id"),
+ **base_summary,
+ }
+ ordinal = {step.step_key: step.ordinal for step in steps}
+ effects: list[dict[str, Any]] = []
+ for step in steps:
+ if step.kind != "single":
+ continue
+ change = dict(step.change_json or {})
+ effect: dict[str, Any] = {
+ "step_key": step.step_key,
+ "change": change,
+ "label": change_label(change),
+ "status": step.status,
+ "backtest_id": (step.result_json or {}).get("backtest_id"),
+ "reason": step.reason or "",
+ "delta_net_return": None,
+ "delta_max_drawdown": None,
+ "delta_trade_count": None,
+ "class": "unknown",
+ }
+ if step.status == "failed":
+ effect["reason"] = step.reason or "backtest_failed"
+ effects.append(effect)
+ continue
+ if step.status != "completed":
+ effect["reason"] = "step_not_terminal"
+ effects.append(effect)
+ continue
+ step_result = dict(step.result_json or {})
+ summary = dict(step_result.get("summary") or {})
+ if (
+ summary.get("net_return") is None
+ or summary.get("max_drawdown") is None
+ or base_summary.get("net_return") is None
+ or base_summary.get("max_drawdown") is None
+ ):
+ effect["reason"] = "metric_missing"
+ effects.append(effect)
+ continue
+ comparison = compare_backtests(
+ dict(step_result.get("metrics") or {}),
+ {
+ "metrics": base_metrics,
+ "backtest_id": base_result.get("backtest_id"),
+ },
+ )
+ if "reason" in comparison:
+ effect["class"] = "incomparable"
+ effect["reason"] = str(comparison["reason"])
+ effects.append(effect)
+ continue
+ delta_return = float(summary["net_return"]) - float(base_summary["net_return"])
+ delta_drawdown = float(summary["max_drawdown"]) - float(base_summary["max_drawdown"])
+ effect["delta_net_return"] = delta_return
+ effect["delta_max_drawdown"] = delta_drawdown
+ if summary.get("trade_count") is not None and base_summary.get("trade_count") is not None:
+ effect["delta_trade_count"] = float(summary["trade_count"]) - float(
+ base_summary["trade_count"]
+ )
+ if delta_return >= HELPFUL_RETURN and delta_drawdown <= HELPFUL_DRAWDOWN_SLACK:
+ effect["class"] = "helpful"
+ elif delta_return <= HARMFUL_RETURN or delta_drawdown >= HARMFUL_DRAWDOWN:
+ effect["class"] = "harmful"
+ else:
+ effect["class"] = "neutral"
+ effects.append(effect)
+
+ def order(effect: dict[str, Any]) -> tuple[int, float, int]:
+ delta = effect["delta_net_return"]
+ step_ordinal = ordinal.get(effect["step_key"], 0)
+ if delta is None:
+ return (1, 0.0, step_ordinal)
+ return (0, float(delta), step_ordinal)
+
+ effects.sort(key=order)
+ result["effects"] = effects
+ for label in ("harmful", "helpful", "neutral", "unknown", "incomparable"):
+ result[label] = [effect["label"] for effect in effects if effect["class"] == label]
+ if any(effect["class"] in {"helpful", "harmful", "neutral"} for effect in effects):
+ return "completed", "", result
+ return "failed", "all_single_changes_failed", result
+
+
+def _advance(
+ db: Database, run: ArcAblationRun, config: Any, client: Any, now: datetime
+) -> None:
+ executed = 0
+ for step in _step_rows(db, run.id):
+ if step.status in {"completed", "failed"}:
+ continue
+ if step.kind != "baseline":
+ base = _baseline_step(db, run.id)
+ if base is not None and base.status == "failed":
+ break
+ _execute_step(db, run, step, client, config, now)
+ fresh = _get_step(db, step.id)
+ if fresh is None or fresh.status not in {"completed", "failed"}:
+ return
+ executed += 1
+ if fresh.kind == "baseline" and fresh.status == "failed":
+ break
+ if executed >= STEPS_PER_TICK:
+ return
+ steps = _step_rows(db, run.id)
+ base = next((step for step in steps if step.kind == "baseline"), None)
+ if base is not None and base.status == "failed":
+ status, reason, result = _summarize(run, steps)
+ _finish(db, run.id, status, reason, now=now, result_json=result)
+ return
+ if steps and all(step.status in {"completed", "failed"} for step in steps):
+ status, reason, result = _summarize(run, steps)
+ _finish(db, run.id, status, reason, now=now, result_json=result)
+
+
+def _record_runtime_failure(
+ db: Database, run: ArcAblationRun, exc: Exception, now: datetime
+) -> None:
+ record_runtime_error(
+ "evolution.ablation", exc, context={"run_id": run.id}, db=db
+ )
+ current = _get_run(db, run.id)
+ result = dict(current.result_json or {}) if current is not None else {}
+ errors = int(result.get("runtime_errors") or 0) + 1
+ result["runtime_errors"] = errors
+ values: dict[str, Any] = {
+ "reason": "runtime_error",
+ "message": failure_text(exc)[:500],
+ "result_json": result,
+ "next_check_at": now + timedelta(minutes=10),
+ }
+ if errors >= MAX_RUNTIME_ERRORS:
+ values.update(status="failed", finished_at=now, next_check_at=None)
+ _write_run(db, run.id, **values)
+
+
+def _due_run(db: Database, now: datetime) -> ArcAblationRun | None:
+ moment = _utc(now)
+ assert moment is not None
+ with db.session() as session:
+ return session.scalars(
+ select(ArcAblationRun)
+ .where(
+ ArcAblationRun.status.in_(("pending", "running")),
+ (ArcAblationRun.next_check_at.is_(None))
+ | (ArcAblationRun.next_check_at <= moment),
+ )
+ .order_by(ArcAblationRun.created_at.asc())
+ .limit(1)
+ ).one_or_none()
+
+
+def ablation_budget_view(db: Database, config: Any, now: datetime) -> dict[str, Any]:
+ """Read-only ledger view for budget status and the effectiveness report.
+
+ Counts are observations over persisted rows, not causal claims. It opens its own short
+ session and never writes, so the caller may degrade to an "unavailable" section.
+ """
+ moment = _utc(now)
+ assert moment is not None
+ start = moment.replace(hour=0, minute=0, second=0, microsecond=0)
+ end = start + timedelta(days=1)
+ with db.session() as session:
+ period_used = int(
+ session.scalar(
+ select(func.count())
+ .select_from(ArcAblationStep)
+ .where(
+ ArcAblationStep.charged_at.is_not(None),
+ ArcAblationStep.charged_at >= start,
+ ArcAblationStep.charged_at < end,
+ )
+ )
+ or 0
+ )
+ runs = {
+ str(status): int(count)
+ for status, count in session.execute(
+ select(ArcAblationRun.status, func.count()).group_by(ArcAblationRun.status)
+ ).all()
+ }
+ reasons = {
+ str(reason): int(count)
+ for reason, count in session.execute(
+ select(ArcAblationRun.reason, func.count())
+ .where(ArcAblationRun.reason.is_not(None), ArcAblationRun.reason != "")
+ .group_by(ArcAblationRun.reason)
+ ).all()
+ }
+ scheduled = {
+ (target_id, str(challenger))
+ for target_id, challenger in session.execute(
+ select(ArcAblationRun.target_id, ArcAblationRun.challenger_strategy_id)
+ ).all()
+ }
+ next_at = session.scalar(
+ select(func.min(ArcAblationRun.next_check_at)).where(
+ ArcAblationRun.status.in_(("pending", "running"))
+ )
+ )
+ losers = list(
+ session.scalars(
+ select(ArcForwardOutcome)
+ .where(
+ ArcForwardOutcome.target_id == config.target_id,
+ ArcForwardOutcome.verdict.in_(LOSER_VERDICTS),
+ )
+ .order_by(
+ case((ArcForwardOutcome.ended_at.is_(None), 1), else_=0),
+ ArcForwardOutcome.ended_at.desc(),
+ ArcForwardOutcome.created_at.desc(),
+ )
+ .limit(500)
+ )
+ )
+ loser_counts = {"eligible": 0, "single_change": 0, "unlinked": 0, "scheduled": 0}
+ for row in losers:
+ if (row.target_id, str(row.challenger_strategy_id)) in scheduled:
+ loser_counts["scheduled"] += 1
+ if row.lineage_status != "linked":
+ loser_counts["unlinked"] += 1
+ elif change_count(row.changes_json) >= 2:
+ loser_counts["eligible"] += 1
+ else:
+ loser_counts["single_change"] += 1
+ enabled = bool(
+ config.enabled and config.ablation_enabled and config.target_id == "bitpro"
+ )
+ reason: str | None = None
+ if not enabled:
+ reason = "disabled"
+ elif period_used >= config.ablation_max_backtests_per_day:
+ reason = "period_budget_exhausted"
+ next_moment = _utc(next_at)
+ return {
+ "schema_version": "ablation_budget.v1",
+ "enabled": enabled,
+ "unit": "backtests",
+ "period": start.date().isoformat(),
+ "period_used": period_used,
+ "period_limit": config.ablation_max_backtests_per_day,
+ "per_loser_limit": config.ablation_max_backtests_per_loser,
+ "runs": runs,
+ "reasons": reasons,
+ "losers": loser_counts,
+ "next_run_at": next_moment.isoformat() if next_moment is not None else None,
+ "reason": reason,
+ "rule": "消融回测独立计账,不占用每日研究名额;只在开发窗口回测,不碰 Paper/实盘",
+ }
+
+
+def list_ablation_runs(db: Database, *, limit: int = 100) -> list[dict[str, Any]]:
+ """Operator view of ablation runs, newest first; read-only and without raw metrics."""
+ bounded = max(1, min(limit, 200))
+ with db.session() as session:
+ runs = list(
+ session.scalars(
+ select(ArcAblationRun)
+ .order_by(ArcAblationRun.updated_at.desc())
+ .limit(bounded)
+ )
+ )
+ run_ids = [run.id for run in runs]
+ steps: dict[str, list[ArcAblationStep]] = {}
+ if run_ids:
+ for step in session.scalars(
+ select(ArcAblationStep)
+ .where(ArcAblationStep.run_id.in_(run_ids))
+ .order_by(ArcAblationStep.ordinal.asc())
+ ):
+ steps.setdefault(step.run_id, []).append(step)
+
+ def iso(value: datetime | None) -> str | None:
+ moment = _utc(value)
+ return moment.isoformat() if moment is not None else None
+
+ rows: list[dict[str, Any]] = []
+ for run in runs:
+ result = dict(run.result_json) if isinstance(run.result_json, dict) else {}
+ window = dict(run.window_json) if isinstance(run.window_json, dict) else {}
+ rows.append(
+ {
+ "id": run.id,
+ "outcome_id": run.outcome_id,
+ "target_id": run.target_id,
+ "parent_strategy_id": run.parent_strategy_id,
+ "challenger_strategy_id": run.challenger_strategy_id,
+ "status": run.status,
+ "reason": run.reason,
+ "message": (run.message or "")[:500],
+ "window": window.get("development"),
+ "created_at": iso(run.created_at),
+ "updated_at": iso(run.updated_at),
+ "started_at": iso(run.started_at),
+ "finished_at": iso(run.finished_at),
+ "next_check_at": iso(run.next_check_at),
+ # Summary labels only: raw metrics, plan and checkpoints stay server-side.
+ "result": (
+ {
+ key: result.get(key)
+ for key in (
+ "harmful",
+ "helpful",
+ "neutral",
+ "unknown",
+ "incomparable",
+ "note",
+ )
+ }
+ if run.status in ("completed", "failed") and result
+ else None
+ ),
+ "steps": [
+ {
+ "step_key": step.step_key,
+ "kind": step.kind,
+ "status": step.status,
+ "reason": step.reason,
+ "charged_at": iso(step.charged_at),
+ "reused_from": (
+ dict(step.checkpoint_json).get("reused_from")
+ if isinstance(step.checkpoint_json, dict)
+ else None
+ ),
+ "backtest_id": (
+ dict(step.result_json).get("backtest_id")
+ if isinstance(step.result_json, dict)
+ else None
+ ),
+ }
+ for step in steps.get(run.id, [])
+ ],
+ }
+ )
+ return rows
+
+
+def _window_span(result: Any) -> tuple[date, date] | None:
+ window = result.get("window") if isinstance(result, dict) else None
+ if (
+ not isinstance(window, list)
+ or len(window) != 2
+ or not all(isinstance(value, str) for value in window)
+ ):
+ return None
+ try:
+ return date.fromisoformat(window[0]), date.fromisoformat(window[1])
+ except ValueError:
+ return None
+
+
+def _ablation_entry(run: ArcAblationRun, result: dict[str, Any]) -> dict[str, Any]:
+ baseline = result["baseline"]
+ return {
+ "memory_id": f"ablation:{run.id}",
+ "type": "ablation",
+ "evidence_status": "development_window_ablation",
+ "ablation_id": run.id,
+ "outcome_id": run.outcome_id,
+ "verdict": result.get("verdict"),
+ "parent_strategy_id": run.parent_strategy_id,
+ "challenger_strategy_id": run.challenger_strategy_id,
+ "window": list(result["window"]),
+ "capital": result.get("capital"),
+ "baseline": {
+ key: baseline.get(key)
+ for key in ("backtest_id", "net_return", "max_drawdown", "trade_count")
+ },
+ "effects": [
+ {
+ key: effect.get(key)
+ for key in (
+ "label",
+ "class",
+ "delta_net_return",
+ "delta_max_drawdown",
+ "delta_trade_count",
+ "backtest_id",
+ "reason",
+ )
+ }
+ for effect in result["effects"]
+ if isinstance(effect, dict)
+ ],
+ "harmful": list(result.get("harmful") or []),
+ "helpful": list(result.get("helpful") or []),
+ "scope": "development_window_single_factor",
+ "causal_claim_verified": False,
+ "rule": ABLATION_MEMORY_RULE,
+ }
+
+
+def ablation_memory(
+ db: Database,
+ *,
+ target_id: str,
+ outcome_ids: Sequence[str],
+ development_end: date,
+ limit: int = 4,
+) -> tuple[list[dict[str, Any]], dict[str, Any]]:
+ """Completed per-change ablations of the forward losers this research context already shows.
+
+ Only rows whose outcome the caller already displayed are projected; lineage and scope
+ filtering stay on the forward-outcome side, so this never widens what the model sees.
+ """
+ ordered = list(dict.fromkeys(outcome_ids))
+ entries: list[dict[str, Any]] = []
+ excluded: dict[str, int] = {}
+ if not ordered:
+ return entries, {
+ "policy": ABLATION_MEMORY_POLICY,
+ "selected": 0,
+ "excluded": excluded,
+ }
+
+ with db.session() as session:
+ runs = {
+ row.outcome_id: row
+ for row in session.scalars(
+ select(ArcAblationRun).where(
+ ArcAblationRun.target_id == target_id,
+ ArcAblationRun.outcome_id.in_(ordered),
+ )
+ )
+ }
+
+ def exclude(reason: str) -> None:
+ excluded[reason] = excluded.get(reason, 0) + 1
+
+ for outcome_id in ordered:
+ run = runs.get(outcome_id)
+ if run is None:
+ continue # Not ablated yet is not an exclusion.
+ if run.status != "completed":
+ exclude("not_completed")
+ continue
+ result = run.result_json
+ span = _window_span(result)
+ if (
+ not isinstance(result, dict)
+ or not isinstance(result.get("effects"), list)
+ or not isinstance(result.get("baseline"), dict)
+ or span is None
+ ):
+ exclude("malformed_result")
+ continue
+ if span[1] > development_end:
+ # Same leak guard as development memory: later observations stay out.
+ exclude("outside_development_horizon")
+ continue
+ if len(entries) >= limit:
+ exclude("context_budget")
+ continue
+ entry = _ablation_entry(run, result)
+ if (
+ len(json.dumps(entry, ensure_ascii=False, default=str))
+ > ABLATION_MEMORY_ENTRY_CHARS
+ ):
+ exclude("entry_too_large")
+ continue
+ entries.append(entry)
+
+ return entries, {
+ "policy": ABLATION_MEMORY_POLICY,
+ "selected": len(entries),
+ "excluded": excluded,
+ }
+
+
+def ablation_once(
+ db: Database, *, now: datetime | None = None, client: Any = None
+) -> dict[str, Any]:
+ """Advance at most one loser run; expected waits never surface as runtime errors."""
+ from hypertrade.arc.evolution import EvolutionConfig
+
+ moment = _utc(now) if now is not None else datetime.now(UTC)
+ assert moment is not None
+ with db.session() as session:
+ control = session.get(EvolutionControl, "global")
+ config = EvolutionConfig.model_validate(
+ dict(control.config_json or {}) if control is not None else {}
+ )
+ if (
+ not config.enabled
+ or not config.ablation_enabled
+ or config.target_id != "bitpro"
+ ):
+ return {"status": "disabled"}
+ with research_lock("evolution-ablation") as owner:
+ if owner is None:
+ return {"status": "busy"}
+ scheduled = schedule_runs(db, config, moment)
+ run = _due_run(db, moment)
+ if run is None:
+ return {"status": "idle", "scheduled": scheduled}
+ run_id = run.id
+ adapter = client or _default_client()
+ try:
+ if run.status == "pending":
+ _prepare(db, run, config, adapter, moment)
+ prepared = _get_run(db, run_id)
+ if prepared is not None and prepared.status == "running":
+ _advance(db, prepared, config, adapter, moment)
+ else:
+ _advance(db, run, config, adapter, moment)
+ except Exception as exc:
+ _record_runtime_failure(db, run, exc, moment)
+ fresh = _get_run(db, run_id)
+ return {
+ "status": "advanced",
+ "run_id": run_id,
+ "run_status": fresh.status if fresh is not None else "missing",
+ "reason": fresh.reason if fresh is not None else "",
+ "scheduled": scheduled,
+ }
+
+
+def _default_client() -> Any:
+ from hypertrade.bitpro.mcp import BitProToolAdapter
+
+ return BitProToolAdapter()
diff --git a/backend/src/hypertrade/arc/avo.py b/backend/src/hypertrade/arc/avo.py
index 36fd0f6..e0273a5 100644
--- a/backend/src/hypertrade/arc/avo.py
+++ b/backend/src/hypertrade/arc/avo.py
@@ -191,6 +191,13 @@
of this strategy without new evidence and a repeat_reason; exploring near a winning
direction is preferred. A single race is a noisy observation, not a causal conclusion,
and never replaces development evidence.
+Ablation entries (type=ablation) may follow them: for a lost variant shown above, its parent
+was re-tested on one development window with exactly one of that variant's changes applied at
+a time (memory_id ablation:...). Each effect is that single change against the parent on that
+window: harmful, helpful, neutral, unknown or incomparable. They are single-factor development
+observations without interactions, not causal conclusions. A narrower candidate that keeps the
+helpful changes and drops the harmful ones is a hypothesis worth developing, never a result;
+cite the ablation by memory_id.
source_variant_policy.relay.tried_variants lists the changes BitPro already raced, queued or
eliminated for this parent; a proposal identical to one of them is rejected because BitPro never
races the same changes twice, so propose a different direction. relay.slots shows how many of the
diff --git a/backend/src/hypertrade/arc/effectiveness.py b/backend/src/hypertrade/arc/effectiveness.py
index 76c6a59..b7633fe 100644
--- a/backend/src/hypertrade/arc/effectiveness.py
+++ b/backend/src/hypertrade/arc/effectiveness.py
@@ -16,9 +16,14 @@
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
-from sqlalchemy import select
+from sqlalchemy import func, select
-from hypertrade.arc.evolution_models import EvolutionCycle
+from hypertrade.arc.evolution_models import (
+ ArcAblationRun,
+ ArcAblationStep,
+ EvolutionCycle,
+)
+from hypertrade.arc.runtime_journal import failure_text
from hypertrade.arc.store import load_projection
from hypertrade.db import ArcMission, Database, StrategyOutcome
@@ -81,6 +86,10 @@ class EvolutionEffectivenessV1(BaseModel):
cost_candidates: int = 0
missions_admitted: int = 0
per_source: list[PerSourceEffectivenessV1] = Field(default_factory=list)
+ ablation_runs_by_status: dict[str, int] = Field(default_factory=dict)
+ ablation_reasons: dict[str, int] = Field(default_factory=dict)
+ ablation_backtests_charged: int = 0
+ ablation_error: str | None = None
causal_conclusion: str = "not_established"
note: str = (
"只统计进化循环发起的任务(evolution_context/feedback_parent);胜率是"
@@ -196,6 +205,37 @@ def build_effectiveness_report(
report.per_source = [per_source[k] for k in sorted(per_source)]
+ # Independent session: an unreadable ablation ledger degrades only its own counters
+ # and must never hide the rest of the report.
+ try:
+ with db.session() as session:
+ runs = {
+ str(status): int(count)
+ for status, count in session.execute(
+ select(ArcAblationRun.status, func.count()).group_by(
+ ArcAblationRun.status
+ )
+ ).all()
+ }
+ reasons = {
+ str(reason): int(count)
+ for reason, count in session.execute(
+ select(ArcAblationRun.reason, func.count())
+ .where(ArcAblationRun.reason.is_not(None), ArcAblationRun.reason != "")
+ .group_by(ArcAblationRun.reason)
+ ).all()
+ }
+ charged = session.scalar(
+ select(func.count())
+ .select_from(ArcAblationStep)
+ .where(ArcAblationStep.charged_at.is_not(None))
+ )
+ report.ablation_runs_by_status = runs
+ report.ablation_reasons = reasons
+ report.ablation_backtests_charged = int(charged or 0)
+ except Exception as exc:
+ report.ablation_error = failure_text(exc)[:200]
+
if report.baseline_comparisons:
win_rate = report.beat_baseline / report.baseline_comparisons
report.baseline_win_rate = f"{win_rate:.4f}"
diff --git a/backend/src/hypertrade/arc/evidence_view.py b/backend/src/hypertrade/arc/evidence_view.py
index a9908b5..a2ae78c 100644
--- a/backend/src/hypertrade/arc/evidence_view.py
+++ b/backend/src/hypertrade/arc/evidence_view.py
@@ -77,8 +77,8 @@ def build_evidence_view(projection: ARCMissionProjection) -> dict[str, Any]:
research["research"]["evolution"]["order_sample"] = {
k: v for k, v in context.get("orders", {}).items() if k != "fills"
}
- # Forward races are not development references: they carry no backtest receipt,
- # so mixing them in would read as development evidence without a backtest id.
+ # Forward races and per-change ablations are not development references: they carry
+ # no development receipt, so mixing them in would read as ARC development evidence.
research["research"]["evolution"]["memory_references"] = [
{
"mission_id": item.get("mission_id"),
@@ -86,7 +86,7 @@ def build_evidence_view(projection: ARCMissionProjection) -> dict[str, Any]:
"backtest_id": item.get("development", {}).get("backtest_id"),
}
for item in context.get("memory", [])
- if item.get("type") != "forward_outcome"
+ if item.get("type") not in ("forward_outcome", "ablation")
]
research["research"]["evolution"]["forward_outcome_references"] = [
{
@@ -104,6 +104,23 @@ def build_evidence_view(projection: ARCMissionProjection) -> dict[str, Any]:
for item in context.get("memory", [])
if item.get("type") == "forward_outcome"
]
+ research["research"]["evolution"]["ablation_references"] = [
+ {
+ key: item.get(key)
+ for key in (
+ "memory_id",
+ "outcome_id",
+ "verdict",
+ "parent_strategy_id",
+ "challenger_strategy_id",
+ "window",
+ "harmful",
+ "helpful",
+ )
+ }
+ for item in context.get("memory", [])
+ if item.get("type") == "ablation"
+ ]
if projection.goal and projection.goal.paper_review_required:
from hypertrade.arc.paper_review import build_paper_review
diff --git a/backend/src/hypertrade/arc/evolution.py b/backend/src/hypertrade/arc/evolution.py
index 7b47b8d..5a5a553 100644
--- a/backend/src/hypertrade/arc/evolution.py
+++ b/backend/src/hypertrade/arc/evolution.py
@@ -113,6 +113,11 @@ def reject_boolean_strategy_ids(cls, value: Any) -> Any:
max_backtests: int = Field(default=8, ge=3, le=30)
paper_capital: Decimal = Field(default=Decimal("100"), gt=0, le=10000)
auto_approve_paper: bool = False
+ # Per-change ablation of forward losers (lost_futility / lost_exam): development-window
+ # backtests only, with their own daily cap that never uses the daily research quota.
+ ablation_enabled: bool = True
+ ablation_max_backtests_per_day: int = Field(default=6, ge=1, le=60)
+ ablation_max_backtests_per_loser: int = Field(default=6, ge=3, le=6)
def digest(value: Any) -> str:
@@ -1186,4 +1191,39 @@ def forward_outcome_memory(row: dict[str, Any]) -> dict[str, Any]:
"won": sum(1 for row in rows if row["verdict"] == "won"),
"lost": sum(1 for row in rows if row["verdict"] in ("lost_futility", "lost_exam")),
}
- return [*outcomes, *memory]
+
+ # Ablations of the losers shown above share the slots ahead of development memory:
+ # outcomes and ablations together stay within 8 entries, so at least 12 development
+ # entries remain inside the model's 20-entry view.
+ ablations: list[dict[str, Any]] = []
+ loser_ids = [
+ entry["outcome_id"]
+ for entry in outcomes
+ if entry["verdict"] in ("lost_futility", "lost_exam")
+ ]
+ try:
+ from hypertrade.arc.ablation import ablation_memory
+
+ ablations, manifest["ablations"] = ablation_memory(
+ self.db,
+ target_id=context["target_id"],
+ outcome_ids=loser_ids,
+ development_end=windows.window("development")[1],
+ limit=max(0, min(4, 8 - len(outcomes))),
+ )
+ except Exception as exc: # Research continues without ablation context.
+ record_runtime_error(
+ "evolution.ablation_memory",
+ exc,
+ context={
+ "source_strategy_id": str(context["source_strategy_id"]),
+ "target_id": context["target_id"],
+ },
+ db=self.db,
+ )
+ manifest["ablations"] = {
+ "policy": "ablation_memory_v1",
+ "selected": 0,
+ "error": failure_text(exc)[:200],
+ }
+ return [*outcomes, *ablations, *memory]
diff --git a/backend/src/hypertrade/arc/evolution_models.py b/backend/src/hypertrade/arc/evolution_models.py
index 7e30ab6..714cf99 100644
--- a/backend/src/hypertrade/arc/evolution_models.py
+++ b/backend/src/hypertrade/arc/evolution_models.py
@@ -119,3 +119,60 @@ class ArcForwardOutcome(Base, TimestampMixin):
attempt_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
changes_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
scope_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+
+
+class ArcAblationRun(Base, TimestampMixin):
+ """Per-change ablation of one forward loser; development-window backtests only.
+
+ Status: ``pending | running | completed | failed | skipped``.
+ """
+
+ __tablename__ = "arc_ablation_runs"
+ __table_args__ = (
+ UniqueConstraint(
+ "target_id", "challenger_strategy_id", name="uq_arc_ablation_run_challenger"
+ ),
+ UniqueConstraint("outcome_id", name="uq_arc_ablation_run_outcome"),
+ )
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ outcome_id: Mapped[str] = mapped_column(String(64))
+ target_id: Mapped[str] = mapped_column(String(32))
+ parent_strategy_id: Mapped[str] = mapped_column(String(64), index=True)
+ challenger_strategy_id: Mapped[str] = mapped_column(String(64))
+ status: Mapped[str] = mapped_column(String(16), default="pending", index=True)
+ reason: Mapped[str] = mapped_column(String(64), default="")
+ message: Mapped[str] = mapped_column(Text, default="")
+ research_id: Mapped[str] = mapped_column(String(64), default="")
+ window_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+ plan_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+ result_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+ next_check_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True, index=True
+ )
+ started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+
+class ArcAblationStep(Base, TimestampMixin):
+ """One backtest of a run: the unchanged parent baseline, or the parent plus one change.
+
+ Status: ``pending | running | completed | failed``. ``charged_at`` marks the job
+ identity write (the billing point); steps reusing another step's job leave it empty.
+ """
+
+ __tablename__ = "arc_ablation_steps"
+ __table_args__ = (UniqueConstraint("run_id", "step_key", name="uq_arc_ablation_step"),)
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ run_id: Mapped[str] = mapped_column(String(64), index=True)
+ step_key: Mapped[str] = mapped_column(String(160))
+ ordinal: Mapped[int] = mapped_column(Integer, default=0)
+ kind: Mapped[str] = mapped_column(String(16))
+ change_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+ status: Mapped[str] = mapped_column(String(16), default="pending", index=True)
+ reason: Mapped[str] = mapped_column(String(128), default="")
+ request_hash: Mapped[str] = mapped_column(String(64), default="", index=True)
+ checkpoint_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+ result_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+ charged_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True, index=True
+ )
diff --git a/backend/src/hypertrade/arc/evolution_router.py b/backend/src/hypertrade/arc/evolution_router.py
index b0850fe..9e78f32 100644
--- a/backend/src/hypertrade/arc/evolution_router.py
+++ b/backend/src/hypertrade/arc/evolution_router.py
@@ -77,6 +77,14 @@ def _report() -> dict[str, Any]:
return await run_in_threadpool(_report)
+@router.get("/evolution/ablations", dependencies=[Depends(require_scope(ARCScope.READ))])
+async def evolution_ablations(request: Request) -> dict[str, Any]:
+ """Per-change ablation runs of forward losers; development-window backtests only."""
+ from hypertrade.arc.ablation import list_ablation_runs
+
+ return {"runs": await run_in_threadpool(list_ablation_runs, request.app.state.db)}
+
+
@router.get("/evolution/alerts", dependencies=[Depends(require_scope(ARCScope.READ))])
async def evolution_alerts(request: Request) -> dict[str, Any]:
"""Operator-visible evolution alerts; silent stalls are defects."""
diff --git a/backend/src/hypertrade/arc/research_budget.py b/backend/src/hypertrade/arc/research_budget.py
index afe8b43..64ee75d 100644
--- a/backend/src/hypertrade/arc/research_budget.py
+++ b/backend/src/hypertrade/arc/research_budget.py
@@ -19,6 +19,7 @@
from hypertrade.arc.controller import ARCController
from hypertrade.arc.evolution_models import EvolutionControl, EvolutionCycle
from hypertrade.arc.feedback import _feedback_child_active, _pending_effect
+from hypertrade.arc.runtime_journal import failure_text
from hypertrade.arc.store import insert_mission, load_projection
from hypertrade.db import ArcMission, Database
@@ -209,7 +210,15 @@ def budget_status(db: Database, config: EvolutionConfig, now: datetime) -> dict[
.limit(20)
)
]
- return state
+ # A separate transaction: this runs on every scan, so an unreadable ablation ledger may
+ # only degrade its own section, never the daily research budget.
+ try:
+ from hypertrade.arc.ablation import ablation_budget_view
+
+ state["ablation"] = ablation_budget_view(db, config, now)
+ except Exception as exc:
+ state["ablation"] = {"status": "unavailable", "error": failure_text(exc)[:200]}
+ return state
def admit(
diff --git a/backend/src/hypertrade/arc/self_test.py b/backend/src/hypertrade/arc/self_test.py
index 2339f4b..dd33198 100644
--- a/backend/src/hypertrade/arc/self_test.py
+++ b/backend/src/hypertrade/arc/self_test.py
@@ -415,6 +415,7 @@ def run(
goal: ARCGoalV1,
*,
purpose: Literal["development", "final"] = "final",
+ data_reference: dict[str, str] | None = None,
) -> SelfTestResult:
client = self._client or BitProToolAdapter()
try:
@@ -636,27 +637,38 @@ def run(
backtest_key = _window_backtest_key(scope, purpose, start, end, goal)
data_ref: dict[str, str] = {}
if is_source_variant and not is_baseline_attempt and goal.paper_review_required:
- # BitPro compares a candidate only on its baseline's sealed data. The baseline
- # uses the exact create/backtest keys its own attempt would, so the later
- # baseline self-test replays this job instead of running a second one.
- baseline_ref = _baseline_data_reference(
- client,
- attempt,
- goal,
- symbols,
- timeframe,
- purpose,
- start,
- end,
- backtest_start=lambda **kw: self._backtest(client, **kw),
- prerequisite_error=self._backtest_runner.defer_prerequisite
- if self._backtest_runner is not None
- else None,
- )
- if isinstance(baseline_ref, SelfTestResult):
- baseline_ref.bitpro_strategy_id = str(strategy_id)
- return baseline_ref
- data_ref = baseline_ref
+ # Ablation already obtained the sealed data reference from its own baseline
+ # backtest. Reusing it avoids an implicit baseline row and a second job per
+ # structural candidate; AVO keeps the original baseline path.
+ if data_reference is not None:
+ data_ref = {
+ "verified_data_snapshot_id": str(data_reference["verified_data_snapshot_id"]),
+ "verified_data_manifest_sha256": str(
+ data_reference["verified_data_manifest_sha256"]
+ ),
+ }
+ else:
+ # BitPro compares a candidate only on its baseline's sealed data. The baseline
+ # uses the exact create/backtest keys its own attempt would, so the later
+ # baseline self-test replays this job instead of running a second one.
+ baseline_ref = _baseline_data_reference(
+ client,
+ attempt,
+ goal,
+ symbols,
+ timeframe,
+ purpose,
+ start,
+ end,
+ backtest_start=lambda **kw: self._backtest(client, **kw),
+ prerequisite_error=self._backtest_runner.defer_prerequisite
+ if self._backtest_runner is not None
+ else None,
+ )
+ if isinstance(baseline_ref, SelfTestResult):
+ baseline_ref.bitpro_strategy_id = str(strategy_id)
+ return baseline_ref
+ data_ref = baseline_ref
try:
backtest = self._backtest(
client,
diff --git a/backend/src/hypertrade/worker.py b/backend/src/hypertrade/worker.py
index c7203ec..1eb33b9 100644
--- a/backend/src/hypertrade/worker.py
+++ b/backend/src/hypertrade/worker.py
@@ -752,6 +752,7 @@ async def arc_observation_loop(db: Database) -> None:
async def arc_evolution_loop(db: Database) -> None:
+ from hypertrade.arc.ablation import ablation_once
from hypertrade.arc.evolution import EvolutionService
from hypertrade.arc.evolution_alerts import evolution_alerts_once
@@ -760,6 +761,7 @@ async def arc_evolution_loop(db: Database) -> None:
while True:
await _guarded(db, "worker.arc_evolution", service.tick)
await _guarded(db, "worker.arc_evolution_alerts", lambda: evolution_alerts_once(db))
+ await _guarded(db, "worker.arc_ablation", lambda: ablation_once(db))
await asyncio.sleep(60)
diff --git a/docs/agent_architecture_and_interview_handbook.md b/docs/agent_architecture_and_interview_handbook.md
index 5b530c1..23be553 100644
--- a/docs/agent_architecture_and_interview_handbook.md
+++ b/docs/agent_architecture_and_interview_handbook.md
@@ -80,7 +80,7 @@ HyperTrade 整体系统划分为清晰的七层架构,实现了控制面、计
|
+---------------------------------------------------------------------------------------------------+
| 7. 持久化与审计基座 (Persistence & Telemetry Ledger) |
-| - PostgreSQL 14+ / pgvector (50 项 Alembic 迁移) · Langfuse 分布式追踪 · Agent 飞行记录仪 |
+| - PostgreSQL 14+ / pgvector (51 项 Alembic 迁移) · Langfuse 分布式追踪 · Agent 飞行记录仪 |
+---------------------------------------------------------------------------------------------------+
```
diff --git a/docs/architecture/33-system-architecture.md b/docs/architecture/33-system-architecture.md
index 905a12a..ae0013b 100644
--- a/docs/architecture/33-system-architecture.md
+++ b/docs/architecture/33-system-architecture.md
@@ -87,7 +87,7 @@ flowchart TB
end
subgraph Persistence["持久化与审计账本"]
- PG[("PostgreSQL 14+ / pgvector
50 项 Alembic 迁移 · 投影 · 演进树 · 记忆库")]
+ PG[("PostgreSQL 14+ / pgvector
51 项 Alembic 迁移 · 投影 · 演进树 · 记忆库")]
end
Web --> API
@@ -113,9 +113,9 @@ flowchart TB
| **Control Plane** | `hypertrade/main.py`, `hypertrade/security/token_manager.py` | 暴露 REST/SSE 控制端点,分级探活(`/livez`, `/readyz`, `/healthz`),零信任令牌签发/轮换/鉴权 |
| **Domain Core** | `hypertrade/runtime/domain/`, `hypertrade/arc/contracts.py` | 严格定义 Mission、Plan、Step、Capability、HET 假设、NettingPlan 等不可变 Pydantic 模型 |
| **Research Engine** | `hypertrade/arc/`, `hypertrade/research/` | RD-Agent 假设演进、Co-STEER 代码生成、`ASTGatekeeper` 语法守卫、`QuantLabStrategyTranspiler` 转译 |
-| **Paper & Evolution** | `hypertrade/arc/evolution.py`, `hypertrade/paper/relay_netting.py` | 小时级基准相对退化扫描、预算准入、延续账本(遵守 BitPro `relay.blocks_evolution` 跳过接力中的策略;按变体位空位排序、空位已被排队候选预订时让位、AVO 拒绝与 BitPro 已赛过变体完全相同的提案)、赛马裁判只读通知(按变体位逐赛道跟踪、按取消原因区分系统淘汰与人工取消、观察中的赛道慢轮询;BitPro 是唯一接力控制者)、赛道结束时记入前向结果表且结果回流研究记忆、两腿持仓净额平滑换仓(仅限有真实持仓的目标) |
+| **Paper & Evolution** | `hypertrade/arc/evolution.py`, `hypertrade/paper/relay_netting.py` | 小时级基准相对退化扫描、预算准入、延续账本(遵守 BitPro `relay.blocks_evolution` 跳过接力中的策略;按变体位空位排序、空位已被排队候选预订时让位、AVO 拒绝与 BitPro 已赛过变体完全相同的提案)、赛马裁判只读通知(按变体位逐赛道跟踪、按取消原因区分系统淘汰与人工取消、观察中的赛道慢轮询;BitPro 是唯一接力控制者)、赛道结束时记入前向结果表且结果回流研究记忆、输家逐项消融(开发窗口单因素回测、独立每日额度,结果回流研究记忆)、两腿持仓净额平滑换仓(仅限有真实持仓的目标) |
| **Target Adapters** | `hypertrade/targets/bitpro.py`, `hypertrade/targets/quantlab.py` | 实现 `market_target.v1` 档案与 `market-evolution.v1` 契约,提供标准化行情、回测、部署与心跳探测 |
-| **Data & Storage** | PostgreSQL 14+, pgvector, 50 项 Alembic 迁移 | 保存规范 Mission 投影、策略资产卡片、实验记录、认知记忆表及飞书告警台账 |
+| **Data & Storage** | PostgreSQL 14+, pgvector, 51 项 Alembic 迁移 | 保存规范 Mission 投影、策略资产卡片、实验记录、认知记忆表及飞书告警台账 |
| **Safety & Ops** | UDS 沙箱, GitHub Actions, Docker Compose, `./scripts/check.sh` | 进程资源限制、网络隔离、静态代码检查(Ruff/Mypy)与 2100+ 项全量自动化测试 |
---
@@ -155,7 +155,7 @@ flowchart TB
## 7. 部署模型与质量门禁
-1. **部署拓扑**:Docker Compose / K8s + Nginx + PostgreSQL (pgvector)。所有服务启动前自动执行 50 项 Alembic 迁移。
+1. **部署拓扑**:Docker Compose / K8s + Nginx + PostgreSQL (pgvector)。所有服务启动前自动执行 51 项 Alembic 迁移。
2. **质量门禁**:通过 `./scripts/check.sh` 实行一票否决制:
- 前端代码检查与 Vitest 测试全绿;
- 前端 Vite 生产构建成功;
diff --git a/docs/contracts/user-directed-evolution-hardening.md b/docs/contracts/user-directed-evolution-hardening.md
index e7a307f..7718292 100644
--- a/docs/contracts/user-directed-evolution-hardening.md
+++ b/docs/contracts/user-directed-evolution-hardening.md
@@ -198,3 +198,18 @@ BitPro 的母体现在默认有三个变体位(见 BitPro 接力合同「变
- **未做**:输家逐项消融(第二期)。
验证:`tests/test_forward_outcomes_ledger.py` 覆盖结论映射与证据分级、未知结束升级与具体结论不改写、并发插入对齐、指标未知保持 `None`、血缘定位(事件索引命中滑出窗口的旧任务、重复编号取最新、回退任务扫描、异常载荷不报错)、接班链回溯与排序限量、`summary` 聚合;`tests/test_arc_forward_outcomes_postgres.py` 验证真库迁移升级/降级、唯一组合写入与 JSON 路径查询;`tests/test_race_judge.py` 覆盖赛道结束记账、位置被补走时按淘汰归档发卡并记输赢、归档缺失时仍记结束、重新放行与补录;`tests/test_arc_evolution.py` 覆盖沿接班链回流、按结束时间排序限量、账本失败不阻断研究、结果条目排在开发记忆之前,并断言 `status()` 不含该统计;`tests/test_avo_evidence.py` 与 `tests/test_source_variant_avo.py` 覆盖证据视图只把开发记忆列为参照、`outcome:` 引用可被接受而缺失引用被拒绝、系统提示包含使用规则;`tests/test_arc_external_surface.py` 覆盖 `GET /api/v1/arc/evolution` 的 `forward_outcomes` 计数与账本读取失败时仍返回 200 且带 `error`。
+
+## 输家逐项消融(2026-10-22)
+
+- **目的**:赛马被系统判负(`lost_futility`、`lost_exam`)的挑战者,在开发窗口上把它相对母体的每一处改动单独加到母体上回测,结果作为单因素观察回流研究记忆。不含改动之间的交互,不是因果结论。
+- **触发与幂等**:只处理已对接研究血缘、参数改动与结构改动合计 ≥ 2 的输家;每个(target、challenger)一个任务(表 `arc_ablation_runs` 以 `target_id + challenger_strategy_id` 和 `outcome_id` 唯一),每项改动一步(表 `arc_ablation_steps` 唯一键 `(run_id, step_key)`);迁移 0051。
+- **执行口径**:冻结开发窗口(`as_of` 为前一天);复用 AVO develop 的 `ARCSelfTestService.run(purpose="development")`;先测母体基线(`purpose=baseline` 变体行),再测「母体 + 单项改动」(`purpose=candidate`);候选使用基线回测封存的数据引用(`data_reference` 参数,AVO 路径不变);每个输家恰好「改动数 + 1」次回测;从不请求最终验证窗口。
+- **BitPro 侧**:幂等键由母体身份、开发窗口、资金、标的与周期派生,不含输家身份,因此同一母体同一窗口下的同一改动复用变体行与回测作业,复用不计费(`charged_at` 留空);消融行是停止状态、无正式 Paper 审核绑定,BitPro 自动纳管与 `tried_variants` 都不会把它们当成候选;不建 ARC 任务,不进 incubation 与 Paper 审核,不占每日研究名额。
+- **预算**:`EvolutionConfig.ablation_enabled`(默认开启)、`ablation_max_backtests_per_day`(默认 6,范围 1–60,按 UTC 日)、`ablation_max_backtests_per_loser`(默认 6,范围 3–6);「改动数 + 1」超过单输家上限的输家记为 `skipped` / `too_many_changes`;计费点是回测作业身份落库;每轮只推进一个任务、最多两步回测;`enabled` 关闭或目标不是 BitPro 时不运行。
+- **恢复**:进度检查点存在步骤行;读失败累计 5 次转 blocked,中断最多续跑 2 次,单个作业最长等待 24 小时,执行版本变化原样记为失败;崩溃后重放同一幂等键不会重复建作业、不会重复计费。
+- **等待与失败**:未完成或等待中的原因码有 `parent_policy_unavailable`、`parent_relay_hold`、`daily_budget_exhausted`、`backtest_pending`;终态失败与跳过有 `parent_relay_hold_timeout`、`parent_changed_since_race`、`parent_variant_creation_unsupported`、`lineage_incomplete`、`lineage_unavailable`、`outcome_missing`、`not_source_variant`、`single_change`、`too_many_changes`、`baseline_data_reference_unavailable`、`baseline_data_snapshot_unavailable`、`baseline_backtest_failed`、`all_single_changes_failed`、`runtime_error`;步骤级另有 `metric_missing`、`step_not_terminal`、`backtest_failed` 与比较证据无效时的 `comparison_evidence_invalid`。失败一律写具体原因,未知指标保持 `None`,不补零。
+- **结果分类**:`ablation_result.v1`;每项改动为 `helpful`/`harmful`/`neutral`/`unknown`(指标缺失或该步失败)/`incomparable`(执行证据、成本、数据或窗口与基线不一致);阈值是描述性噪声带——净收益差 ≥ +0.005 且回撤差 ≤ +0.005 记 helpful,净收益差 ≤ −0.005 或回撤差 ≥ +0.01 记 harmful,其余 neutral——不是显著性检验。
+- **回流记忆**:`_memory` 在前向结果之后、开发记忆之前加入 `type=ablation` 条目(`memory_id` 形如 `ablation:`),只投影本轮已展示的前向输家的已完成消融;与前向结果合计不超过 8 条,模型可见的前 20 条里开发记忆至少 12 条;消融窗口结束日不得晚于本轮开发窗口结束日,否则记 `outside_development_horizon`;AVO 系统提示说明含义、引用方式与「保留有帮助、去掉有害」只是假说;研究证据视图单列 `ablation_references`,`memory_references` 仍只列开发记忆;消融出错只记运行错误,研究照常。
+- **可观察**:`GET /api/v1/arc/evolution` 的 `budget.ablation`(当日用量与上限、单输家上限、各状态任务数、原因计数、输家计数与下次检查时间;读取失败只降级该段);效果报告新增 `ablation_runs_by_status`、`ablation_reasons`、`ablation_backtests_charged`(失败记 `ablation_error`);只读列表 `GET /api/v1/arc/evolution/ablations` 只给状态与标签,不含原始回测指标。
+- **已知限制**:BitPro 每个母体只扫描最近 400 条 `purpose=candidate` 回执,消融行会占用这个扫描窗口(复用与每日上限使影响有限,长期可请 BitPro 在扫描时排除无审核绑定的行);BitPro 谱系接口需要管理员会话,未用于历史回填;崩溃恰好发生在「记下续跑次数」与「发出续跑请求」之间时会多算一次续跑;未线上实测。
+- **验证**:新增 `tests/test_arc_ablation.py`、`tests/test_arc_ablation_schema.py`、`tests/test_arc_ablation_postgres.py`、`tests/test_arc_ablation_observability.py`、`tests/test_arc_ablation_memory.py`;并在 `tests/test_arc_self_test.py`、`tests/test_arc_evolution.py`、`tests/test_arc_external_surface.py`、`tests/test_avo_evidence.py` 中补充用例。
diff --git a/docs/progress.md b/docs/progress.md
index 2b17158..99a21e1 100644
--- a/docs/progress.md
+++ b/docs/progress.md
@@ -1,3 +1,10 @@
+## 2026-10-22:赛马输家逐项消融
+
+- **背景**:被系统判负的挑战者只留下「整体输了」的结论,看不出是哪一处改动拖累;研究侧需要一个中性的单因素观察,避免把整份提案连坐。
+- **交付**:迁移 0051 新增 `arc_ablation_runs`/`arc_ablation_steps`(每个 目标/挑战者 一行,每项改动一步);引擎只对已对接血缘且改动 ≥ 2 的输家,在冻结开发窗口上先测母体基线、再逐项把单处改动加到母体回测(复用 `ARCSelfTestService.run(purpose="development")`,候选复用基线的封存数据引用,从不请求最终窗口);幂等键不含输家身份,同一母体同一窗口的同一改动复用变体行与回测作业且不计费;`ablation_enabled` 默认开启、每日独立额度默认 6 次、单输家上限默认 6 次,「改动数 + 1」超出记 `too_many_changes`;检查点续跑、读失败有界重试、单作业最长等待 24 小时;每项改动分类为 helpful/harmful/neutral/unknown/incomparable(±0.5pp 描述性噪声带,不是显著性检验);`_memory` 在前向结果之后加入 `type=ablation` 条目,与前向结果合计最多 8 条、开发记忆至少保留 12 条可见;`GET /api/v1/arc/evolution` 新增 `budget.ablation`,效果报告新增 `ablation_*` 计数,只读 `GET /api/v1/arc/evolution/ablations` 只给状态与标签;AVO 系统提示与研究证据视图同步(`ablation_references`)。
+- **验证**:新增 `tests/test_arc_ablation.py`、`tests/test_arc_ablation_schema.py`、`tests/test_arc_ablation_postgres.py`(PostgreSQL 真库)、`tests/test_arc_ablation_observability.py`、`tests/test_arc_ablation_memory.py`,并在 `tests/test_arc_self_test.py`、`tests/test_arc_evolution.py`、`tests/test_arc_external_surface.py`、`tests/test_avo_evidence.py` 补充用例;对 9 处关键守卫做过变异检验,去掉任一处都有用例失败(每日上限有两道检查,同时去掉才失败,属有意冗余);完整检查通过:前端 lint、15 项测试与构建,`ruff`、`mypy`(319 个文件)、`pytest` 2325 项(前端复用主工作区依赖,未重新安装)。
+- **未做**:未线上实测;PR 待用户确认后合并,排在 #79 之后。
+
## 2026-10-21:赛马前向结果回流研究记忆
- **背景**:赛马结束的结论此前只留在裁判状态文件里,研究侧看不到自己提的改动实际赛成什么样;研究提速到每天最多 32 轮后,旧任务会滑出「最近 500 个任务」的扫描窗口,血缘更容易对不上。
diff --git a/docs/spec.md b/docs/spec.md
index 0fd767d..f09ce37 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -1148,7 +1148,7 @@ research start/continue默认持续跟进服务端持久事件;--detach明确
### 每小时自主进化
-BitPro自主进化开关对应HyperTrade持久配置与worker调度。每小时诊断模拟盘7+7退化、当前会话历史成交样本及可追溯开发回测记忆,自主生成改进假设;经过原版本同窗比较和人工审核后,独立运行新Paper。预算、范围、冷却和去重由确定性服务控制。数据不足和不支持复现的组合/策略应显示诊断缺口,不自动替换原策略。赛马赛道结束时把结论写入前向结果表,并沿接班链作为研究记忆回流后续研究。默认关闭,立即诊断不会创建研究。(2026-09-14 起 `EvolutionConfig.enabled` 默认开启,仅影响新建配置;见文末「可插拔市场目标与自进化通用化」。)
+BitPro自主进化开关对应HyperTrade持久配置与worker调度。每小时诊断模拟盘7+7退化、当前会话历史成交样本及可追溯开发回测记忆,自主生成改进假设;经过原版本同窗比较和人工审核后,独立运行新Paper。预算、范围、冷却和去重由确定性服务控制。数据不足和不支持复现的组合/策略应显示诊断缺口,不自动替换原策略。赛马赛道结束时把结论写入前向结果表,并沿接班链作为研究记忆回流后续研究。被系统判负且改动两项及以上的挑战者会在开发窗口逐项消融(每次只把一处改动加到母体上回测,独立每日额度),结果作为单因素观察回流研究记忆。默认关闭,立即诊断不会创建研究。(2026-09-14 起 `EvolutionConfig.enabled` 默认开启,仅影响新建配置;见文末「可插拔市场目标与自进化通用化」。)
### 自主进化的证据上下文与提案合同(2026-09-13)
diff --git a/tests/test_arc_ablation.py b/tests/test_arc_ablation.py
new file mode 100644
index 0000000..36a4679
--- /dev/null
+++ b/tests/test_arc_ablation.py
@@ -0,0 +1,739 @@
+"""Per-change ablation: development-window replay of a forward loser's changes."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from datetime import UTC, datetime, timedelta
+from decimal import Decimal
+from typing import Any
+
+import pytest
+from hypertrade.arc import ablation
+from hypertrade.arc.ablation import ablation_once, schedule_runs
+from hypertrade.arc.contracts import (
+ ARCCandidateAttemptV1,
+ ARCGoalV1,
+ ResearchWindowsV1,
+)
+from hypertrade.arc.controller import ARCController
+from hypertrade.arc.evolution import EvolutionConfig
+from hypertrade.arc.evolution_models import (
+ ArcAblationRun,
+ ArcAblationStep,
+ ArcForwardOutcome,
+ EvolutionControl,
+)
+from hypertrade.arc.forward_outcomes import outcome_id
+from hypertrade.arc.research_budget import budget_status
+from hypertrade.arc.store import configure_store, reset_store, save_mission
+from hypertrade.db import ArcMission, Database
+from sqlalchemy import func, select
+
+M = "a" * 64
+E = "b" * 64
+DATA = "e" * 64
+NOW = datetime(2026, 10, 21, 12, tzinfo=UTC)
+
+
+@pytest.fixture
+def db():
+ database = Database("sqlite:///:memory:")
+ database.create_all()
+ configure_store(database)
+ try:
+ yield database
+ finally:
+ reset_store()
+
+
+def _seed_loser(
+ db: Database,
+ *,
+ challenger: str = "9101",
+ parameter_changes: dict[str, Any] | None = None,
+ structural_changes: dict[str, Any] | None = None,
+ verdict: str = "lost_futility",
+ lineage: str = "linked",
+ variant: bool = True,
+) -> str:
+ parameters = (
+ dict(parameter_changes) if parameter_changes is not None else {"fast": 12, "slow": 40}
+ )
+ structural = (
+ dict(structural_changes)
+ if structural_changes is not None
+ else {"vol_filter": {"window": 20}}
+ )
+ baseline = ARCCandidateAttemptV1(
+ attempt_id="baseline_443",
+ candidate_id="baseline_443",
+ hypothesis="不可变的原策略比较基线",
+ strategy_code="class Parent: pass\n",
+ strategy_spec={
+ "symbol": "BTC-USDT-SWAP",
+ "timeframe": "1H",
+ "baseline_config": {"fast": 10, "slow": 30},
+ "is_source_variant": True,
+ "parent_strategy_id": 443,
+ "parent_manifest_sha256": M,
+ "parent_execution_identity_sha256": E,
+ },
+ )
+ goal = ARCGoalV1(
+ objective="evolve 443",
+ research_mode="avo",
+ paper_review_required=True,
+ symbols=["BTC-USDT-SWAP"],
+ timeframes=["1H"],
+ paper_initial_equity=Decimal("100"),
+ evolution_context={
+ "source_strategy_id": 443,
+ "baseline": baseline.model_dump(mode="json"),
+ },
+ )
+ controller = ARCController(goal=goal)
+ spec: dict[str, Any] = {
+ **baseline.strategy_spec,
+ "parameter_changes": parameters,
+ "structural_changes": structural,
+ "variation_operator": "avo",
+ }
+ if variant:
+ spec.update(
+ {
+ "is_source_variant": True,
+ "parent_strategy_id": 443,
+ "parent_manifest_sha256": M,
+ "parent_execution_identity_sha256": E,
+ }
+ )
+ else:
+ for key in (
+ "is_source_variant",
+ "parent_strategy_id",
+ "parent_manifest_sha256",
+ "parent_execution_identity_sha256",
+ ):
+ spec.pop(key, None)
+ attempt = ARCCandidateAttemptV1(
+ attempt_id="att_901",
+ candidate_id="cand_901",
+ hypothesis="narrow the entry window",
+ strategy_code=baseline.strategy_code,
+ strategy_spec=spec,
+ bitpro_strategy_id=challenger,
+ )
+ controller.projection.attempts.append(attempt)
+ save_mission(controller)
+ oid = outcome_id("bitpro", 443, challenger)
+ with db.session() as session:
+ session.add(
+ ArcForwardOutcome(
+ id=oid,
+ target_id="bitpro",
+ parent_strategy_id="443",
+ challenger_strategy_id=challenger,
+ verdict=verdict,
+ lineage_status=lineage,
+ mission_id=controller.mission_id,
+ candidate_id=attempt.candidate_id,
+ attempt_id=attempt.attempt_id,
+ changes_json={
+ "parameter_changes": parameters,
+ "structural_changes": structural,
+ },
+ scope_json={},
+ ended_at=NOW - timedelta(hours=1),
+ metrics_json={},
+ )
+ )
+ return oid
+
+
+class FakeBitPro:
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, Any]] = []
+ self.relay: dict[str, Any] = {}
+ self.policy_execution_identity = E
+ self.default_status = "completed"
+ self.status: dict[str, str] = {}
+ self.fail_when: Callable[[dict[str, Any]], bool] | None = None
+ self.cost_hash_when: Callable[[dict[str, Any]], str | None] | None = None
+ self.created = 0
+ self.jobs: dict[str, dict[str, Any]] = {}
+ self.by_key: dict[str, str] = {}
+ self.variants: dict[int, tuple[dict[str, int], dict[str, Any]]] = {}
+ self.candidate_seq = 5000
+
+ def __getattr__(self, name: str) -> Any:
+ raise AssertionError(f"unexpected BitPro call: {name}")
+
+ def strategy_research_variant_policy(self, *, strategy_id: int) -> dict[str, Any]:
+ self.calls.append(("policy", strategy_id))
+ return {
+ "contract_version": "strategy_research_variant_policy.v1",
+ "parent_strategy_id": strategy_id,
+ "variant_creation_supported": True,
+ "authorized_parameters": [],
+ "parent_manifest_sha256": M,
+ "parent_execution_identity_sha256": self.policy_execution_identity,
+ "relay": dict(self.relay),
+ }
+
+ def strategy_research_variant_create(self, **kwargs: Any) -> dict[str, Any]:
+ self.calls.append(("variant_create", kwargs))
+ assert kwargs["purpose"] in {"candidate", "baseline"}
+ key = kwargs["idempotency_key"]
+ if key not in self.by_key:
+ self.candidate_seq += 1
+ self.by_key[key] = str(self.candidate_seq)
+ self.variants[self.candidate_seq] = (
+ dict(kwargs["parameter_changes"]),
+ dict(kwargs.get("structural_changes") or {}),
+ )
+ candidate = int(self.by_key[key])
+ return {
+ "contract_version": "strategy_research_variant.v1",
+ "candidate_strategy_id": candidate,
+ "purpose": kwargs["purpose"],
+ "status": "stopped",
+ "parameter_changes": kwargs["parameter_changes"],
+ "structural_changes": kwargs.get("structural_changes") or {},
+ "parent_strategy_id": kwargs["strategy_id"],
+ }
+
+ def backtest_start_job(self, **kwargs: Any) -> dict[str, Any]:
+ self.calls.append(("backtest_start", kwargs))
+ key = kwargs["idempotency_key"]
+ if key not in self.by_key:
+ self.created += 1
+ job_id = f"job-{self.created}"
+ self.by_key[key] = job_id
+ self.jobs[job_id] = dict(kwargs)
+ job_id = self.by_key[key]
+ return {
+ "job": {
+ "job_id": job_id,
+ "input_snapshot_hash": "sha256:" + "c" * 64,
+ "strategy_id": kwargs["strategy_id"],
+ "status": "queued",
+ }
+ }
+
+ def _metrics(self, strategy_id: int) -> dict[str, Any]:
+ parameters, structural = self.variants.get(strategy_id, ({}, {}))
+ if parameters == {"slow": 40} and not structural:
+ return {
+ "total_return_pct": 12.0,
+ "max_drawdown_pct": 7.5,
+ "trade_count": 41,
+ "sharpe_ratio": 1.3,
+ }
+ if parameters == {"fast": 12} and not structural:
+ return {
+ "total_return_pct": 7.0,
+ "max_drawdown_pct": 8.2,
+ "trade_count": 38,
+ "sharpe_ratio": 0.8,
+ }
+ if structural:
+ return {
+ "total_return_pct": 10.2,
+ "max_drawdown_pct": 8.1,
+ "trade_count": 36,
+ "sharpe_ratio": 1.1,
+ }
+ return {
+ "total_return_pct": 10.0,
+ "max_drawdown_pct": 8.0,
+ "trade_count": 40,
+ "sharpe_ratio": 1.1,
+ }
+
+ def backtest_get_job(self, *, job_id: str) -> dict[str, Any]:
+ self.calls.append(("backtest_get", job_id))
+ request = self.jobs[job_id]
+ status = self.status.get(job_id, self.default_status)
+ if self.fail_when is not None and self.fail_when(request):
+ status = "failed"
+ cost_hash = "d" * 64
+ if self.cost_hash_when is not None:
+ cost_hash = self.cost_hash_when(request) or cost_hash
+ return {
+ "job": {
+ "job_id": job_id,
+ "strategy_id": request["strategy_id"],
+ "status": status,
+ "input_snapshot_hash": "sha256:" + "c" * 64,
+ "updated_at": "2026-10-21T00:00:00Z",
+ "strategy_input_snapshot": {
+ "runtime_sha256": "r" * 64,
+ "cost_policy_hash": cost_hash,
+ "initial_capital": 100.0,
+ "symbols": ["BTC-USDT-SWAP"],
+ "timeframes": ["1H"],
+ "start_date": request["start_date"],
+ "end_date": request["end_date"],
+ },
+ "verified_data_binding": {
+ "entries": [
+ {
+ "verified_snapshot_id": "vbs_" + DATA,
+ "manifest_sha256": DATA,
+ }
+ ]
+ },
+ },
+ "backtest_result": (
+ {
+ "id": f"bt-{job_id}",
+ "metrics": self._metrics(request["strategy_id"]),
+ }
+ if status == "completed"
+ else None
+ ),
+ }
+
+ def backtest_resume_job(self, *, job_id: str, idempotency_key: str) -> dict[str, Any]:
+ self.calls.append(("backtest_resume", job_id))
+ self.status[job_id] = "running"
+ return {"job": self.backtest_get_job(job_id=job_id)["job"]}
+
+
+def _runs(db: Database) -> list[ArcAblationRun]:
+ with db.session() as session:
+ return list(session.scalars(select(ArcAblationRun)))
+
+
+def _run(db: Database, challenger: str) -> ArcAblationRun:
+ with db.session() as session:
+ row = session.scalars(
+ select(ArcAblationRun).where(
+ ArcAblationRun.challenger_strategy_id == challenger
+ )
+ ).one()
+ return row
+
+
+def _steps(db: Database, run_id: str) -> list[ArcAblationStep]:
+ with db.session() as session:
+ return list(
+ session.scalars(
+ select(ArcAblationStep)
+ .where(ArcAblationStep.run_id == run_id)
+ .order_by(ArcAblationStep.ordinal.asc())
+ )
+ )
+
+
+def _charged(db: Database) -> int:
+ with db.session() as session:
+ return int(
+ session.scalar(
+ select(func.count())
+ .select_from(ArcAblationStep)
+ .where(ArcAblationStep.charged_at.is_not(None))
+ )
+ or 0
+ )
+
+
+def _aware(value: datetime | None) -> datetime | None:
+ if value is None:
+ return None
+ return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
+
+
+def _run_until_done(
+ db: Database, fake: FakeBitPro, challenger: str, *, start: datetime = NOW
+) -> ArcAblationRun:
+ moment = start
+ for _ in range(12):
+ ablation_once(db, now=moment, client=fake)
+ run = _run(db, challenger)
+ if run.status in {"completed", "failed", "skipped"}:
+ return run
+ moment += timedelta(minutes=5)
+ raise AssertionError(f"run for {challenger} did not finish")
+
+
+def _by_label(run: ArcAblationRun, label: str) -> dict[str, Any]:
+ return next(effect for effect in run.result_json["effects"] if effect["label"] == label)
+
+
+def test_only_linked_system_losers_with_two_or_more_changes_are_scheduled(db: Database) -> None:
+ _seed_loser(db, challenger="9001", verdict="won")
+ _seed_loser(
+ db, challenger="9002", parameter_changes={"fast": 12}, structural_changes={}
+ )
+ _seed_loser(db, challenger="9003", verdict="lost_exam")
+ _seed_loser(db, challenger="9004", verdict="cancelled_operator")
+ _seed_loser(db, challenger="9005", verdict="expired_timeout")
+ _seed_loser(db, challenger="9006", verdict="superseded")
+ _seed_loser(db, challenger="9007", verdict="cancelled_unknown")
+ _seed_loser(db, challenger="9008", lineage="no_lineage")
+ fake = FakeBitPro()
+
+ result = ablation_once(db, now=NOW, client=fake)
+
+ assert result["scheduled"] == {
+ "created": 1,
+ "single_change": 1,
+ "already_scheduled": 0,
+ }
+ assert [run.challenger_strategy_id for run in _runs(db)] == ["9003"]
+ again = schedule_runs(db, EvolutionConfig(), NOW)
+ assert again == {"created": 0, "single_change": 1, "already_scheduled": 1}
+ ablation_once(db, now=NOW + timedelta(minutes=5), client=fake)
+ assert len(_runs(db)) == 1
+
+
+def test_full_run_backtests_baseline_and_each_change_on_the_development_window(
+ db: Database,
+) -> None:
+ _seed_loser(db)
+ fake = FakeBitPro()
+
+ run = _run_until_done(db, fake, "9101")
+
+ assert run.status == "completed"
+ assert fake.created == 4
+ creates = [call[1] for call in fake.calls if call[0] == "variant_create"]
+ assert len(creates) == 4
+ assert [entry["purpose"] for entry in creates].count("baseline") == 1
+ start, end = ResearchWindowsV1(
+ as_of=NOW.date() - timedelta(days=1)
+ ).window("development")
+ requests = [call[1] for call in fake.calls if call[0] == "backtest_start"]
+ assert len(requests) == 4
+ for request in requests:
+ assert request["start_date"] == start.isoformat()
+ assert request["end_date"] == end.isoformat()
+ changed = {
+ candidate
+ for candidate, (parameters, structural) in fake.variants.items()
+ if parameters or structural
+ }
+ for request in requests:
+ if request["strategy_id"] in changed:
+ assert request["verified_data_snapshot_id"] == "vbs_" + DATA
+ assert request["verified_data_manifest_sha256"] == DATA
+
+ effects = run.result_json["effects"]
+ assert [effect["label"] for effect in effects] == [
+ "fast=12",
+ effects[1]["label"],
+ "slow=40",
+ ]
+ assert effects[1]["label"].startswith("vol_filter:")
+ assert [effect["class"] for effect in effects] == ["harmful", "neutral", "helpful"]
+ assert run.result_json["harmful"] == ["fast=12"]
+ assert run.result_json["helpful"] == ["slow=40"]
+ assert run.result_json["neutral"] == [effects[1]["label"]]
+ assert run.result_json["causal_claim_verified"] is False
+ assert run.result_json["note"]
+ assert _charged(db) == 4
+
+
+def test_pending_job_resumes_without_new_job_or_second_charge(db: Database) -> None:
+ _seed_loser(db)
+ fake = FakeBitPro()
+ fake.default_status = "running"
+
+ ablation_once(db, now=NOW, client=fake)
+ pending = _run(db, "9101")
+ assert pending.reason == "backtest_pending"
+ assert fake.created == 1
+ assert _charged(db) == 1
+
+ # Simulate a restart: the database survives, every in-process object does not.
+ reset_store()
+ configure_store(db)
+ fake.default_status = "completed"
+
+ moment = NOW + timedelta(hours=2)
+ for _ in range(12):
+ ablation_once(db, now=moment, client=fake)
+ if _run(db, "9101").status == "completed":
+ break
+ moment += timedelta(minutes=10)
+
+ finished = _run(db, "9101")
+ assert finished.status == "completed"
+ assert fake.created == 4
+ assert _charged(db) == 4
+
+
+def test_crash_after_job_created_before_checkpoint_does_not_double_create_or_charge(
+ db: Database, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ _seed_loser(db)
+ fake = FakeBitPro()
+ original = ablation._record_submitted
+ calls = {"count": 0}
+
+ class Crash(BaseException):
+ pass
+
+ def crash_once(
+ database: Database, step_id: str, job: dict[str, Any], now: datetime
+ ) -> None:
+ calls["count"] += 1
+ if calls["count"] == 1:
+ raise Crash()
+ original(database, step_id, job, now)
+
+ monkeypatch.setattr(ablation, "_record_submitted", crash_once)
+ with pytest.raises(Crash):
+ ablation_once(db, now=NOW, client=fake)
+ assert fake.created == 1
+ assert _charged(db) == 0
+
+ monkeypatch.setattr(ablation, "_record_submitted", original)
+ run = _run_until_done(db, fake, "9101")
+
+ assert run.status == "completed"
+ assert fake.created == 4
+ assert _charged(db) == 4
+ assert not run.result_json.get("runtime_errors")
+
+
+def test_daily_budget_cap_pauses_until_next_utc_midnight(db: Database) -> None:
+ _seed_loser(db)
+ with db.session() as session:
+ session.add(
+ EvolutionControl(
+ id="global", config_json={"ablation_max_backtests_per_day": 2}
+ )
+ )
+ fake = FakeBitPro()
+
+ ablation_once(db, now=NOW, client=fake)
+ assert fake.created == 2
+ ablation_once(db, now=NOW, client=fake)
+ paused = _run(db, "9101")
+ assert paused.reason == "daily_budget_exhausted"
+ assert _aware(paused.next_check_at) == datetime(2026, 10, 22, 0, tzinfo=UTC)
+ assert fake.created == 2
+
+ tomorrow = NOW + timedelta(days=1)
+ for _ in range(6):
+ ablation_once(db, now=tomorrow, client=fake)
+ if _run(db, "9101").status == "completed":
+ break
+ tomorrow += timedelta(minutes=5)
+
+ assert _run(db, "9101").status == "completed"
+ assert fake.created == 4
+
+
+def test_loser_needing_more_backtests_than_the_per_loser_cap_is_skipped_without_bitpro_calls(
+ db: Database,
+) -> None:
+ _seed_loser(
+ db,
+ parameter_changes={f"p{index}": index + 1 for index in range(6)},
+ structural_changes={},
+ )
+ fake = FakeBitPro()
+
+ ablation_once(db, now=NOW, client=fake)
+
+ run = _run(db, "9101")
+ assert run.status == "skipped"
+ assert run.reason == "too_many_changes"
+ assert "7" in run.message and "6" in run.message
+ assert fake.calls == []
+
+
+def test_parent_change_skips_and_seat_hold_waits_with_visible_reasons(db: Database) -> None:
+ _seed_loser(db, challenger="9101")
+ changed = FakeBitPro()
+ changed.policy_execution_identity = "c" * 64
+
+ ablation_once(db, now=NOW, client=changed)
+ run = _run(db, "9101")
+ assert run.status == "skipped"
+ assert run.reason == "parent_changed_since_race"
+ assert len(changed.calls) == 1
+
+ _seed_loser(db, challenger="9102")
+ held = FakeBitPro()
+ held.relay = {"blocks_evolution": True, "reason": "3 个变体位已占满"}
+
+ ablation_once(db, now=NOW, client=held)
+ waiting = _run(db, "9102")
+ assert waiting.status == "pending"
+ assert waiting.reason == "parent_relay_hold"
+ assert "3 个变体位已占满" in waiting.message
+ assert _aware(waiting.next_check_at) == NOW + timedelta(hours=1)
+
+ ablation_once(db, now=NOW + timedelta(days=8), client=held)
+ timed_out = _run(db, "9102")
+ assert timed_out.status == "failed"
+ assert timed_out.reason == "parent_relay_hold_timeout"
+
+
+def test_baseline_failure_fails_run_and_single_failure_is_unknown_not_zero(
+ db: Database,
+) -> None:
+ _seed_loser(db, challenger="9101")
+ fake = FakeBitPro()
+ fake.default_status = "failed"
+
+ ablation_once(db, now=NOW, client=fake)
+ failed = _run(db, "9101")
+ assert failed.status == "failed"
+ assert failed.reason
+ assert fake.created == 1
+
+ # A fresh store keeps the first scenario's failed jobs out of the cross-run reuse map.
+ second = Database("sqlite:///:memory:")
+ second.create_all()
+ configure_store(second)
+ try:
+ _seed_loser(second, challenger="9102")
+ other = FakeBitPro()
+
+ def fail_fast(request: dict[str, Any]) -> bool:
+ parameters, structural = other.variants.get(request["strategy_id"], ({}, {}))
+ return parameters == {"fast": 12} and not structural
+
+ other.fail_when = fail_fast
+ _run_until_done(second, other, "9102")
+ finished = _run(second, "9102")
+ finally:
+ configure_store(db)
+ assert finished.status == "completed"
+ fast = _by_label(finished, "fast=12")
+ assert fast["class"] == "unknown"
+ assert fast["delta_net_return"] is None
+ assert fast["delta_max_drawdown"] is None
+ assert fast["delta_trade_count"] is None
+ assert fast["reason"]
+
+
+def test_mismatched_execution_evidence_is_incomparable(db: Database) -> None:
+ _seed_loser(db)
+ fake = FakeBitPro()
+
+ def slow_cost(request: dict[str, Any]) -> str | None:
+ parameters, structural = fake.variants.get(request["strategy_id"], ({}, {}))
+ return "f" * 64 if parameters == {"slow": 40} and not structural else None
+
+ fake.cost_hash_when = slow_cost
+ _run_until_done(db, fake, "9101")
+
+ run = _run(db, "9101")
+ slow = _by_label(run, "slow=40")
+ assert slow["class"] == "incomparable"
+ assert slow["reason"]
+ assert slow["delta_net_return"] is None
+ assert "slow=40" in run.result_json["incomparable"]
+
+
+def test_same_change_across_two_losers_of_the_same_parent_reuses_jobs_without_charge(
+ db: Database,
+) -> None:
+ _seed_loser(
+ db,
+ challenger="9101",
+ parameter_changes={"fast": 12, "slow": 40},
+ structural_changes={},
+ )
+ fake = FakeBitPro()
+ first = _run_until_done(db, fake, "9101")
+ assert first.status == "completed"
+ assert fake.created == 3
+
+ _seed_loser(
+ db,
+ challenger="9102",
+ parameter_changes={"fast": 12},
+ structural_changes={"vol_filter": {"window": 20}},
+ )
+ second = _run_until_done(db, fake, "9102")
+ assert second.status == "completed"
+ assert fake.created == 4
+ assert _charged(db) == 4
+
+ first_steps = {step.step_key: step for step in _steps(db, first.id)}
+ second_steps = {step.step_key: step for step in _steps(db, second.id)}
+ for key in ("baseline", "param:fast"):
+ reused = second_steps[key]
+ assert reused.charged_at is None
+ assert reused.checkpoint_json["reused_from"] == first_steps[key].id
+ assert second_steps["struct:vol_filter"].charged_at is not None
+
+
+def test_ablation_never_touches_paper_incubation_missions_or_research_budget(
+ db: Database,
+) -> None:
+ _seed_loser(db)
+ fake = FakeBitPro()
+ with db.session() as session:
+ missions_before = int(session.scalar(select(func.count()).select_from(ArcMission)) or 0)
+ budget_before = budget_status(db, EvolutionConfig(), NOW)["period_used"]
+
+ run = _run_until_done(db, fake, "9101")
+
+ assert run.status == "completed"
+ assert not run.result_json.get("runtime_errors")
+ with db.session() as session:
+ missions_after = int(session.scalar(select(func.count()).select_from(ArcMission)) or 0)
+ assert missions_after == missions_before
+ assert budget_status(db, EvolutionConfig(), NOW)["period_used"] == budget_before
+ assert {call[0] for call in fake.calls} <= {
+ "policy",
+ "variant_create",
+ "backtest_start",
+ "backtest_get",
+ }
+ purposes = [call[1]["purpose"] for call in fake.calls if call[0] == "variant_create"]
+ assert purposes and set(purposes) <= {"candidate", "baseline"}
+
+
+@pytest.mark.parametrize(
+ "config",
+ [
+ {"ablation_enabled": False},
+ {"enabled": False},
+ {"target_id": "quantlab"},
+ ],
+)
+def test_disabled_or_non_bitpro_config_does_nothing(
+ db: Database, config: dict[str, Any]
+) -> None:
+ _seed_loser(db)
+ with db.session() as session:
+ session.add(EvolutionControl(id="global", config_json=config))
+ fake = FakeBitPro()
+
+ result = ablation_once(db, now=NOW, client=fake)
+
+ assert result == {"status": "disabled"}
+ assert _runs(db) == []
+ assert fake.calls == []
+
+
+def test_missing_lineage_fails_and_non_variant_is_skipped(db: Database) -> None:
+ oid = _seed_loser(db, challenger="9101")
+ with db.session() as session:
+ row = session.get(ArcForwardOutcome, oid)
+ assert row is not None
+ row.mission_id = "missing-mission"
+ fake = FakeBitPro()
+
+ ablation_once(db, now=NOW, client=fake)
+ failed = _run(db, "9101")
+ assert failed.status == "failed"
+ assert failed.reason == "lineage_unavailable"
+ assert fake.calls == []
+
+ _seed_loser(db, challenger="9102", variant=False)
+ other = FakeBitPro()
+ ablation_once(db, now=NOW, client=other)
+ skipped = _run(db, "9102")
+ assert skipped.status == "skipped"
+ assert skipped.reason == "not_source_variant"
+ assert other.calls == []
diff --git a/tests/test_arc_ablation_memory.py b/tests/test_arc_ablation_memory.py
new file mode 100644
index 0000000..bd3cae4
--- /dev/null
+++ b/tests/test_arc_ablation_memory.py
@@ -0,0 +1,398 @@
+"""Completed loser ablations projected into citable research memory entries."""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any
+
+import pytest
+from hypertrade.arc.ablation import ablation_memory
+from hypertrade.arc.evolution_models import ArcAblationRun
+from hypertrade.db import Database
+
+DEV_END = date(2026, 8, 20)
+
+ENTRY_KEYS = {
+ "memory_id",
+ "type",
+ "evidence_status",
+ "ablation_id",
+ "outcome_id",
+ "verdict",
+ "parent_strategy_id",
+ "challenger_strategy_id",
+ "window",
+ "capital",
+ "baseline",
+ "effects",
+ "harmful",
+ "helpful",
+ "scope",
+ "causal_claim_verified",
+ "rule",
+}
+EFFECT_KEYS = {
+ "label",
+ "class",
+ "delta_net_return",
+ "delta_max_drawdown",
+ "delta_trade_count",
+ "backtest_id",
+ "reason",
+}
+BASELINE_KEYS = {"backtest_id", "net_return", "max_drawdown", "trade_count"}
+
+
+@pytest.fixture
+def db():
+ database = Database("sqlite:///:memory:")
+ database.create_all()
+ yield database
+
+
+def _result(
+ outcome_id: str,
+ *,
+ window: tuple[str, str] = ("2026-04-21", "2026-08-20"),
+ harmful_label: str = "fast=12",
+ helpful_label: str = "slow=40",
+) -> dict[str, Any]:
+ return {
+ "schema_version": "ablation_result.v1",
+ "outcome_id": outcome_id,
+ "verdict": "lost_futility",
+ "parent_strategy_id": "443",
+ "challenger_strategy_id": "9103",
+ "window": list(window),
+ "capital": "10000",
+ "baseline": {
+ "backtest_id": 11,
+ "net_return": 0.1,
+ "max_drawdown": 0.05,
+ "trade_count": 42,
+ },
+ "effects": [
+ {
+ "step_key": "param:fast",
+ "change": {"kind": "parameter", "key": "fast", "value": 12},
+ "label": harmful_label,
+ "status": "completed",
+ "backtest_id": 12,
+ "reason": "",
+ "delta_net_return": -0.02,
+ "delta_max_drawdown": 0.01,
+ "delta_trade_count": 3.0,
+ "class": "harmful",
+ },
+ {
+ "step_key": "param:slow",
+ "change": {"kind": "parameter", "key": "slow", "value": 40},
+ "label": helpful_label,
+ "status": "completed",
+ "backtest_id": 13,
+ "reason": "",
+ "delta_net_return": 0.03,
+ "delta_max_drawdown": -0.005,
+ "delta_trade_count": -2.0,
+ "class": "helpful",
+ },
+ ],
+ "harmful": [harmful_label],
+ "helpful": [helpful_label],
+ "neutral": [],
+ "unknown": [],
+ "incomparable": [],
+ "scope": "development_window_single_factor",
+ "note": "dev-only single-factor observation",
+ "causal_claim_verified": False,
+ }
+
+
+def _run(
+ db: Database,
+ *,
+ run_id: str,
+ outcome_id: str | None = None,
+ target_id: str = "bitpro",
+ status: str = "completed",
+ result: dict[str, Any] | None = None,
+) -> None:
+ with db.session() as session:
+ session.add(
+ ArcAblationRun(
+ id=run_id,
+ outcome_id=outcome_id or f"out_{run_id}",
+ target_id=target_id,
+ parent_strategy_id="443",
+ challenger_strategy_id=run_id,
+ status=status,
+ result_json=result if result is not None else {},
+ )
+ )
+
+
+def test_completed_ablations_follow_outcome_order_with_citable_memory_ids(
+ db: Database,
+) -> None:
+ for name in ("a", "b", "c"):
+ _run(
+ db,
+ run_id=f"run_{name}",
+ outcome_id=f"out_{name}",
+ result=_result(f"out_{name}"),
+ )
+
+ entries, manifest = ablation_memory(
+ db,
+ target_id="bitpro",
+ outcome_ids=["out_c", "out_a", "out_b"],
+ development_end=DEV_END,
+ )
+
+ assert [entry["outcome_id"] for entry in entries] == ["out_c", "out_a", "out_b"]
+ assert [entry["memory_id"] for entry in entries] == [
+ "ablation:run_c",
+ "ablation:run_a",
+ "ablation:run_b",
+ ]
+ for entry in entries:
+ assert entry["type"] == "ablation"
+ assert entry["causal_claim_verified"] is False
+ assert set(entry) == ENTRY_KEYS
+ assert not any(
+ marker in key
+ for key in entry
+ for marker in ("metrics", "plan_json", "window_json", "final")
+ )
+ assert set(entry["baseline"]) == BASELINE_KEYS
+ assert [effect["class"] for effect in entry["effects"]] == ["harmful", "helpful"]
+ for effect in entry["effects"]:
+ assert set(effect) == EFFECT_KEYS
+ assert entries[0]["harmful"] == ["fast=12"]
+ assert entries[0]["helpful"] == ["slow=40"]
+ assert manifest == {"policy": "ablation_memory_v1", "selected": 3, "excluded": {}}
+
+
+def test_unfinished_malformed_and_late_window_runs_are_excluded_with_reasons(
+ db: Database,
+) -> None:
+ for status in ("running", "failed", "skipped"):
+ _run(db, run_id=f"run_{status}", outcome_id=f"out_{status}", status=status)
+ _run(db, run_id="run_malformed", outcome_id="out_malformed", result={})
+ _run(
+ db,
+ run_id="run_late",
+ outcome_id="out_late",
+ result=_result("out_late", window=("2026-04-21", "2026-08-21")),
+ )
+
+ entries, manifest = ablation_memory(
+ db,
+ target_id="bitpro",
+ outcome_ids=[
+ "out_running",
+ "out_failed",
+ "out_skipped",
+ "out_malformed",
+ "out_late",
+ ],
+ development_end=DEV_END,
+ )
+
+ assert entries == []
+ assert manifest == {
+ "policy": "ablation_memory_v1",
+ "selected": 0,
+ "excluded": {
+ "not_completed": 3,
+ "malformed_result": 1,
+ "outside_development_horizon": 1,
+ },
+ }
+
+
+def test_limit_and_dedup(db: Database) -> None:
+ for index in range(5):
+ outcome_id = f"out_{index}"
+ _run(
+ db,
+ run_id=f"run_{index}",
+ outcome_id=outcome_id,
+ result=_result(outcome_id),
+ )
+
+ entries, manifest = ablation_memory(
+ db,
+ target_id="bitpro",
+ outcome_ids=["out_0", "out_1", "out_1", "out_2", "out_3", "out_4"],
+ development_end=DEV_END,
+ limit=4,
+ )
+
+ assert [entry["outcome_id"] for entry in entries] == [
+ "out_0",
+ "out_1",
+ "out_2",
+ "out_3",
+ ]
+ assert len({entry["memory_id"] for entry in entries}) == 4
+ assert manifest == {
+ "policy": "ablation_memory_v1",
+ "selected": 4,
+ "excluded": {"context_budget": 1},
+ }
+
+
+def test_other_target_and_empty_input(db: Database) -> None:
+ _run(db, run_id="run_bitpro", outcome_id="out_bitpro", result=_result("out_bitpro"))
+ _run(
+ db,
+ run_id="run_quantlab",
+ outcome_id="out_quantlab",
+ target_id="quantlab",
+ result=_result("out_quantlab"),
+ )
+
+ entries, manifest = ablation_memory(
+ db,
+ target_id="bitpro",
+ outcome_ids=["out_quantlab", "out_bitpro"],
+ development_end=DEV_END,
+ )
+ assert [entry["outcome_id"] for entry in entries] == ["out_bitpro"]
+ assert manifest["selected"] == 1
+
+ assert ablation_memory(
+ db, target_id="bitpro", outcome_ids=[], development_end=DEV_END
+ ) == (
+ [],
+ {"policy": "ablation_memory_v1", "selected": 0, "excluded": {}},
+ )
+
+
+def test_oversized_entry_is_excluded(db: Database) -> None:
+ _run(
+ db,
+ run_id="run_huge",
+ outcome_id="out_huge",
+ result=_result("out_huge", harmful_label="fast=12 " + "x" * 5000),
+ )
+
+ entries, manifest = ablation_memory(
+ db,
+ target_id="bitpro",
+ outcome_ids=["out_huge"],
+ development_end=DEV_END,
+ )
+
+ assert entries == []
+ assert manifest["excluded"] == {"entry_too_large": 1}
+
+
+def test_list_ablation_runs_is_newest_first_and_hides_raw_payloads(db: Database) -> None:
+ from datetime import UTC, datetime
+
+ from hypertrade.arc.ablation import list_ablation_runs
+ from hypertrade.arc.evolution_models import ArcAblationStep
+
+ def add_run(run_id: str, status: str, updated_at: datetime, result: dict | None) -> None:
+ with db.session() as session:
+ session.add(
+ ArcAblationRun(
+ id=run_id,
+ outcome_id=f"out_{run_id}",
+ target_id="bitpro",
+ parent_strategy_id="443",
+ challenger_strategy_id=run_id,
+ status=status,
+ reason="",
+ message="m",
+ window_json={"development": ["2026-03-15", "2026-07-12"]},
+ plan_json={"steps": ["internal"]},
+ result_json=result or {},
+ created_at=updated_at,
+ updated_at=updated_at,
+ )
+ )
+
+ def add_step(
+ step_id: str,
+ run_id: str,
+ ordinal: int,
+ *,
+ flagged: bool = False,
+ ) -> None:
+ with db.session() as session:
+ session.add(
+ ArcAblationStep(
+ id=step_id,
+ run_id=run_id,
+ step_key=f"step-{ordinal}",
+ ordinal=ordinal,
+ kind="baseline" if ordinal == 0 else "parameter",
+ status="completed",
+ reason="",
+ checkpoint_json={"reused_from": "abs_source"} if flagged else {},
+ result_json={"backtest_id": 10 + ordinal},
+ charged_at=datetime(2026, 1, 2, tzinfo=UTC) if ordinal == 0 else None,
+ )
+ )
+
+ add_run(
+ "run_skipped",
+ "skipped",
+ datetime(2026, 1, 1, tzinfo=UTC),
+ None,
+ )
+ add_run(
+ "run_running",
+ "running",
+ datetime(2026, 1, 2, tzinfo=UTC),
+ {"harmful": ["not-a-summary-yet"], "metrics": {"net_return": 1}},
+ )
+ add_run(
+ "run_completed",
+ "completed",
+ datetime(2026, 1, 3, tzinfo=UTC),
+ {
+ "harmful": ["fast=12"],
+ "helpful": ["slow=40"],
+ "neutral": [],
+ "unknown": [],
+ "incomparable": [],
+ "note": "dev-only single-factor observation",
+ "metrics": {"net_return": 1},
+ },
+ )
+ # Insertion order differs from ordinal order on purpose.
+ add_step("abs_1", "run_completed", 1, flagged=True)
+ add_step("abs_0", "run_completed", 0)
+
+ runs = list_ablation_runs(db)
+
+ assert [run["id"] for run in runs] == ["run_completed", "run_running", "run_skipped"]
+ completed, running, skipped = runs
+ assert completed["window"] == ["2026-03-15", "2026-07-12"]
+ assert completed["result"] == {
+ "harmful": ["fast=12"],
+ "helpful": ["slow=40"],
+ "neutral": [],
+ "unknown": [],
+ "incomparable": [],
+ "note": "dev-only single-factor observation",
+ }
+ assert running["result"] is None
+ assert skipped["result"] is None
+ assert [step["step_key"] for step in completed["steps"]] == ["step-0", "step-1"]
+ assert completed["steps"][0]["charged_at"] is not None
+ assert completed["steps"][1]["reused_from"] == "abs_source"
+ assert completed["steps"][1]["backtest_id"] == 11
+ for run in runs:
+ assert not {"metrics", "checkpoint_json", "plan_json"} & set(run)
+ for step in run["steps"]:
+ assert not {"metrics", "checkpoint_json", "plan_json"} & set(step)
+ assert [run["id"] for run in list_ablation_runs(db, limit=2)] == [
+ "run_completed",
+ "run_running",
+ ]
+ assert [run["id"] for run in list_ablation_runs(db, limit=0)] == ["run_completed"]
diff --git a/tests/test_arc_ablation_observability.py b/tests/test_arc_ablation_observability.py
new file mode 100644
index 0000000..8023a67
--- /dev/null
+++ b/tests/test_arc_ablation_observability.py
@@ -0,0 +1,322 @@
+"""Ablation budget and effectiveness visibility: read-only counters over persisted rows."""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+import pytest
+from hypertrade.arc import ablation
+from hypertrade.arc.effectiveness import build_effectiveness_report
+from hypertrade.arc.evolution import EvolutionConfig, EvolutionService
+from hypertrade.arc.evolution_models import (
+ ArcAblationRun,
+ ArcAblationStep,
+ ArcForwardOutcome,
+ ArcRuntimeError,
+)
+from hypertrade.arc.research_budget import budget_status
+from hypertrade.arc.store import configure_store, reset_store
+from hypertrade.db import Database
+from sqlalchemy import select
+
+NOW = datetime(2026, 10, 21, 12, tzinfo=UTC)
+
+
+@pytest.fixture
+def db():
+ database = Database("sqlite:///:memory:")
+ database.create_all()
+ configure_store(database)
+ try:
+ yield database
+ finally:
+ reset_store()
+
+
+def _run(
+ db: Database,
+ *,
+ run_id: str,
+ challenger: str,
+ status: str,
+ reason: str = "",
+ next_check_at: datetime | None = None,
+) -> None:
+ with db.session() as session:
+ session.add(
+ ArcAblationRun(
+ id=run_id,
+ outcome_id="out_" + run_id,
+ target_id="bitpro",
+ parent_strategy_id="443",
+ challenger_strategy_id=challenger,
+ status=status,
+ reason=reason,
+ next_check_at=next_check_at,
+ )
+ )
+
+
+def _step(
+ db: Database, *, run_id: str, step_key: str, charged_at: datetime | None = None
+) -> None:
+ with db.session() as session:
+ session.add(
+ ArcAblationStep(
+ id=f"{run_id}:{step_key}",
+ run_id=run_id,
+ step_key=step_key,
+ ordinal=0 if step_key == "baseline" else 1,
+ kind="baseline" if step_key == "baseline" else "single",
+ status="pending",
+ charged_at=charged_at,
+ )
+ )
+
+
+def _loser(
+ db: Database,
+ *,
+ outcome_id: str,
+ challenger: str,
+ lineage: str = "linked",
+ changes: dict[str, Any] | None = None,
+) -> None:
+ with db.session() as session:
+ session.add(
+ ArcForwardOutcome(
+ id=outcome_id,
+ target_id="bitpro",
+ parent_strategy_id="443",
+ challenger_strategy_id=challenger,
+ verdict="lost_futility",
+ lineage_status=lineage,
+ changes_json=(
+ dict(changes)
+ if changes is not None
+ else {"parameter_changes": {"fast": 12, "slow": 40}}
+ ),
+ )
+ )
+
+
+def _seed_ledger(db: Database) -> None:
+ _run(db, run_id="run_pending", challenger="9101", status="pending")
+ _run(
+ db,
+ run_id="run_running",
+ challenger="9102",
+ status="running",
+ reason="backtest_pending",
+ next_check_at=NOW + timedelta(minutes=5),
+ )
+ _run(db, run_id="run_completed", challenger="9103", status="completed")
+ _run(
+ db,
+ run_id="run_skipped",
+ challenger="9104",
+ status="skipped",
+ reason="too_many_changes",
+ )
+ _run(
+ db,
+ run_id="run_failed",
+ challenger="9105",
+ status="failed",
+ reason="parent_relay_hold_timeout",
+ )
+ _step(db, run_id="run_completed", step_key="baseline", charged_at=NOW)
+ _step(db, run_id="run_completed", step_key="param:fast", charged_at=NOW)
+ _step(
+ db,
+ run_id="run_completed",
+ step_key="param:slow",
+ charged_at=NOW - timedelta(days=1),
+ )
+ _loser(db, outcome_id="out_a", challenger="9101")
+ _loser(db, outcome_id="out_b", challenger="9202")
+ _loser(
+ db,
+ outcome_id="out_c",
+ challenger="9203",
+ changes={"parameter_changes": {"fast": 12}},
+ )
+ _loser(db, outcome_id="out_d", challenger="9204", lineage="no_lineage")
+
+
+def _errors(db: Database) -> list[ArcRuntimeError]:
+ with db.session() as session:
+ return list(session.scalars(select(ArcRuntimeError)))
+
+
+def test_budget_status_includes_ablation_view_without_touching_research_counters(
+ db: Database,
+) -> None:
+ _seed_ledger(db)
+
+ state = budget_status(db, EvolutionConfig(), NOW)
+ view = state["ablation"]
+
+ assert view["schema_version"] == "ablation_budget.v1"
+ assert view["enabled"] is True
+ assert view["unit"] == "backtests"
+ assert view["period"] == "2026-10-21"
+ assert view["period_used"] == 2
+ assert view["period_limit"] == 6
+ assert view["per_loser_limit"] == 6
+ assert view["runs"] == {
+ "pending": 1,
+ "running": 1,
+ "completed": 1,
+ "skipped": 1,
+ "failed": 1,
+ }
+ assert view["reasons"] == {
+ "backtest_pending": 1,
+ "too_many_changes": 1,
+ "parent_relay_hold_timeout": 1,
+ }
+ assert view["losers"] == {
+ "eligible": 2,
+ "single_change": 1,
+ "unlinked": 1,
+ "scheduled": 1,
+ }
+ assert view["next_run_at"] == (NOW + timedelta(minutes=5)).isoformat()
+ assert view["reason"] is None
+
+ # The daily research quota is untouched by ablation backtests.
+ assert state["period_used"] == 0
+ assert state["period_limit"] == 4
+ assert "sources" in state and "receipts" in state
+
+
+def test_ablation_view_reports_disabled_and_exhausted(db: Database) -> None:
+ disabled = budget_status(db, EvolutionConfig(ablation_enabled=False), NOW)["ablation"]
+ assert disabled["enabled"] is False
+ assert disabled["reason"] == "disabled"
+
+ _run(db, run_id="run_completed", challenger="9103", status="completed")
+ _step(db, run_id="run_completed", step_key="baseline", charged_at=NOW)
+ _step(db, run_id="run_completed", step_key="param:fast", charged_at=NOW)
+ exhausted = budget_status(
+ db, EvolutionConfig(ablation_max_backtests_per_day=2), NOW
+ )["ablation"]
+ assert exhausted["period_used"] == 2
+ assert exhausted["reason"] == "period_budget_exhausted"
+
+
+def test_budget_status_survives_ablation_view_failure(
+ db: Database, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ def boom(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
+ raise RuntimeError("boom")
+
+ monkeypatch.setattr(ablation, "ablation_budget_view", boom)
+
+ state = budget_status(db, EvolutionConfig(), NOW)
+ assert state["ablation"]["status"] == "unavailable"
+ assert "boom" in state["ablation"]["error"]
+ assert state["period_used"] == 0
+ assert "receipts" in state
+
+
+def test_evolution_status_exposes_ablation_budget(db: Database) -> None:
+ budget = EvolutionService(db).status()["budget"]
+ assert budget["ablation"]["schema_version"] == "ablation_budget.v1"
+
+
+def test_effectiveness_counts_ablation_runs_and_charges(db: Database) -> None:
+ _seed_ledger(db)
+
+ report = build_effectiveness_report(db, now=NOW)
+
+ assert report.ablation_runs_by_status == {
+ "pending": 1,
+ "running": 1,
+ "completed": 1,
+ "skipped": 1,
+ "failed": 1,
+ }
+ assert report.ablation_reasons == {
+ "backtest_pending": 1,
+ "too_many_changes": 1,
+ "parent_relay_hold_timeout": 1,
+ }
+ assert report.ablation_backtests_charged == 3
+ assert report.ablation_error is None
+
+
+def test_effectiveness_survives_ablation_ledger_failure(db: Database) -> None:
+ ArcAblationStep.__table__.drop(db.engine)
+
+ report = build_effectiveness_report(db, now=NOW)
+
+ assert report.schema_version == "evolution_effectiveness.v1"
+ assert report.ablation_error
+ assert report.ablation_runs_by_status == {}
+
+
+def _cancel_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
+ async def cancel(_delay: float) -> None:
+ raise asyncio.CancelledError()
+
+ monkeypatch.setattr(asyncio, "sleep", cancel)
+
+
+def test_evolution_loop_runs_ablation_after_scan_and_alerts(
+ db: Database, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ from hypertrade import worker
+ from hypertrade.arc import evolution_alerts
+
+ calls: list[str] = []
+
+ def tick(_self: Any, now: datetime | None = None) -> dict[str, Any]:
+ calls.append("tick")
+ return {"status": "no_action"}
+
+ monkeypatch.setattr(EvolutionService, "tick", tick)
+ monkeypatch.setattr(
+ evolution_alerts, "evolution_alerts_once", lambda _db: calls.append("alerts")
+ )
+ monkeypatch.setattr(ablation, "ablation_once", lambda _db: calls.append("ablation"))
+ _cancel_sleep(monkeypatch)
+
+ with pytest.raises(asyncio.CancelledError):
+ asyncio.run(worker.arc_evolution_loop(db))
+
+ assert calls == ["tick", "alerts", "ablation"]
+
+
+def test_evolution_loop_journals_ablation_failure(
+ db: Database, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ from hypertrade import worker
+ from hypertrade.arc import evolution_alerts
+
+ calls: list[str] = []
+
+ def tick(_self: Any, now: datetime | None = None) -> dict[str, Any]:
+ calls.append("tick")
+ return {"status": "no_action"}
+
+ def boom(_db: Database) -> dict[str, Any]:
+ calls.append("ablation")
+ raise RuntimeError("ablation exploded")
+
+ monkeypatch.setattr(EvolutionService, "tick", tick)
+ monkeypatch.setattr(
+ evolution_alerts, "evolution_alerts_once", lambda _db: calls.append("alerts")
+ )
+ monkeypatch.setattr(ablation, "ablation_once", boom)
+ _cancel_sleep(monkeypatch)
+
+ with pytest.raises(asyncio.CancelledError):
+ asyncio.run(worker.arc_evolution_loop(db))
+
+ assert calls == ["tick", "alerts", "ablation"]
+ rows = _errors(db)
+ assert [row.component for row in rows] == ["worker.arc_ablation"]
diff --git a/tests/test_arc_ablation_postgres.py b/tests/test_arc_ablation_postgres.py
new file mode 100644
index 0000000..b035745
--- /dev/null
+++ b/tests/test_arc_ablation_postgres.py
@@ -0,0 +1,72 @@
+"""Ablation migration, ORM writes and unique pairs on an isolated PostgreSQL cluster."""
+
+from __future__ import annotations
+
+import pytest
+from hypertrade.arc.evolution_models import ArcAblationRun, ArcAblationStep
+from hypertrade.db import Database
+from sqlalchemy import inspect, text
+from sqlalchemy.exc import IntegrityError
+from test_arc_event_journal_postgres import _alembic, _cluster
+
+
+def test_postgres_upgrade_write_unique_pair_and_downgrade(tmp_path) -> None:
+ with _cluster(tmp_path) as url:
+ db = Database(url)
+ with db.engine.begin() as connection:
+ connection.execute(
+ text("CREATE TABLE alembic_version (version_num VARCHAR(32) PRIMARY KEY)")
+ )
+ connection.execute(
+ text("INSERT INTO alembic_version VALUES ('0050_arc_forward_outcomes')")
+ )
+
+ _alembic(url, "upgrade", "0051_arc_ablation_runs")
+
+ run = {
+ "outcome_id": "outcome-1",
+ "target_id": "bitpro",
+ "parent_strategy_id": "41",
+ "challenger_strategy_id": "42",
+ }
+ with db.session() as session:
+ session.add(ArcAblationRun(id="run-1", **run))
+ session.add(
+ ArcAblationStep(
+ id="step-1", run_id="run-1", step_key="baseline:41", kind="baseline"
+ )
+ )
+ session.add(
+ ArcAblationStep(
+ id="step-2", run_id="run-1", step_key="change:fast_window", kind="change"
+ )
+ )
+
+ with pytest.raises(IntegrityError), db.session() as session:
+ session.add(
+ ArcAblationRun(
+ id="run-2",
+ **{**run, "outcome_id": "outcome-2"},
+ )
+ )
+
+ db.engine.dispose()
+ _alembic(url, "downgrade", "0050_arc_forward_outcomes")
+ downgraded = Database(url)
+ with downgraded.engine.connect() as connection:
+ version = connection.execute(
+ text("SELECT version_num FROM alembic_version")
+ ).scalar_one()
+ assert version == "0050_arc_forward_outcomes"
+ tables = set(inspect(connection).get_table_names())
+ assert "arc_ablation_runs" not in tables
+ assert "arc_ablation_steps" not in tables
+ downgraded.engine.dispose()
+
+ _alembic(url, "upgrade", "0051_arc_ablation_runs")
+ rerun = Database(url)
+ with rerun.engine.connect() as connection:
+ assert {"arc_ablation_runs", "arc_ablation_steps"}.issubset(
+ set(inspect(connection).get_table_names())
+ )
+ rerun.engine.dispose()
diff --git a/tests/test_arc_ablation_schema.py b/tests/test_arc_ablation_schema.py
new file mode 100644
index 0000000..0f1b876
--- /dev/null
+++ b/tests/test_arc_ablation_schema.py
@@ -0,0 +1,188 @@
+"""Ablation schema: config bounds, idempotent migration, ORM/migration parity, uniques."""
+
+from __future__ import annotations
+
+import importlib.util
+from pathlib import Path
+
+import pytest
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from hypertrade.arc.evolution import EvolutionConfig
+from hypertrade.arc.evolution_models import ArcAblationRun, ArcAblationStep
+from hypertrade.db import Database
+from pydantic import ValidationError
+from sqlalchemy import create_engine, inspect
+from sqlalchemy.exc import IntegrityError
+
+TABLES = ("arc_ablation_runs", "arc_ablation_steps")
+RUN_INDEXES = {
+ "ix_arc_ablation_runs_parent_strategy_id",
+ "ix_arc_ablation_runs_status",
+ "ix_arc_ablation_runs_next_check_at",
+}
+STEP_INDEXES = {
+ "ix_arc_ablation_steps_run_id",
+ "ix_arc_ablation_steps_status",
+ "ix_arc_ablation_steps_request_hash",
+ "ix_arc_ablation_steps_charged_at",
+}
+
+
+def _migration():
+ path = Path(__file__).parents[1] / "backend/alembic/versions/0051_arc_ablation_runs.py"
+ spec = importlib.util.spec_from_file_location("arc_ablation_migration", path)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _schema(database: Database) -> dict[str, dict[str, set[str]]]:
+ inspector = inspect(database.engine)
+ return {
+ table: {
+ "columns": {column["name"] for column in inspector.get_columns(table)},
+ "uniques": {
+ item["name"] for item in inspector.get_unique_constraints(table)
+ },
+ "indexes": {item["name"] for item in inspector.get_indexes(table)},
+ }
+ for table in TABLES
+ }
+
+
+def test_evolution_config_ablation_defaults_and_round_trip() -> None:
+ config = EvolutionConfig.model_validate(
+ {
+ "enabled": True,
+ "max_research_per_day": 32,
+ "interval_minutes": 30,
+ "target_id": "bitpro",
+ }
+ )
+
+ assert config.ablation_enabled is True
+ assert config.ablation_max_backtests_per_day == 6
+ assert config.ablation_max_backtests_per_loser == 6
+ assert EvolutionConfig.model_validate(config.model_dump(mode="json")) == config
+
+
+@pytest.mark.parametrize(
+ "overrides",
+ [
+ {"ablation_max_backtests_per_loser": 2},
+ {"ablation_max_backtests_per_loser": 7},
+ {"ablation_max_backtests_per_day": 0},
+ {"ablation_max_backtests_per_day": 61},
+ {"ablation_foo": 1},
+ ],
+)
+def test_evolution_config_rejects_out_of_range_ablation_values(
+ overrides: dict[str, object],
+) -> None:
+ with pytest.raises(ValidationError):
+ EvolutionConfig.model_validate(overrides)
+
+
+def test_ablation_migration_is_idempotent_and_reversible(tmp_path: Path) -> None:
+ migration = _migration()
+ engine = create_engine(f"sqlite:///{tmp_path / 'migration.db'}")
+
+ with engine.begin() as connection, Operations.context(MigrationContext.configure(connection)):
+ migration.upgrade()
+ migration.upgrade()
+ inspector = inspect(connection)
+ assert set(TABLES).issubset(inspector.get_table_names())
+ assert {
+ item["name"] for item in inspector.get_unique_constraints("arc_ablation_runs")
+ }.issuperset({"uq_arc_ablation_run_challenger", "uq_arc_ablation_run_outcome"})
+ assert {
+ item["name"] for item in inspector.get_unique_constraints("arc_ablation_steps")
+ }.issuperset({"uq_arc_ablation_step"})
+ assert RUN_INDEXES.issubset(
+ {item["name"] for item in inspector.get_indexes("arc_ablation_runs")}
+ )
+ assert STEP_INDEXES.issubset(
+ {item["name"] for item in inspector.get_indexes("arc_ablation_steps")}
+ )
+
+ migration.downgrade()
+ assert not set(TABLES) & set(inspect(connection).get_table_names())
+ migration.downgrade()
+ migration.upgrade()
+ assert set(TABLES).issubset(inspect(connection).get_table_names())
+
+ engine.dispose()
+
+
+def test_ablation_migration_accepts_create_all_database(tmp_path: Path) -> None:
+ db = Database(f"sqlite:///{tmp_path / 'dev.db'}")
+ db.create_all()
+ migration = _migration()
+
+ with db.engine.begin() as connection, Operations.context(
+ MigrationContext.configure(connection)
+ ):
+ migration.upgrade()
+
+ assert set(TABLES).issubset(set(inspect(db.engine).get_table_names()))
+ db.engine.dispose()
+
+
+def test_ablation_migration_matches_orm_schema(tmp_path: Path) -> None:
+ db = Database(f"sqlite:///{tmp_path / 'orm.db'}")
+ db.create_all()
+ orm_schema = _schema(db)
+ db.engine.dispose()
+
+ migration = _migration()
+ migrated = Database(f"sqlite:///{tmp_path / 'migrated.db'}")
+ with migrated.engine.begin() as connection, Operations.context(
+ MigrationContext.configure(connection)
+ ):
+ migration.upgrade()
+ assert _schema(migrated) == orm_schema
+ migrated.engine.dispose()
+
+
+def test_ablation_unique_constraints() -> None:
+ db = Database("sqlite:///:memory:")
+ db.create_all()
+ run = {
+ "outcome_id": "outcome-1",
+ "target_id": "bitpro",
+ "parent_strategy_id": "41",
+ "challenger_strategy_id": "42",
+ }
+
+ with db.session() as session:
+ session.add(ArcAblationRun(id="run-1", **run))
+ session.add(
+ ArcAblationStep(
+ id="step-1", run_id="run-1", step_key="baseline:41", kind="baseline"
+ )
+ )
+
+ with pytest.raises(IntegrityError), db.session() as session:
+ session.add(
+ ArcAblationRun(
+ id="run-2",
+ **{**run, "outcome_id": "outcome-2", "challenger_strategy_id": "42"},
+ )
+ )
+
+ with pytest.raises(IntegrityError), db.session() as session:
+ session.add(
+ ArcAblationRun(
+ id="run-3",
+ **{**run, "challenger_strategy_id": "43"},
+ )
+ )
+
+ with pytest.raises(IntegrityError), db.session() as session:
+ session.add(
+ ArcAblationStep(
+ id="step-2", run_id="run-1", step_key="baseline:41", kind="baseline"
+ )
+ )
diff --git a/tests/test_arc_evolution.py b/tests/test_arc_evolution.py
index 14391d9..3c2fd82 100644
--- a/tests/test_arc_evolution.py
+++ b/tests/test_arc_evolution.py
@@ -983,3 +983,184 @@ def project(self, records, **kwargs):
assert [entry["type"] for entry in view["memory"][:2]] == ["forward_outcome"] * 2
assert view["memory"][2]["memory_id"] == "dev-0"
assert view["memory_in_view"] == {"shown": 20, "total": 27}
+
+
+def _ablation_run(
+ db,
+ *,
+ run_id: str,
+ outcome_id: str,
+ challenger: str,
+ ended_on: str = "2026-07-11",
+):
+ """A completed single-factor ablation whose window ends inside the dev window."""
+ from hypertrade.arc.evolution_models import ArcAblationRun
+
+ result = {
+ "schema_version": "ablation_result.v1",
+ "outcome_id": outcome_id,
+ "verdict": "lost_futility",
+ "parent_strategy_id": "44",
+ "challenger_strategy_id": challenger,
+ "window": ["2026-03-15", ended_on],
+ "capital": "10000",
+ "baseline": {
+ "backtest_id": 11,
+ "net_return": 0.1,
+ "max_drawdown": 0.05,
+ "trade_count": 42,
+ },
+ "effects": [
+ {
+ "step_key": "param:fast_window",
+ "label": "fast_window=20",
+ "status": "completed",
+ "backtest_id": 12,
+ "delta_net_return": -0.02,
+ "delta_max_drawdown": 0.01,
+ "delta_trade_count": 3.0,
+ "class": "harmful",
+ }
+ ],
+ "harmful": ["fast_window=20"],
+ "helpful": [],
+ "neutral": [],
+ "unknown": [],
+ "incomparable": [],
+ "note": "dev-only single-factor observation",
+ }
+ with db.session() as session:
+ session.add(
+ ArcAblationRun(
+ id=run_id,
+ outcome_id=outcome_id,
+ target_id="bitpro",
+ parent_strategy_id="44",
+ challenger_strategy_id=challenger,
+ status="completed",
+ result_json=result,
+ )
+ )
+
+
+def test_memory_places_ablations_after_outcomes_and_before_development(service, monkeypatch):
+ from hypertrade.arc.avo import _evolution_view
+ from hypertrade.arc.store import get_controller
+ from hypertrade.memory.service import MemoryService
+
+ dev = [
+ {"memory_id": f"dev-{i}", "mission_id": f"m-{i}", "candidate_id": f"c-{i}"}
+ for i in range(25)
+ ]
+
+ def project(self, records, **kwargs):
+ return dev, {"policy": "test"}
+
+ monkeypatch.setattr(MemoryService, "project_research", project)
+ _forward_outcome(
+ service.db, parent="43", challenger="44", verdict="won", ended_at="2026-01-01T00:00:00"
+ )
+ lost = _forward_outcome(
+ service.db,
+ parent="44",
+ challenger="4401",
+ verdict="lost_futility",
+ ended_at="2026-01-03T00:00:00",
+ )
+ _ablation_run(service.db, run_id="run_4401", outcome_id=lost["id"], challenger="4401")
+
+ now = _variant_service(service, monkeypatch, 100)
+ result = service.tick(now)
+ assert result["status"] == "research_created"
+ context = get_controller(result["payload"]["mission_id"]).projection.goal.evolution_context
+ memory = context["memory"]
+ assert [entry["type"] for entry in memory[:3]] == [
+ "forward_outcome",
+ "forward_outcome",
+ "ablation",
+ ]
+ assert memory[2]["memory_id"] == "ablation:run_4401"
+ assert [entry["memory_id"] for entry in memory[3:]] == [f"dev-{i}" for i in range(25)]
+ assert context["memory_manifest"]["ablations"] == {
+ "policy": "ablation_memory_v1",
+ "selected": 1,
+ "excluded": {},
+ }
+
+ view = _evolution_view(context)
+ assert len(view["memory"]) == 20
+ assert sum(1 for entry in view["memory"] if entry["memory_id"].startswith("dev-")) >= 12
+
+
+def test_ablations_never_push_development_memory_below_twelve_visible(service, monkeypatch):
+ from hypertrade.arc.store import get_controller
+ from hypertrade.memory.service import MemoryService
+
+ dev = [
+ {"memory_id": f"dev-{i}", "mission_id": f"m-{i}", "candidate_id": f"c-{i}"}
+ for i in range(25)
+ ]
+ monkeypatch.setattr(
+ MemoryService, "project_research", lambda self, records, **kwargs: (dev, {"policy": "test"})
+ )
+ for index in range(8):
+ row = _forward_outcome(
+ service.db,
+ parent="44",
+ challenger=f"44{index:02d}",
+ verdict="lost_futility",
+ ended_at=f"2026-01-{index + 1:02d}T00:00:00",
+ )
+ _ablation_run(
+ service.db,
+ run_id=f"run_{index}",
+ outcome_id=row["id"],
+ challenger=f"44{index:02d}",
+ )
+
+ now = _variant_service(service, monkeypatch, 100)
+ result = service.tick(now)
+ assert result["status"] == "research_created"
+ context = get_controller(result["payload"]["mission_id"]).projection.goal.evolution_context
+ memory = context["memory"]
+ assert len(memory) == 33
+ assert [entry["type"] for entry in memory[:8]] == ["forward_outcome"] * 8
+ assert memory[8]["memory_id"] == "dev-0"
+ assert context["memory_manifest"]["ablations"] == {
+ "policy": "ablation_memory_v1",
+ "selected": 0,
+ "excluded": {"context_budget": 8},
+ }
+
+
+def test_memory_continues_when_ablation_memory_fails(service, monkeypatch):
+ import hypertrade.arc.ablation as ablation_module
+ from hypertrade.arc.store import get_controller
+
+ def down(*args, **kwargs):
+ raise RuntimeError("ablation down")
+
+ monkeypatch.setattr(ablation_module, "ablation_memory", down)
+ _forward_outcome(
+ service.db,
+ parent="44",
+ challenger="4401",
+ verdict="lost_futility",
+ ended_at="2026-01-03T00:00:00",
+ )
+
+ now = _variant_service(service, monkeypatch, 100)
+ result = service.tick(now)
+ assert result["status"] == "research_created"
+ context = get_controller(result["payload"]["mission_id"]).projection.goal.evolution_context
+ assert [entry["type"] for entry in context["memory"]] == ["forward_outcome"]
+ ablations = context["memory_manifest"]["ablations"]
+ assert ablations["selected"] == 0
+ assert "ablation down" in ablations["error"]
+
+ from hypertrade.arc.evolution_models import ArcRuntimeError
+ from sqlalchemy import select
+
+ with service.db.session() as session:
+ components = list(session.scalars(select(ArcRuntimeError.component)))
+ assert "evolution.ablation_memory" in components
diff --git a/tests/test_arc_external_surface.py b/tests/test_arc_external_surface.py
index 935f1bd..afe30c4 100644
--- a/tests/test_arc_external_surface.py
+++ b/tests/test_arc_external_surface.py
@@ -628,3 +628,37 @@ def fail(_self) -> dict:
assert response.status_code == 200
assert "error" in response.json()["forward_outcomes"]
+
+
+def test_evolution_ablations_requires_read_scope_and_lists_runs(client: TestClient) -> None:
+ from hypertrade.arc.evolution_models import ArcAblationRun
+
+ with client.app.state.db.session() as session:
+ session.add(
+ ArcAblationRun(
+ id="run_surface",
+ outcome_id="out_surface",
+ target_id="bitpro",
+ parent_strategy_id="44",
+ challenger_strategy_id="4401",
+ status="completed",
+ message="done",
+ result_json={"harmful": ["fast=12"], "note": "dev-only"},
+ )
+ )
+
+ response = client.get("/api/v1/arc/evolution/ablations", headers=_headers(READ_TOKEN))
+
+ assert response.status_code == 200
+ runs = response.json()["runs"]
+ assert isinstance(runs, list)
+ assert [run["id"] for run in runs] == ["run_surface"]
+ assert runs[0]["result"] == {
+ "harmful": ["fast=12"],
+ "helpful": None,
+ "neutral": None,
+ "unknown": None,
+ "incomparable": None,
+ "note": "dev-only",
+ }
+ assert client.get("/api/v1/arc/evolution/ablations").status_code == 401
diff --git a/tests/test_arc_self_test.py b/tests/test_arc_self_test.py
index 144ce0d..2ef8676 100644
--- a/tests/test_arc_self_test.py
+++ b/tests/test_arc_self_test.py
@@ -402,3 +402,68 @@ def test_success_criteria_applies_deflated_sharpe_check():
)
assert passed_multi is False
assert any("deflated_sharpe" in r for r in reasons_multi)
+
+
+def test_source_variant_uses_supplied_sealed_data_reference_without_baseline_creation():
+ from datetime import date
+
+ from hypertrade.arc.contracts import ResearchWindowsV1
+
+ manifest = "a" * 64
+ identity = "b" * 64
+ data = "e" * 64
+ created = []
+ backtests = []
+
+ class Client:
+ def strategy_research_variant_create(self, **kwargs):
+ created.append(kwargs)
+ return {"candidate_strategy_id": 5001, "status": "stopped"}
+
+ def backtest_start_job(self, **kwargs):
+ backtests.append(kwargs)
+ return {
+ "backtest_result": {"id": "bt-1", "metrics": {"net_return": 0.1}},
+ "job": {"job_id": "job-1", "status": "completed"},
+ }
+
+ attempt = ARCCandidateAttemptV1(
+ attempt_id="abl_case_1",
+ candidate_id="abl_case_1",
+ hypothesis="ablation",
+ strategy_code="class Parent: pass\n",
+ strategy_spec={
+ "symbol": "BTC-USDT-SWAP",
+ "timeframe": "1H",
+ "is_source_variant": True,
+ "parent_strategy_id": 443,
+ "parent_manifest_sha256": manifest,
+ "parent_execution_identity_sha256": identity,
+ "parameter_changes": {"fast": 12},
+ },
+ )
+ goal = ARCGoalV1(
+ objective="ablation",
+ research_mode="avo",
+ paper_review_required=True,
+ research_id="ablation-case",
+ research_windows=ResearchWindowsV1(as_of=date(2026, 10, 20)),
+ symbols=["BTC-USDT-SWAP"],
+ timeframes=["1H"],
+ paper_initial_equity=Decimal("100"),
+ )
+ result = ARCSelfTestService(Client()).run(
+ attempt,
+ goal,
+ purpose="development",
+ data_reference={
+ "verified_data_snapshot_id": "vbs_" + data,
+ "verified_data_manifest_sha256": data,
+ },
+ )
+
+ assert result.backtest_id == "bt-1"
+ assert len(created) == 1
+ assert created[0]["purpose"] == "candidate"
+ assert backtests[0]["verified_data_snapshot_id"] == "vbs_" + data
+ assert backtests[0]["verified_data_manifest_sha256"] == data
diff --git a/tests/test_avo_evidence.py b/tests/test_avo_evidence.py
index 51557b4..3d583e9 100644
--- a/tests/test_avo_evidence.py
+++ b/tests/test_avo_evidence.py
@@ -76,3 +76,101 @@ def test_forward_outcome_memory_is_not_reported_as_development_reference():
"ended_at": "2026-01-01T00:00:00",
}
]
+
+
+def test_system_prompt_explains_ablations_without_causal_claims():
+ from hypertrade.arc.avo import _SYSTEM
+
+ assert "memory_id ablation:" in _SYSTEM
+ assert "not causal conclusions" in _SYSTEM
+
+
+def test_ablation_memory_id_is_citable_evidence():
+ import pytest
+ from hypertrade.arc.evolution_memory import bind_hypothesis
+
+ proposal = {
+ "evolution_hypothesis": {
+ "evidence_refs": ["ablation:abl_x"],
+ "expected_metric": "net_return",
+ "expected_direction": "increase",
+ "falsification": "Reject unless the single-factor rerun improves net return",
+ }
+ }
+ context = {"memory": [{"memory_id": "ablation:abl_x", "type": "ablation"}]}
+
+ bound = bind_hypothesis(proposal, context, {})
+ assert bound["status"] == "hypothesis_not_causal_fact"
+
+ with pytest.raises(ValueError, match="cites unavailable evidence"):
+ bind_hypothesis(
+ {
+ "evolution_hypothesis": {
+ **proposal["evolution_hypothesis"],
+ "evidence_refs": ["ablation:missing"],
+ }
+ },
+ context,
+ {},
+ )
+
+
+def test_ablation_memory_is_reported_separately_from_development_references():
+ ctrl = ARCController(
+ goal=ARCGoalV1(
+ objective="test",
+ research_mode="avo",
+ paper_review_required=True,
+ evolution_context={
+ "memory": [
+ {
+ "mission_id": "m_dev",
+ "candidate_id": "c_dev",
+ "development": {"backtest_id": "bt_dev"},
+ },
+ {
+ "memory_id": "outcome:fo_1",
+ "type": "forward_outcome",
+ "verdict": "lost_futility",
+ "relation": "variant_of_current",
+ "generations_back": 0,
+ "parent_strategy_id": "44",
+ "challenger_strategy_id": "4401",
+ "ended_at": "2026-01-03T00:00:00",
+ },
+ {
+ "memory_id": "ablation:run_4401",
+ "type": "ablation",
+ "outcome_id": "fo_1",
+ "verdict": "lost_futility",
+ "parent_strategy_id": "44",
+ "challenger_strategy_id": "4401",
+ "window": ["2026-03-15", "2026-07-12"],
+ "harmful": ["fast_window=20"],
+ "helpful": ["slow_window=40"],
+ "effects": [{"label": "fast_window=20", "class": "harmful"}],
+ },
+ ]
+ },
+ )
+ )
+ evolution = build_evidence_view(ctrl.projection)["research"]["evolution"]
+
+ assert evolution["memory_references"] == [
+ {"mission_id": "m_dev", "candidate_id": "c_dev", "backtest_id": "bt_dev"}
+ ]
+ assert [row["memory_id"] for row in evolution["forward_outcome_references"]] == [
+ "outcome:fo_1"
+ ]
+ assert evolution["ablation_references"] == [
+ {
+ "memory_id": "ablation:run_4401",
+ "outcome_id": "fo_1",
+ "verdict": "lost_futility",
+ "parent_strategy_id": "44",
+ "challenger_strategy_id": "4401",
+ "window": ["2026-03-15", "2026-07-12"],
+ "harmful": ["fast_window=20"],
+ "helpful": ["slow_window=40"],
+ }
+ ]