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"], + } + ]