Add Progeny replay arena experiments
This commit is contained in:
parent
2f142b59c4
commit
9cd10d4559
8 changed files with 1084 additions and 2 deletions
520
agents/replay_arena.py
Normal file
520
agents/replay_arena.py
Normal file
|
|
@ -0,0 +1,520 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from statistics import median
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from agents.progeny import ProgenyService
|
||||||
|
from control_plane.agents.models import (
|
||||||
|
AgentVersion,
|
||||||
|
ExperimentComparison,
|
||||||
|
ExperimentVariant,
|
||||||
|
ImprovementCandidate,
|
||||||
|
ProgenyExperiment,
|
||||||
|
ReplayCase,
|
||||||
|
ReplayDataset,
|
||||||
|
ReplayResult,
|
||||||
|
ReplayRun,
|
||||||
|
)
|
||||||
|
from control_plane.events.bus import EventBus
|
||||||
|
from control_plane.projects.models import CommitRecord, Milestone, Project, ProjectPlan, Task, TaskStatus, Worktree
|
||||||
|
from control_plane.verification.models import Review, TestRun, Verification, VerificationResult
|
||||||
|
from graph.bootstrap import champion_task_execution_graph_v1
|
||||||
|
from graph.langgraph_runtime import LangGraphRuntime
|
||||||
|
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus, GraphApproval, GraphApprovalStatus, GraphRun
|
||||||
|
from graph.native_runtime import GraphExecutionContext
|
||||||
|
from graph.registry import NodeHandlerRegistry, NodeResult
|
||||||
|
from graph.task_execution import task_execution_graph_v2_static_analysis
|
||||||
|
from graph.task_nodes import TaskExecutionServices, task_execution_registry
|
||||||
|
from model_router.router import ModelRouter
|
||||||
|
|
||||||
|
|
||||||
|
INFRA_FAILURES = {"PROVIDER_FAILURE", "INFRASTRUCTURE_FAILURE", "REPLAY_RUNTIME_FAILURE", "EVALUATOR_FAILURE"}
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayArena:
|
||||||
|
def __init__(self, router: ModelRouter | None = None, bus: EventBus | None = None, *, test_command: list[str] | None = None) -> None:
|
||||||
|
self.router = router or ModelRouter({})
|
||||||
|
self.bus = bus or EventBus()
|
||||||
|
self.test_command = test_command or ["python", "-m", "pytest"]
|
||||||
|
|
||||||
|
def create_dataset(self, name: str, *, description: str = "", version: int = 1, selection_criteria: dict[str, object] | None = None) -> ReplayDataset:
|
||||||
|
return ReplayDataset.objects.create(name=name, description=description, version=version, selection_criteria=selection_criteria or {})
|
||||||
|
|
||||||
|
def add_case_from_task(self, dataset: ReplayDataset, task: Task, *, failure_classification: str = "", selection_metadata: dict[str, object] | None = None) -> ReplayCase:
|
||||||
|
baseline = self._repository_head(Path(task.project.repository_path)) if task.project.repository_path else ""
|
||||||
|
return ReplayCase.objects.create(
|
||||||
|
replay_dataset=dataset,
|
||||||
|
source_project=task.project,
|
||||||
|
source_task=task,
|
||||||
|
source_task_attempt=task.attempts.order_by("-attempt_number").first(),
|
||||||
|
source_graph_run=task.graph_runs.order_by("-created_at").first(),
|
||||||
|
task_type=task.task_type,
|
||||||
|
project_type=task.project.project_type,
|
||||||
|
original_goal=task.goal,
|
||||||
|
acceptance_criteria=task.acceptance_criteria,
|
||||||
|
repository_path=task.project.repository_path,
|
||||||
|
repository_baseline_ref=baseline,
|
||||||
|
expected_evaluator_inputs={"acceptance_criteria": task.acceptance_criteria},
|
||||||
|
failure_classification=failure_classification,
|
||||||
|
selection_metadata=selection_metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_dataset_from_history(self, name: str, **filters: object) -> ReplayDataset:
|
||||||
|
dataset = self.create_dataset(name, selection_criteria=filters)
|
||||||
|
tasks = Task.objects.select_related("project", "milestone").all()
|
||||||
|
if filters.get("task_type"):
|
||||||
|
tasks = tasks.filter(task_type=filters["task_type"])
|
||||||
|
if filters.get("project"):
|
||||||
|
tasks = tasks.filter(project=filters["project"])
|
||||||
|
if filters.get("outcome"):
|
||||||
|
tasks = tasks.filter(status=filters["outcome"])
|
||||||
|
if filters.get("graph_version"):
|
||||||
|
tasks = tasks.filter(graph_runs__execution_graph_version=filters["graph_version"])
|
||||||
|
if filters.get("agent_version"):
|
||||||
|
tasks = tasks.filter(attempts__coder=filters["agent_version"])
|
||||||
|
if filters.get("date_start"):
|
||||||
|
tasks = tasks.filter(created_at__gte=filters["date_start"])
|
||||||
|
if filters.get("date_end"):
|
||||||
|
tasks = tasks.filter(created_at__lte=filters["date_end"])
|
||||||
|
if filters.get("signal_grouping_key"):
|
||||||
|
tasks = tasks.filter(progenysignal__grouping_key=filters["signal_grouping_key"])
|
||||||
|
max_cases = int(filters.get("max_cases", 10))
|
||||||
|
for task in tasks.distinct().order_by("created_at")[:max_cases]:
|
||||||
|
if task.project.repository_path:
|
||||||
|
self.add_case_from_task(dataset, task)
|
||||||
|
return dataset
|
||||||
|
|
||||||
|
def freeze_dataset(self, dataset: ReplayDataset) -> ReplayDataset:
|
||||||
|
if not dataset.cases.exists():
|
||||||
|
raise ValidationError("ReplayDataset must contain at least one case before freezing.")
|
||||||
|
dataset.status = "FROZEN"
|
||||||
|
dataset.frozen_at = timezone.now()
|
||||||
|
dataset.save(update_fields=["status", "frozen_at", "updated_at"])
|
||||||
|
return dataset
|
||||||
|
|
||||||
|
def create_replacement_dataset_version(self, dataset: ReplayDataset) -> ReplayDataset:
|
||||||
|
return ReplayDataset.objects.create(
|
||||||
|
name=dataset.name,
|
||||||
|
description=dataset.description,
|
||||||
|
version=dataset.version + 1,
|
||||||
|
selection_criteria=dataset.selection_criteria,
|
||||||
|
metadata={"replaces_dataset_id": str(dataset.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_experiment(self, candidate: ImprovementCandidate, dataset: ReplayDataset, *, success_criteria: dict[str, object] | None = None) -> ProgenyExperiment:
|
||||||
|
if dataset.status != "FROZEN":
|
||||||
|
raise ValidationError("Experiments require a frozen replay dataset.")
|
||||||
|
experiment = ProgenyExperiment.objects.create(
|
||||||
|
investigation=candidate.investigation,
|
||||||
|
improvement_candidate=candidate,
|
||||||
|
target_type=candidate.target_type,
|
||||||
|
target_identifier=candidate.target_id,
|
||||||
|
replay_dataset=dataset,
|
||||||
|
hypothesis=candidate.hypothesis,
|
||||||
|
success_criteria=success_criteria or {"minimum_replay_cases": 3, "confidence_threshold": 0.7},
|
||||||
|
status="DRAFT",
|
||||||
|
metadata={"controls": {"single_variable": True}},
|
||||||
|
)
|
||||||
|
return experiment
|
||||||
|
|
||||||
|
def add_champion(self, experiment: ProgenyExperiment, *, graph_version: ExecutionGraphVersion | None = None, agent_version: AgentVersion | None = None) -> ExperimentVariant:
|
||||||
|
return self._add_variant(experiment, "CHAMPION", graph_version=graph_version, agent_version=agent_version)
|
||||||
|
|
||||||
|
def add_challenger(self, experiment: ProgenyExperiment, *, graph_version: ExecutionGraphVersion | None = None, agent_version: AgentVersion | None = None) -> ExperimentVariant:
|
||||||
|
return self._add_variant(experiment, "CHALLENGER", graph_version=graph_version, agent_version=agent_version)
|
||||||
|
|
||||||
|
def ensure_static_analysis_graph_challenger(self) -> ExecutionGraphVersion:
|
||||||
|
spec = task_execution_graph_v2_static_analysis()
|
||||||
|
definition, _ = ExecutionGraphDefinition.objects.get_or_create(name=spec.name, defaults={"graph_type": spec.graph_type, "description": "Task execution graph"})
|
||||||
|
version, _ = ExecutionGraphVersion.objects.get_or_create(
|
||||||
|
graph=definition,
|
||||||
|
version=spec.version,
|
||||||
|
defaults={"status": ExecutionGraphVersionStatus.CHALLENGER, "graph_spec": spec.to_dict(), "metadata": {"parent_version": 1, "change_summary": spec.metadata["change_summary"]}},
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
|
||||||
|
def run_experiment(self, experiment: ProgenyExperiment, *, max_cases: int | None = None) -> ProgenyExperiment:
|
||||||
|
budget = dict(experiment.metadata.get("budget", {})) if isinstance(experiment.metadata, dict) else {}
|
||||||
|
case_cap = max_cases or int(budget.get("maximum_replay_cases", experiment.replay_dataset.cases.count()))
|
||||||
|
model_request_cap = int(budget.get("maximum_model_requests", 10**9))
|
||||||
|
started = time.monotonic()
|
||||||
|
runtime_budget = float(budget.get("runtime_budget_seconds", 10**9))
|
||||||
|
experiment.status = "RUNNING"
|
||||||
|
experiment.started_at = timezone.now()
|
||||||
|
experiment.save(update_fields=["status", "started_at", "updated_at"])
|
||||||
|
for replay_case in experiment.replay_dataset.cases.order_by("created_at")[:case_cap]:
|
||||||
|
for variant in experiment.variants.order_by("role", "created_at"):
|
||||||
|
used_requests = sum(int(run.telemetry.get("model_requests_per_task", 0)) for run in experiment.replay_runs.all())
|
||||||
|
if used_requests >= model_request_cap or time.monotonic() - started > runtime_budget:
|
||||||
|
experiment.status = "FAILED"
|
||||||
|
experiment.completed_at = timezone.now()
|
||||||
|
experiment.metadata = {**experiment.metadata, "incomplete_reason": "budget_exceeded"}
|
||||||
|
experiment.save(update_fields=["status", "completed_at", "metadata", "updated_at"])
|
||||||
|
return experiment
|
||||||
|
self.run_case(experiment, replay_case, variant)
|
||||||
|
experiment.status = "COMPLETE"
|
||||||
|
experiment.completed_at = timezone.now()
|
||||||
|
experiment.save(update_fields=["status", "completed_at", "updated_at"])
|
||||||
|
self.compare(experiment)
|
||||||
|
return experiment
|
||||||
|
|
||||||
|
def run_case(self, experiment: ProgenyExperiment, replay_case: ReplayCase, variant: ExperimentVariant) -> ReplayRun:
|
||||||
|
run = ReplayRun.objects.create(experiment=experiment, replay_case=replay_case, variant=variant, started_at=timezone.now(), status="RUNNING")
|
||||||
|
replay_repo = self._fresh_replay_repository(replay_case, variant)
|
||||||
|
try:
|
||||||
|
replay_task = self._clone_task(replay_case, replay_repo, variant)
|
||||||
|
graph_version = variant.execution_graph_version or champion_task_execution_graph_v1()
|
||||||
|
graph_run = GraphRun.objects.create(
|
||||||
|
execution_graph_version=graph_version,
|
||||||
|
project=replay_task.project,
|
||||||
|
milestone=replay_task.milestone,
|
||||||
|
task=replay_task,
|
||||||
|
current_node=graph_version.graph_spec["entry"],
|
||||||
|
metadata={"replay_experiment_id": str(experiment.id), "replay_variant_id": str(variant.id), "replay_case_id": str(replay_case.id)},
|
||||||
|
)
|
||||||
|
run.graph_run = graph_run
|
||||||
|
run.replay_task = replay_task
|
||||||
|
run.save(update_fields=["graph_run", "replay_task", "updated_at"])
|
||||||
|
overrides = {}
|
||||||
|
if variant.agent_version_id:
|
||||||
|
overrides[variant.agent_version.agent.role] = variant.agent_version
|
||||||
|
services = TaskExecutionServices(self.router, bus=self.bus, test_command=self.test_command, agent_overrides=overrides)
|
||||||
|
LangGraphRuntime(task_execution_registry(services), bus=self.bus).run_until_terminal_or_paused(graph_run)
|
||||||
|
replay_task.refresh_from_db()
|
||||||
|
commit = CommitRecord.objects.filter(task=replay_task).first()
|
||||||
|
run.graph_run = graph_run
|
||||||
|
run.replay_task = replay_task
|
||||||
|
run.commit_candidate_sha = commit.sha if commit else ""
|
||||||
|
run.status = "COMPLETE" if replay_task.status == TaskStatus.COMPLETE else "FAILED"
|
||||||
|
run.failure_classification = "VARIANT_FAILURE" if run.status == "FAILED" else ""
|
||||||
|
run.telemetry = self._run_telemetry(replay_task, graph_run)
|
||||||
|
run.metadata = {"replay_repository_path": str(replay_repo), "production_safe": True, "commit_label": "REPLAY / EXPERIMENTAL"}
|
||||||
|
run.completed_at = timezone.now()
|
||||||
|
run.save(update_fields=["graph_run", "replay_task", "commit_candidate_sha", "status", "failure_classification", "telemetry", "metadata", "completed_at", "updated_at"])
|
||||||
|
self._persist_result(run, replay_task)
|
||||||
|
except Exception as exc:
|
||||||
|
run.status = "FAILED"
|
||||||
|
run.failure_classification = self._classify_replay_exception(exc)
|
||||||
|
run.failure_evidence = {"error": str(exc)}
|
||||||
|
run.completed_at = timezone.now()
|
||||||
|
run.save(update_fields=["status", "failure_classification", "failure_evidence", "completed_at", "updated_at"])
|
||||||
|
return run
|
||||||
|
|
||||||
|
def compare(self, experiment: ProgenyExperiment) -> ExperimentComparison:
|
||||||
|
champion = experiment.variants.get(role="CHAMPION")
|
||||||
|
challenger = experiment.variants.filter(role="CHALLENGER").order_by("created_at").first()
|
||||||
|
if challenger is None:
|
||||||
|
raise ValidationError("Experiment requires a challenger variant.")
|
||||||
|
champion_results = self._eligible_results(experiment, champion)
|
||||||
|
challenger_results = self._eligible_results(experiment, challenger)
|
||||||
|
aggregate = {"CHAMPION": self._aggregate(champion_results), "CHALLENGER": self._aggregate(challenger_results)}
|
||||||
|
aggregate["DELTA"] = self._delta(aggregate["CHAMPION"], aggregate["CHALLENGER"])
|
||||||
|
paired = self._paired_outcomes(experiment, champion, challenger)
|
||||||
|
verdict, reasons = self.judge_experiment(experiment, aggregate, paired)
|
||||||
|
comparison, _ = ExperimentComparison.objects.update_or_create(
|
||||||
|
experiment=experiment,
|
||||||
|
defaults={
|
||||||
|
"champion_variant": champion,
|
||||||
|
"challenger_variant": challenger,
|
||||||
|
"aggregate_metrics": aggregate,
|
||||||
|
"paired_outcomes": paired,
|
||||||
|
"regression_cases": paired["regression_cases"],
|
||||||
|
"verdict": verdict,
|
||||||
|
"reasons": reasons,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return comparison
|
||||||
|
|
||||||
|
def judge_experiment(self, experiment: ProgenyExperiment, aggregate: dict[str, Any], paired: dict[str, Any]) -> tuple[str, list[str]]:
|
||||||
|
minimum = int(experiment.success_criteria.get("minimum_replay_cases", 3))
|
||||||
|
reasons: list[str] = []
|
||||||
|
case_count = paired["case_count"]
|
||||||
|
if "CHAMPION" in aggregate and "CHALLENGER" in aggregate and (aggregate["CHAMPION"].get("case_count", 0) == 0 or aggregate["CHALLENGER"].get("case_count", 0) == 0):
|
||||||
|
return "RUN_MORE_REPLAYS", ["Comparable non-infrastructure results are missing for at least one variant."]
|
||||||
|
if case_count < minimum:
|
||||||
|
return "RUN_MORE_REPLAYS", [f"Only {case_count} paired replay cases; minimum is {minimum}."]
|
||||||
|
if paired["CHAMPION_ONLY_PASS"]:
|
||||||
|
return "REJECT_RECOMMENDED", ["Challenger regressed cases that champion passed."]
|
||||||
|
quality_delta = aggregate["DELTA"].get("accepted_candidate_rate", 0)
|
||||||
|
runtime_delta = aggregate["DELTA"].get("median_runtime_seconds", 0)
|
||||||
|
if quality_delta > 0.05:
|
||||||
|
reasons.append("Challenger materially improves accepted-candidate rate without critical regressions.")
|
||||||
|
return "PROMOTE_RECOMMENDED", reasons
|
||||||
|
if abs(quality_delta) <= 0.01 and runtime_delta < -0.1:
|
||||||
|
reasons.append("Challenger is quality-equivalent with meaningful runtime improvement.")
|
||||||
|
return "PROMOTE_RECOMMENDED", reasons
|
||||||
|
if quality_delta < -0.01:
|
||||||
|
return "REJECT_RECOMMENDED", ["Challenger quality is worse than champion."]
|
||||||
|
return "INCONCLUSIVE", ["No material quality or efficiency improvement detected."]
|
||||||
|
|
||||||
|
def approve_promotion(self, comparison: ExperimentComparison, *, actor: str = "human") -> ExperimentComparison:
|
||||||
|
challenger = comparison.challenger_variant
|
||||||
|
if challenger is None:
|
||||||
|
raise ValidationError("Comparison has no challenger variant.")
|
||||||
|
with transaction.atomic():
|
||||||
|
if challenger.execution_graph_version_id:
|
||||||
|
graph = challenger.execution_graph_version.graph
|
||||||
|
ExecutionGraphVersion.objects.filter(graph=graph, status=ExecutionGraphVersionStatus.CHAMPION).update(status=ExecutionGraphVersionStatus.RETIRED)
|
||||||
|
challenger.execution_graph_version.status = ExecutionGraphVersionStatus.CHAMPION
|
||||||
|
challenger.execution_graph_version.promoted_at = timezone.now()
|
||||||
|
challenger.execution_graph_version.save(update_fields=["status", "promoted_at"])
|
||||||
|
self.bus.publish("EXECUTION_GRAPH_PROMOTED", actor=actor, payload={"experiment_id": str(comparison.experiment_id), "graph_version_id": str(challenger.execution_graph_version_id)})
|
||||||
|
if challenger.agent_version_id:
|
||||||
|
agent = challenger.agent_version.agent
|
||||||
|
if agent.champion_version_id:
|
||||||
|
agent.champion_version.promotion_status = "CANDIDATE"
|
||||||
|
agent.champion_version.save(update_fields=["promotion_status", "updated_at"])
|
||||||
|
challenger.agent_version.promotion_status = "CHAMPION"
|
||||||
|
challenger.agent_version.save(update_fields=["promotion_status", "updated_at"])
|
||||||
|
agent.champion_version = challenger.agent_version
|
||||||
|
agent.save(update_fields=["champion_version", "updated_at"])
|
||||||
|
self.bus.publish("AGENT_EXPERIMENT_PROMOTED", actor=actor, payload={"experiment_id": str(comparison.experiment_id), "agent_version_id": str(challenger.agent_version_id)})
|
||||||
|
comparison.approved_at = timezone.now()
|
||||||
|
comparison.decided_by = actor
|
||||||
|
comparison.save(update_fields=["approved_at", "decided_by", "updated_at"])
|
||||||
|
return comparison
|
||||||
|
|
||||||
|
def reject_promotion(self, comparison: ExperimentComparison, *, actor: str = "human") -> ExperimentComparison:
|
||||||
|
comparison.rejected_at = timezone.now()
|
||||||
|
comparison.decided_by = actor
|
||||||
|
comparison.save(update_fields=["rejected_at", "decided_by", "updated_at"])
|
||||||
|
self.bus.publish("EXPERIMENT_PROMOTION_REJECTED", actor=actor, payload={"experiment_id": str(comparison.experiment_id)})
|
||||||
|
return comparison
|
||||||
|
|
||||||
|
def create_experiment_from_candidate(self, candidate: ImprovementCandidate, dataset: ReplayDataset, **kwargs: object) -> ProgenyExperiment:
|
||||||
|
return self.create_experiment(candidate, dataset, success_criteria=kwargs.get("success_criteria") if isinstance(kwargs.get("success_criteria"), dict) else None)
|
||||||
|
|
||||||
|
def _add_variant(self, experiment: ProgenyExperiment, role: str, *, graph_version: ExecutionGraphVersion | None, agent_version: AgentVersion | None) -> ExperimentVariant:
|
||||||
|
if graph_version is None and agent_version is None:
|
||||||
|
raise ValidationError("Variant requires an execution graph or agent version target.")
|
||||||
|
target_type = "EXECUTION_GRAPH" if graph_version else "AGENT"
|
||||||
|
target = graph_version or agent_version
|
||||||
|
snapshot = self._variant_snapshot(graph_version=graph_version, agent_version=agent_version)
|
||||||
|
return ExperimentVariant.objects.create(
|
||||||
|
experiment=experiment,
|
||||||
|
role=role,
|
||||||
|
target_type=target_type,
|
||||||
|
target_reference=str(target.id),
|
||||||
|
execution_graph_version=graph_version,
|
||||||
|
agent_version=agent_version,
|
||||||
|
configuration_snapshot=snapshot,
|
||||||
|
metadata={"single_variable_control": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _variant_snapshot(self, *, graph_version: ExecutionGraphVersion | None, agent_version: AgentVersion | None) -> dict[str, object]:
|
||||||
|
if graph_version is not None:
|
||||||
|
return {"graph": graph_version.graph.name, "version": graph_version.version, "status": graph_version.status, "graph_spec": graph_version.graph_spec, "metadata": graph_version.metadata}
|
||||||
|
assert agent_version is not None
|
||||||
|
return {
|
||||||
|
"agent": agent_version.agent.name,
|
||||||
|
"role": agent_version.agent.role,
|
||||||
|
"version": agent_version.version,
|
||||||
|
"model": agent_version.model,
|
||||||
|
"system_contract": agent_version.system_contract,
|
||||||
|
"context_policy": agent_version.context_policy,
|
||||||
|
"tools": agent_version.tools,
|
||||||
|
"retry_policy": agent_version.retry_policy,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _repository_head(self, repository_path: Path) -> str:
|
||||||
|
completed = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repository_path, capture_output=True, text=True, check=True)
|
||||||
|
return completed.stdout.strip()
|
||||||
|
|
||||||
|
def _fresh_replay_repository(self, replay_case: ReplayCase, variant: ExperimentVariant) -> Path:
|
||||||
|
source = Path(replay_case.repository_path).resolve()
|
||||||
|
target = source.parent / f"{source.name}-replay-{replay_case.id}-{variant.role.lower()}"
|
||||||
|
if target.exists():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
subprocess.run(["git", "clone", str(source), str(target)], check=True, capture_output=True, text=True)
|
||||||
|
subprocess.run(["git", "checkout", replay_case.repository_baseline_ref], cwd=target, check=True, capture_output=True, text=True)
|
||||||
|
return target
|
||||||
|
|
||||||
|
def _clone_task(self, replay_case: ReplayCase, replay_repo: Path, variant: ExperimentVariant) -> Task:
|
||||||
|
source_project = replay_case.source_project
|
||||||
|
project = Project.objects.create(
|
||||||
|
name=f"REPLAY {variant.role} {source_project.name if source_project else replay_case.id}",
|
||||||
|
project_type=replay_case.project_type or (source_project.project_type if source_project else "WEB_APP"),
|
||||||
|
goal=f"REPLAY / EXPERIMENTAL: {replay_case.original_goal}",
|
||||||
|
repository_path=str(replay_repo),
|
||||||
|
)
|
||||||
|
plan = ProjectPlan.objects.create(project=project, version=1, goal=project.goal)
|
||||||
|
milestone = Milestone.objects.create(project=project, plan=plan, key="REPLAY", title="Replay", goal="Replay experiment")
|
||||||
|
task = Task.objects.create(
|
||||||
|
project=project,
|
||||||
|
milestone=milestone,
|
||||||
|
task_type=replay_case.task_type,
|
||||||
|
status=TaskStatus.RUNNING,
|
||||||
|
goal=replay_case.original_goal,
|
||||||
|
acceptance_criteria=replay_case.acceptance_criteria,
|
||||||
|
max_retries=2,
|
||||||
|
)
|
||||||
|
Worktree.objects.create(
|
||||||
|
task=task,
|
||||||
|
repository_path=str(replay_repo),
|
||||||
|
worktree_path=str(replay_repo),
|
||||||
|
branch_name=f"replay/{variant.role.lower()}/{task.id}",
|
||||||
|
base_ref=replay_case.repository_baseline_ref,
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def _run_telemetry(self, task: Task, graph_run: GraphRun) -> dict[str, object]:
|
||||||
|
telemetry = dict(graph_run.metadata.get("telemetry", {})) if isinstance(graph_run.metadata, dict) else {}
|
||||||
|
telemetry["runtime_seconds"] = (graph_run.completed_at - graph_run.started_at).total_seconds() if graph_run.started_at and graph_run.completed_at else 0
|
||||||
|
telemetry["retry_count"] = task.retry_count
|
||||||
|
telemetry["graph_node_failures"] = graph_run.node_runs.filter(status="FAILED").count()
|
||||||
|
telemetry["model_requests_per_task"] = telemetry.get("model_requests", 0)
|
||||||
|
return telemetry
|
||||||
|
|
||||||
|
def _persist_result(self, run: ReplayRun, task: Task) -> ReplayResult:
|
||||||
|
test_run = TestRun.objects.filter(task=task).order_by("-created_at").first()
|
||||||
|
review = Review.objects.filter(task=task).order_by("-created_at").first()
|
||||||
|
verification = Verification.objects.filter(task=task).order_by("-created_at").first()
|
||||||
|
metrics = {
|
||||||
|
"completion": 1 if task.status == TaskStatus.COMPLETE else 0,
|
||||||
|
"test_pass": 1 if test_run and test_run.status == "PASS" else 0,
|
||||||
|
"review_pass": 1 if review and review.status == "PASS" else 0,
|
||||||
|
"review_rework": 1 if review and review.status == "REWORK_REQUIRED" else 0,
|
||||||
|
"review_reject": 1 if review and review.status == "REJECTED" else 0,
|
||||||
|
"judge_pass": 1 if verification and verification.result == VerificationResult.PASS else 0,
|
||||||
|
"accepted_candidate": 1 if task.status == TaskStatus.COMPLETE else 0,
|
||||||
|
"retry_count": task.retry_count,
|
||||||
|
"retry_exhausted": 1 if task.status == TaskStatus.FAILED else 0,
|
||||||
|
"model_output_invalid": task.progenysignal_set.filter(failure_category="MODEL_OUTPUT_INVALID").count() if hasattr(task, "progenysignal_set") else 0,
|
||||||
|
"mutation_failures": run.telemetry.get("mutation_failures", 0),
|
||||||
|
"patch_mismatch": run.telemetry.get("patch_mismatches", 0),
|
||||||
|
"runtime_seconds": run.telemetry.get("runtime_seconds", 0),
|
||||||
|
"model_requests": run.telemetry.get("model_requests_per_task", 0),
|
||||||
|
"mutation_operations": run.telemetry.get("mutation_operations", 0),
|
||||||
|
}
|
||||||
|
return ReplayResult.objects.create(
|
||||||
|
replay_run=run,
|
||||||
|
completion_status=task.status,
|
||||||
|
tests_status=test_run.status if test_run else "",
|
||||||
|
reviewer_status=review.status if review else "",
|
||||||
|
judge_status=verification.result if verification else "",
|
||||||
|
accepted_candidate=task.status == TaskStatus.COMPLETE,
|
||||||
|
metrics=metrics,
|
||||||
|
safety={"unexpected_file_scope_changes": 0, "policy_violations": 0, "duplicate_side_effect_attempts": 0},
|
||||||
|
evidence={"commit_candidate_sha": run.commit_candidate_sha, "failure_classification": run.failure_classification},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _eligible_results(self, experiment: ProgenyExperiment, variant: ExperimentVariant) -> list[ReplayResult]:
|
||||||
|
return list(
|
||||||
|
ReplayResult.objects.filter(replay_run__experiment=experiment, replay_run__variant=variant)
|
||||||
|
.exclude(replay_run__failure_classification__in=INFRA_FAILURES)
|
||||||
|
.select_related("replay_run", "replay_run__replay_case")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _aggregate(self, results: list[ReplayResult]) -> dict[str, float]:
|
||||||
|
count = len(results)
|
||||||
|
if count == 0:
|
||||||
|
return {"case_count": 0}
|
||||||
|
keys = ["completion", "test_pass", "review_pass", "review_rework", "review_reject", "judge_pass", "accepted_candidate", "retry_exhausted", "model_output_invalid", "mutation_failures", "patch_mismatch", "model_requests", "mutation_operations"]
|
||||||
|
aggregate = {"case_count": float(count)}
|
||||||
|
for key in keys:
|
||||||
|
total = sum(float(result.metrics.get(key, 0)) for result in results)
|
||||||
|
aggregate[f"{key}_rate" if key in {"completion", "test_pass", "review_pass", "review_rework", "review_reject", "judge_pass", "accepted_candidate", "retry_exhausted", "model_output_invalid"} else f"{key}_per_case"] = total / count
|
||||||
|
aggregate["median_runtime_seconds"] = median([float(result.metrics.get("runtime_seconds", 0)) for result in results])
|
||||||
|
return aggregate
|
||||||
|
|
||||||
|
def _delta(self, champion: dict[str, float], challenger: dict[str, float]) -> dict[str, float]:
|
||||||
|
return {key: challenger.get(key, 0) - champion.get(key, 0) for key in set(champion) | set(challenger) if key != "case_count"}
|
||||||
|
|
||||||
|
def _paired_outcomes(self, experiment: ProgenyExperiment, champion: ExperimentVariant, challenger: ExperimentVariant) -> dict[str, object]:
|
||||||
|
outcomes = {"BOTH_PASS": [], "BOTH_FAIL": [], "CHAMPION_ONLY_PASS": [], "CHALLENGER_ONLY_PASS": []}
|
||||||
|
for replay_case in experiment.replay_dataset.cases.all():
|
||||||
|
champion_result = ReplayResult.objects.filter(replay_run__experiment=experiment, replay_run__variant=champion, replay_run__replay_case=replay_case).first()
|
||||||
|
challenger_result = ReplayResult.objects.filter(replay_run__experiment=experiment, replay_run__variant=challenger, replay_run__replay_case=replay_case).first()
|
||||||
|
if not champion_result or not challenger_result:
|
||||||
|
continue
|
||||||
|
if champion_result.replay_run.failure_classification in INFRA_FAILURES or challenger_result.replay_run.failure_classification in INFRA_FAILURES:
|
||||||
|
continue
|
||||||
|
champion_pass = champion_result.accepted_candidate
|
||||||
|
challenger_pass = challenger_result.accepted_candidate
|
||||||
|
key = "BOTH_PASS" if champion_pass and challenger_pass else "BOTH_FAIL" if not champion_pass and not challenger_pass else "CHAMPION_ONLY_PASS" if champion_pass else "CHALLENGER_ONLY_PASS"
|
||||||
|
outcomes[key].append(str(replay_case.id))
|
||||||
|
return {**outcomes, "case_count": sum(len(value) for value in outcomes.values()), "regression_cases": outcomes["CHAMPION_ONLY_PASS"]}
|
||||||
|
|
||||||
|
def _classify_replay_exception(self, exc: Exception) -> str:
|
||||||
|
text = str(exc).lower()
|
||||||
|
if "provider" in text or "qwen" in text:
|
||||||
|
return "PROVIDER_FAILURE"
|
||||||
|
if "git" in text or "worktree" in text or "repository" in text:
|
||||||
|
return "REPLAY_RUNTIME_FAILURE"
|
||||||
|
return "INFRASTRUCTURE_FAILURE"
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayArenaNode:
|
||||||
|
idempotent = True
|
||||||
|
replay_safe = True
|
||||||
|
destructive = False
|
||||||
|
|
||||||
|
def __init__(self, arena: ReplayArena, node_type: str) -> None:
|
||||||
|
self.arena = arena
|
||||||
|
self.node_type = node_type
|
||||||
|
|
||||||
|
def experiment(self, context: GraphExecutionContext) -> ProgenyExperiment:
|
||||||
|
return ProgenyExperiment.objects.get(id=context.graph_run.metadata["experiment_id"])
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayNoopNode(ReplayArenaNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
return NodeResult("COMPLETE", "success")
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayRunChampionNode(ReplayArenaNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
experiment = self.experiment(context)
|
||||||
|
champion = experiment.variants.get(role="CHAMPION")
|
||||||
|
for replay_case in experiment.replay_dataset.cases.order_by("created_at"):
|
||||||
|
if not ReplayRun.objects.filter(experiment=experiment, replay_case=replay_case, variant=champion).exists():
|
||||||
|
self.arena.run_case(experiment, replay_case, champion)
|
||||||
|
return NodeResult("COMPLETE", "success")
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayRunChallengerNode(ReplayArenaNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
experiment = self.experiment(context)
|
||||||
|
challenger = experiment.variants.filter(role="CHALLENGER").order_by("created_at").first()
|
||||||
|
if challenger is None:
|
||||||
|
return NodeResult("FAILED", "failure", failure_evidence={"reason": "missing challenger variant"})
|
||||||
|
for replay_case in experiment.replay_dataset.cases.order_by("created_at"):
|
||||||
|
if not ReplayRun.objects.filter(experiment=experiment, replay_case=replay_case, variant=challenger).exists():
|
||||||
|
self.arena.run_case(experiment, replay_case, challenger)
|
||||||
|
return NodeResult("COMPLETE", "success")
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayCompareNode(ReplayArenaNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
comparison = self.arena.compare(self.experiment(context))
|
||||||
|
return NodeResult("COMPLETE", "success", {"comparison_id": str(comparison.id), "verdict": comparison.verdict})
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayHumanDecisionNode(ReplayArenaNode):
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
node_run = context.graph_run.node_runs.filter(node_id=context.graph_run.current_node).order_by("-visit_index").first()
|
||||||
|
if GraphApproval.objects.filter(graph_run=context.graph_run, status=GraphApprovalStatus.APPROVED).exists():
|
||||||
|
return NodeResult("COMPLETE", "approved")
|
||||||
|
if GraphApproval.objects.filter(graph_run=context.graph_run, status=GraphApprovalStatus.REJECTED).exists():
|
||||||
|
return NodeResult("COMPLETE", "rejected")
|
||||||
|
GraphApproval.objects.get_or_create(graph_run=context.graph_run, node_run=node_run, reason="AWAITING_REPLAY_PROMOTION_DECISION")
|
||||||
|
return NodeResult("PAUSED", "awaiting", pause_reason="AWAITING_REPLAY_PROMOTION_DECISION")
|
||||||
|
|
||||||
|
|
||||||
|
def replay_experiment_registry(arena: ReplayArena) -> NodeHandlerRegistry:
|
||||||
|
registry = NodeHandlerRegistry()
|
||||||
|
for node_type in ["replay_prepare", "replay_select_cases", "replay_validate_variants", "replay_experiment_judge"]:
|
||||||
|
registry.register(ReplayNoopNode(arena, node_type))
|
||||||
|
registry.register(ReplayRunChampionNode(arena, "replay_run_champion"))
|
||||||
|
registry.register(ReplayRunChallengerNode(arena, "replay_run_challenger"))
|
||||||
|
registry.register(ReplayCompareNode(arena, "replay_compare"))
|
||||||
|
registry.register(ReplayHumanDecisionNode(arena, "replay_human_decision"))
|
||||||
|
return registry
|
||||||
145
control_plane/agents/migrations/0007_replay_arena.py
Normal file
145
control_plane/agents/migrations/0007_replay_arena.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("agents", "0006_progenyinvestigation_improvementcandidate"),
|
||||||
|
("graph", "0004_unique_champion_graph_version"),
|
||||||
|
("projects", "0003_commitrecord_graph_run"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ReplayDataset",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("name", models.CharField(max_length=200)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
("version", models.PositiveIntegerField(default=1)),
|
||||||
|
("status", models.CharField(default="DRAFT", max_length=32)),
|
||||||
|
("selection_criteria", models.JSONField(blank=True, default=dict)),
|
||||||
|
("frozen_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ProgenyExperiment",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("target_type", models.CharField(max_length=80)),
|
||||||
|
("target_identifier", models.CharField(blank=True, max_length=240)),
|
||||||
|
("hypothesis", models.TextField()),
|
||||||
|
("success_criteria", models.JSONField(blank=True, default=dict)),
|
||||||
|
("status", models.CharField(default="DRAFT", max_length=32)),
|
||||||
|
("started_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("completed_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
("improvement_candidate", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="experiments", to="agents.improvementcandidate")),
|
||||||
|
("investigation", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="experiments", to="agents.progenyinvestigation")),
|
||||||
|
("replay_dataset", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="experiments", to="agents.replaydataset")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ExperimentVariant",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("role", models.CharField(max_length=32)),
|
||||||
|
("target_type", models.CharField(max_length=80)),
|
||||||
|
("target_reference", models.CharField(max_length=240)),
|
||||||
|
("configuration_snapshot", models.JSONField(default=dict)),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
("agent_version", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="experiment_variants", to="agents.agentversion")),
|
||||||
|
("execution_graph_version", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="experiment_variants", to="graph.executiongraphversion")),
|
||||||
|
("experiment", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="variants", to="agents.progenyexperiment")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ReplayCase",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("task_type", models.CharField(max_length=80)),
|
||||||
|
("project_type", models.CharField(blank=True, max_length=100)),
|
||||||
|
("original_goal", models.TextField()),
|
||||||
|
("acceptance_criteria", models.JSONField(blank=True, default=list)),
|
||||||
|
("repository_path", models.TextField(blank=True)),
|
||||||
|
("repository_baseline_ref", models.CharField(max_length=80)),
|
||||||
|
("expected_evaluator_inputs", models.JSONField(blank=True, default=dict)),
|
||||||
|
("failure_classification", models.CharField(blank=True, max_length=120)),
|
||||||
|
("selection_metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
("replay_dataset", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="cases", to="agents.replaydataset")),
|
||||||
|
("source_graph_run", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="replay_cases", to="graph.graphrun")),
|
||||||
|
("source_project", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="replay_cases", to="projects.project")),
|
||||||
|
("source_task", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="replay_cases", to="projects.task")),
|
||||||
|
("source_task_attempt", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="replay_cases", to="projects.taskattempt")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ReplayRun",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("started_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("completed_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("status", models.CharField(default="PENDING", max_length=32)),
|
||||||
|
("failure_classification", models.CharField(blank=True, max_length=80)),
|
||||||
|
("commit_candidate_sha", models.CharField(blank=True, max_length=64)),
|
||||||
|
("telemetry", models.JSONField(blank=True, default=dict)),
|
||||||
|
("failure_evidence", models.JSONField(blank=True, default=dict)),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
("experiment", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="replay_runs", to="agents.progenyexperiment")),
|
||||||
|
("graph_run", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="replay_runs", to="graph.graphrun")),
|
||||||
|
("replay_case", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="replay_runs", to="agents.replaycase")),
|
||||||
|
("replay_task", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="replay_runs", to="projects.task")),
|
||||||
|
("variant", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="replay_runs", to="agents.experimentvariant")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ReplayResult",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("completion_status", models.CharField(max_length=32)),
|
||||||
|
("tests_status", models.CharField(blank=True, max_length=32)),
|
||||||
|
("reviewer_status", models.CharField(blank=True, max_length=32)),
|
||||||
|
("judge_status", models.CharField(blank=True, max_length=32)),
|
||||||
|
("accepted_candidate", models.BooleanField(default=False)),
|
||||||
|
("metrics", models.JSONField(blank=True, default=dict)),
|
||||||
|
("safety", models.JSONField(blank=True, default=dict)),
|
||||||
|
("evidence", models.JSONField(blank=True, default=dict)),
|
||||||
|
("replay_run", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="result", to="agents.replayrun")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ExperimentComparison",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("aggregate_metrics", models.JSONField(blank=True, default=dict)),
|
||||||
|
("paired_outcomes", models.JSONField(blank=True, default=dict)),
|
||||||
|
("regression_cases", models.JSONField(blank=True, default=list)),
|
||||||
|
("verdict", models.CharField(default="INCONCLUSIVE", max_length=80)),
|
||||||
|
("reasons", models.JSONField(blank=True, default=list)),
|
||||||
|
("approved_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("rejected_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("decided_by", models.CharField(blank=True, max_length=120)),
|
||||||
|
("challenger_variant", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="challenger_comparisons", to="agents.experimentvariant")),
|
||||||
|
("champion_variant", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="champion_comparisons", to="agents.experimentvariant")),
|
||||||
|
("experiment", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="comparison", to="agents.progenyexperiment")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(model_name="replaydataset", constraint=models.UniqueConstraint(fields=("name", "version"), name="unique_replay_dataset_version")),
|
||||||
|
]
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from control_plane.common import TimestampedModel
|
from control_plane.common import TimestampedModel
|
||||||
|
|
@ -129,3 +130,112 @@ class ImprovementCandidate(TimestampedModel):
|
||||||
investigation = models.ForeignKey(ProgenyInvestigation, on_delete=models.SET_NULL, null=True, blank=True, related_name="improvement_candidates")
|
investigation = models.ForeignKey(ProgenyInvestigation, on_delete=models.SET_NULL, null=True, blank=True, related_name="improvement_candidates")
|
||||||
execution_graph_version = models.ForeignKey("graph.ExecutionGraphVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="improvement_candidates")
|
execution_graph_version = models.ForeignKey("graph.ExecutionGraphVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="improvement_candidates")
|
||||||
agent_version = models.ForeignKey(AgentVersion, on_delete=models.SET_NULL, null=True, blank=True, related_name="improvement_candidates")
|
agent_version = models.ForeignKey(AgentVersion, on_delete=models.SET_NULL, null=True, blank=True, related_name="improvement_candidates")
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayDataset(TimestampedModel):
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
version = models.PositiveIntegerField(default=1)
|
||||||
|
status = models.CharField(max_length=32, default="DRAFT")
|
||||||
|
selection_criteria = models.JSONField(default=dict, blank=True)
|
||||||
|
frozen_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [models.UniqueConstraint(fields=["name", "version"], name="unique_replay_dataset_version")]
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayCase(TimestampedModel):
|
||||||
|
replay_dataset = models.ForeignKey(ReplayDataset, on_delete=models.CASCADE, related_name="cases")
|
||||||
|
source_project = models.ForeignKey("projects.Project", on_delete=models.SET_NULL, null=True, blank=True, related_name="replay_cases")
|
||||||
|
source_task = models.ForeignKey("projects.Task", on_delete=models.SET_NULL, null=True, blank=True, related_name="replay_cases")
|
||||||
|
source_task_attempt = models.ForeignKey("projects.TaskAttempt", on_delete=models.SET_NULL, null=True, blank=True, related_name="replay_cases")
|
||||||
|
source_graph_run = models.ForeignKey("graph.GraphRun", on_delete=models.SET_NULL, null=True, blank=True, related_name="replay_cases")
|
||||||
|
task_type = models.CharField(max_length=80)
|
||||||
|
project_type = models.CharField(max_length=100, blank=True)
|
||||||
|
original_goal = models.TextField()
|
||||||
|
acceptance_criteria = models.JSONField(default=list, blank=True)
|
||||||
|
repository_path = models.TextField(blank=True)
|
||||||
|
repository_baseline_ref = models.CharField(max_length=80)
|
||||||
|
expected_evaluator_inputs = models.JSONField(default=dict, blank=True)
|
||||||
|
failure_classification = models.CharField(max_length=120, blank=True)
|
||||||
|
selection_metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
def save(self, *args: object, **kwargs: object) -> None:
|
||||||
|
if self.replay_dataset.status == "FROZEN":
|
||||||
|
raise ValidationError("Frozen replay dataset membership is immutable; create a new dataset version.")
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ProgenyExperiment(TimestampedModel):
|
||||||
|
investigation = models.ForeignKey(ProgenyInvestigation, on_delete=models.SET_NULL, null=True, blank=True, related_name="experiments")
|
||||||
|
improvement_candidate = models.ForeignKey(ImprovementCandidate, on_delete=models.SET_NULL, null=True, blank=True, related_name="experiments")
|
||||||
|
target_type = models.CharField(max_length=80)
|
||||||
|
target_identifier = models.CharField(max_length=240, blank=True)
|
||||||
|
replay_dataset = models.ForeignKey(ReplayDataset, on_delete=models.PROTECT, related_name="experiments")
|
||||||
|
hypothesis = models.TextField()
|
||||||
|
success_criteria = models.JSONField(default=dict, blank=True)
|
||||||
|
status = models.CharField(max_length=32, default="DRAFT")
|
||||||
|
started_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
completed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentVariant(TimestampedModel):
|
||||||
|
experiment = models.ForeignKey(ProgenyExperiment, on_delete=models.CASCADE, related_name="variants")
|
||||||
|
role = models.CharField(max_length=32)
|
||||||
|
target_type = models.CharField(max_length=80)
|
||||||
|
target_reference = models.CharField(max_length=240)
|
||||||
|
execution_graph_version = models.ForeignKey("graph.ExecutionGraphVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="experiment_variants")
|
||||||
|
agent_version = models.ForeignKey(AgentVersion, on_delete=models.SET_NULL, null=True, blank=True, related_name="experiment_variants")
|
||||||
|
configuration_snapshot = models.JSONField(default=dict)
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
def save(self, *args: object, **kwargs: object) -> None:
|
||||||
|
if not self._state.adding:
|
||||||
|
previous = ExperimentVariant.objects.get(pk=self.pk)
|
||||||
|
if previous.configuration_snapshot != self.configuration_snapshot or previous.target_reference != self.target_reference:
|
||||||
|
raise ValidationError("Experiment variant configuration is immutable; create a new variant.")
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayRun(TimestampedModel):
|
||||||
|
experiment = models.ForeignKey(ProgenyExperiment, on_delete=models.CASCADE, related_name="replay_runs")
|
||||||
|
variant = models.ForeignKey(ExperimentVariant, on_delete=models.CASCADE, related_name="replay_runs")
|
||||||
|
replay_case = models.ForeignKey(ReplayCase, on_delete=models.CASCADE, related_name="replay_runs")
|
||||||
|
graph_run = models.ForeignKey("graph.GraphRun", on_delete=models.SET_NULL, null=True, blank=True, related_name="replay_runs")
|
||||||
|
replay_task = models.ForeignKey("projects.Task", on_delete=models.SET_NULL, null=True, blank=True, related_name="replay_runs")
|
||||||
|
started_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
completed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
status = models.CharField(max_length=32, default="PENDING")
|
||||||
|
failure_classification = models.CharField(max_length=80, blank=True)
|
||||||
|
commit_candidate_sha = models.CharField(max_length=64, blank=True)
|
||||||
|
telemetry = models.JSONField(default=dict, blank=True)
|
||||||
|
failure_evidence = models.JSONField(default=dict, blank=True)
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayResult(TimestampedModel):
|
||||||
|
replay_run = models.OneToOneField(ReplayRun, on_delete=models.CASCADE, related_name="result")
|
||||||
|
completion_status = models.CharField(max_length=32)
|
||||||
|
tests_status = models.CharField(max_length=32, blank=True)
|
||||||
|
reviewer_status = models.CharField(max_length=32, blank=True)
|
||||||
|
judge_status = models.CharField(max_length=32, blank=True)
|
||||||
|
accepted_candidate = models.BooleanField(default=False)
|
||||||
|
metrics = models.JSONField(default=dict, blank=True)
|
||||||
|
safety = models.JSONField(default=dict, blank=True)
|
||||||
|
evidence = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentComparison(TimestampedModel):
|
||||||
|
experiment = models.OneToOneField(ProgenyExperiment, on_delete=models.CASCADE, related_name="comparison")
|
||||||
|
champion_variant = models.ForeignKey(ExperimentVariant, on_delete=models.SET_NULL, null=True, blank=True, related_name="champion_comparisons")
|
||||||
|
challenger_variant = models.ForeignKey(ExperimentVariant, on_delete=models.SET_NULL, null=True, blank=True, related_name="challenger_comparisons")
|
||||||
|
aggregate_metrics = models.JSONField(default=dict, blank=True)
|
||||||
|
paired_outcomes = models.JSONField(default=dict, blank=True)
|
||||||
|
regression_cases = models.JSONField(default=list, blank=True)
|
||||||
|
verdict = models.CharField(max_length=80, default="INCONCLUSIVE")
|
||||||
|
reasons = models.JSONField(default=list, blank=True)
|
||||||
|
approved_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
rejected_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
decided_by = models.CharField(max_length=120, blank=True)
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,9 @@ class LangGraphRuntime(GraphRuntime):
|
||||||
if not edges:
|
if not edges:
|
||||||
workflow.add_edge(node_id, END)
|
workflow.add_edge(node_id, END)
|
||||||
continue
|
continue
|
||||||
workflow.add_conditional_edges(node_id, self._route(graph_run, spec, node_id), {edge.condition or "success": edge.target for edge in edges})
|
route_map = {edge.condition or "success": edge.target for edge in edges}
|
||||||
|
route_map["__end__"] = END
|
||||||
|
workflow.add_conditional_edges(node_id, self._route(graph_run, spec, node_id), route_map)
|
||||||
compiled = workflow.compile()
|
compiled = workflow.compile()
|
||||||
try:
|
try:
|
||||||
compiled.invoke({"graph_run_id": graph_run.id, "current_node": graph_run.current_node or spec.entry})
|
compiled.invoke({"graph_run_id": graph_run.id, "current_node": graph_run.current_node or spec.entry})
|
||||||
|
|
@ -104,6 +106,8 @@ class LangGraphRuntime(GraphRuntime):
|
||||||
def _route(self, graph_run: GraphRun, spec: ExecutionGraphSpec, node_id: str):
|
def _route(self, graph_run: GraphRun, spec: ExecutionGraphSpec, node_id: str):
|
||||||
def route(state: LangGraphState) -> str:
|
def route(state: LangGraphState) -> str:
|
||||||
graph_run.refresh_from_db()
|
graph_run.refresh_from_db()
|
||||||
|
if graph_run.status != GraphRunStatus.RUNNING:
|
||||||
|
return "__end__"
|
||||||
edge_result = str(state.get("edge_result") or graph_run.metadata.get("interrupted_edge_result") or "success")
|
edge_result = str(state.get("edge_result") or graph_run.metadata.get("interrupted_edge_result") or "success")
|
||||||
target = None
|
target = None
|
||||||
for edge in spec.edges:
|
for edge in spec.edges:
|
||||||
|
|
@ -130,7 +134,7 @@ class LangGraphRuntime(GraphRuntime):
|
||||||
ProgenyService(self.bus).create_graph_runtime_signal(graph_run, "GRAPH_RUNTIME_ERROR", graph_run.failure_reason, {"node_id": node_id, "edge_result": edge_result}, graph_node_run=node_run)
|
ProgenyService(self.bus).create_graph_runtime_signal(graph_run, "GRAPH_RUNTIME_ERROR", graph_run.failure_reason, {"node_id": node_id, "edge_result": edge_result}, graph_node_run=node_run)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return edge_result
|
return "__end__"
|
||||||
GraphEdgeTraversal.objects.create(graph_run=graph_run, source_node=node_id, target_node=target, condition=edge_result, result=edge_result)
|
GraphEdgeTraversal.objects.create(graph_run=graph_run, source_node=node_id, target_node=target, condition=edge_result, result=edge_result)
|
||||||
metadata = dict(graph_run.metadata)
|
metadata = dict(graph_run.metadata)
|
||||||
metadata["last_node_id"] = node_id
|
metadata["last_node_id"] = node_id
|
||||||
|
|
|
||||||
38
graph/progeny_replay.py
Normal file
38
graph/progeny_replay.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from graph.spec import ExecutionGraphSpec, GraphEdgeSpec, GraphNodeSpec
|
||||||
|
|
||||||
|
|
||||||
|
def progeny_replay_experiment_graph_v1() -> ExecutionGraphSpec:
|
||||||
|
spec = ExecutionGraphSpec(
|
||||||
|
name="progeny_replay_experiment",
|
||||||
|
version=1,
|
||||||
|
graph_type="PROGENY_REPLAY_EXPERIMENT",
|
||||||
|
entry="prepare_experiment",
|
||||||
|
nodes={
|
||||||
|
"prepare_experiment": GraphNodeSpec("prepare_experiment", "replay_prepare"),
|
||||||
|
"select_cases": GraphNodeSpec("select_cases", "replay_select_cases"),
|
||||||
|
"validate_variants": GraphNodeSpec("validate_variants", "replay_validate_variants"),
|
||||||
|
"run_champion": GraphNodeSpec("run_champion", "replay_run_champion"),
|
||||||
|
"run_challenger": GraphNodeSpec("run_challenger", "replay_run_challenger"),
|
||||||
|
"compare": GraphNodeSpec("compare", "replay_compare"),
|
||||||
|
"experiment_judge": GraphNodeSpec("experiment_judge", "replay_experiment_judge"),
|
||||||
|
"await_human_decision": GraphNodeSpec("await_human_decision", "replay_human_decision"),
|
||||||
|
"complete": GraphNodeSpec("complete", "complete", {"terminal": True}),
|
||||||
|
},
|
||||||
|
edges=[
|
||||||
|
GraphEdgeSpec("prepare_experiment", "select_cases", "success"),
|
||||||
|
GraphEdgeSpec("select_cases", "validate_variants", "success"),
|
||||||
|
GraphEdgeSpec("validate_variants", "run_champion", "success"),
|
||||||
|
GraphEdgeSpec("run_champion", "run_challenger", "success"),
|
||||||
|
GraphEdgeSpec("run_challenger", "compare", "success"),
|
||||||
|
GraphEdgeSpec("compare", "experiment_judge", "success"),
|
||||||
|
GraphEdgeSpec("experiment_judge", "await_human_decision", "success"),
|
||||||
|
GraphEdgeSpec("await_human_decision", "complete", "approved"),
|
||||||
|
GraphEdgeSpec("await_human_decision", "complete", "rejected"),
|
||||||
|
],
|
||||||
|
terminal_nodes=["complete"],
|
||||||
|
metadata={"description": "Replay Arena experiment workflow with human promotion gate."},
|
||||||
|
)
|
||||||
|
spec.validate()
|
||||||
|
return spec
|
||||||
|
|
@ -52,3 +52,30 @@ def task_execution_graph_v1() -> ExecutionGraphSpec:
|
||||||
)
|
)
|
||||||
spec.validate()
|
spec.validate()
|
||||||
return spec
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
def task_execution_graph_v2_static_analysis() -> ExecutionGraphSpec:
|
||||||
|
spec = task_execution_graph_v1()
|
||||||
|
nodes = dict(spec.nodes)
|
||||||
|
nodes["static_analysis"] = GraphNodeSpec("static_analysis", "static_analysis", {"deterministic": True})
|
||||||
|
edges = []
|
||||||
|
for edge in spec.edges:
|
||||||
|
if edge.source == "run_tests" and edge.target == "review":
|
||||||
|
edges.append(GraphEdgeSpec("run_tests", "static_analysis", edge.condition, edge.metadata))
|
||||||
|
elif edge.source == "review" and edge.target == "judge":
|
||||||
|
edges.append(edge)
|
||||||
|
else:
|
||||||
|
edges.append(edge)
|
||||||
|
edges.append(GraphEdgeSpec("static_analysis", "review", "PASS"))
|
||||||
|
challenger = ExecutionGraphSpec(
|
||||||
|
name=TASK_EXECUTION_GRAPH_NAME,
|
||||||
|
version=2,
|
||||||
|
graph_type=spec.graph_type,
|
||||||
|
entry=spec.entry,
|
||||||
|
nodes=nodes,
|
||||||
|
edges=edges,
|
||||||
|
terminal_nodes=spec.terminal_nodes,
|
||||||
|
metadata={"parent_version": 1, "change_summary": "Insert deterministic static_analysis node before Reviewer."},
|
||||||
|
)
|
||||||
|
challenger.validate()
|
||||||
|
return challenger
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class TaskExecutionServices:
|
||||||
*,
|
*,
|
||||||
bus: EventBus | None = None,
|
bus: EventBus | None = None,
|
||||||
test_command: list[str] | None = None,
|
test_command: list[str] | None = None,
|
||||||
|
agent_overrides: dict[str, AgentVersion] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.router = router
|
self.router = router
|
||||||
self.bus = bus or EventBus()
|
self.bus = bus or EventBus()
|
||||||
|
|
@ -39,8 +40,13 @@ class TaskExecutionServices:
|
||||||
self.progeny = ProgenyService(self.bus)
|
self.progeny = ProgenyService(self.bus)
|
||||||
self.tests = DeterministicTestRunner()
|
self.tests = DeterministicTestRunner()
|
||||||
self.test_command = test_command or ["python", "-m", "pytest"]
|
self.test_command = test_command or ["python", "-m", "pytest"]
|
||||||
|
self.agent_overrides = agent_overrides or {}
|
||||||
|
|
||||||
def champion(self, role: AgentRole) -> AgentVersion:
|
def champion(self, role: AgentRole) -> AgentVersion:
|
||||||
|
if role in self.agent_overrides:
|
||||||
|
return self.agent_overrides[role]
|
||||||
|
if role.value in self.agent_overrides:
|
||||||
|
return self.agent_overrides[role.value]
|
||||||
return AgentVersion.objects.select_related("agent").get(agent__role=role, promotion_status="CHAMPION")
|
return AgentVersion.objects.select_related("agent").get(agent__role=role, promotion_status="CHAMPION")
|
||||||
|
|
||||||
def tools(self, worktree: Worktree) -> WorktreeTools:
|
def tools(self, worktree: Worktree) -> WorktreeTools:
|
||||||
|
|
@ -434,6 +440,18 @@ class CommitNode(TaskNode):
|
||||||
return NodeResult("COMPLETE", "success", {"commit_id": str(commit.id), "sha": sha})
|
return NodeResult("COMPLETE", "success", {"commit_id": str(commit.id), "sha": sha})
|
||||||
|
|
||||||
|
|
||||||
|
class StaticAnalysisNode(TaskNode):
|
||||||
|
def __init__(self, services: TaskExecutionServices) -> None:
|
||||||
|
super().__init__(services, "static_analysis")
|
||||||
|
|
||||||
|
def run(self, context: GraphExecutionContext) -> NodeResult:
|
||||||
|
task = self.task(context)
|
||||||
|
metadata = self.metadata(context)
|
||||||
|
metadata["static_analysis"] = {"status": "PASS", "checks": ["deterministic_fixture"]}
|
||||||
|
self.save_metadata(context, metadata)
|
||||||
|
return NodeResult("COMPLETE", "PASS", {"static_analysis_status": "PASS"}, {"static_analysis_checks": 1})
|
||||||
|
|
||||||
|
|
||||||
class CleanupNode(TaskNode):
|
class CleanupNode(TaskNode):
|
||||||
def __init__(self, services: TaskExecutionServices) -> None:
|
def __init__(self, services: TaskExecutionServices) -> None:
|
||||||
super().__init__(services, "cleanup")
|
super().__init__(services, "cleanup")
|
||||||
|
|
@ -458,6 +476,7 @@ def task_execution_registry(services: TaskExecutionServices) -> NodeHandlerRegis
|
||||||
ReviewNode(services),
|
ReviewNode(services),
|
||||||
JudgeNode(services),
|
JudgeNode(services),
|
||||||
CommitNode(services),
|
CommitNode(services),
|
||||||
|
StaticAnalysisNode(services),
|
||||||
RetryOrFailNode(services),
|
RetryOrFailNode(services),
|
||||||
CleanupNode(services),
|
CleanupNode(services),
|
||||||
]:
|
]:
|
||||||
|
|
|
||||||
219
tests/test_replay_arena.py
Normal file
219
tests/test_replay_arena.py
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from agents.providers import DeterministicCodingProvider
|
||||||
|
from agents.replay_arena import ReplayArena, replay_experiment_registry
|
||||||
|
from control_plane.agents.management.commands.seed_core_agents import Command as SeedAgentsCommand
|
||||||
|
from control_plane.agents.models import Agent, AgentRole, AgentVersion, ExperimentComparison, ExperimentVariant, ImprovementCandidate, ProgenyExperiment, PromotionStatus, ReplayResult, ReplayRun
|
||||||
|
from control_plane.projects.models import CommitRecord, Milestone, Project, ProjectPlan, Task, TaskStatus
|
||||||
|
from graph.bootstrap import champion_task_execution_graph_v1
|
||||||
|
from graph.models import ExecutionGraphDefinition, ExecutionGraphVersion, ExecutionGraphVersionStatus, GraphRun, GraphRunStatus
|
||||||
|
from graph.native_runtime import NativeGraphRuntime
|
||||||
|
from graph.progeny_replay import progeny_replay_experiment_graph_v1
|
||||||
|
from graph.task_execution import task_execution_graph_v1
|
||||||
|
from model_router.router import ModelRouter
|
||||||
|
from tests.test_m2_autonomous_loop import create_disposable_django_repo
|
||||||
|
|
||||||
|
|
||||||
|
def source_task(repo: Path) -> Task:
|
||||||
|
project = Project.objects.create(name="Replay Source", goal="Source", repository_path=str(repo))
|
||||||
|
plan = ProjectPlan.objects.create(project=project, version=1, goal="Source")
|
||||||
|
milestone = Milestone.objects.create(project=project, plan=plan, key="R1", title="Replay", goal="Replay")
|
||||||
|
return Task.objects.create(
|
||||||
|
project=project,
|
||||||
|
milestone=milestone,
|
||||||
|
task_type="implementation",
|
||||||
|
status=TaskStatus.COMPLETE,
|
||||||
|
goal='Add a /health endpoint returning JSON {"status": "ok"} and add tests.',
|
||||||
|
acceptance_criteria=["/health returns ok", "tests pass"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def candidate(target_type: str = "EXECUTION_GRAPH") -> ImprovementCandidate:
|
||||||
|
return ImprovementCandidate.objects.create(target_type=target_type, hypothesis="Compare challenger against champion", recommended_route="Progeny Graph Evolution")
|
||||||
|
|
||||||
|
|
||||||
|
def frozen_dataset(tmp_path: Path, arena: ReplayArena, *, count: int = 1):
|
||||||
|
dataset = arena.create_dataset("Replay Dataset", selection_criteria={"task_type": "implementation"})
|
||||||
|
cases = []
|
||||||
|
for index in range(count):
|
||||||
|
root = tmp_path / f"repo-{index}"
|
||||||
|
root.mkdir()
|
||||||
|
task = source_task(create_disposable_django_repo(root))
|
||||||
|
cases.append(arena.add_case_from_task(dataset, task))
|
||||||
|
arena.freeze_dataset(dataset)
|
||||||
|
return dataset, cases
|
||||||
|
|
||||||
|
|
||||||
|
def experiment_with_variants(tmp_path: Path, *, count: int = 1, minimum: int = 1):
|
||||||
|
SeedAgentsCommand().handle()
|
||||||
|
arena = ReplayArena(ModelRouter({"qwen": DeterministicCodingProvider()}), test_command=["python", "manage.py", "test"])
|
||||||
|
dataset, cases = frozen_dataset(tmp_path, arena, count=count)
|
||||||
|
experiment = arena.create_experiment(candidate(), dataset, success_criteria={"minimum_replay_cases": minimum})
|
||||||
|
champion = arena.add_champion(experiment, graph_version=champion_task_execution_graph_v1())
|
||||||
|
challenger = arena.add_challenger(experiment, graph_version=arena.ensure_static_analysis_graph_challenger())
|
||||||
|
return arena, dataset, cases, experiment, champion, challenger
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_dataset_freeze_makes_membership_immutable_and_replacement_versions(tmp_path: Path) -> None:
|
||||||
|
arena = ReplayArena()
|
||||||
|
dataset, _ = frozen_dataset(tmp_path, arena)
|
||||||
|
new_root = tmp_path / "new-repo"
|
||||||
|
new_root.mkdir()
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
arena.add_case_from_task(dataset, source_task(create_disposable_django_repo(new_root)))
|
||||||
|
|
||||||
|
replacement = arena.create_replacement_dataset_version(dataset)
|
||||||
|
|
||||||
|
assert dataset.status == "FROZEN"
|
||||||
|
assert replacement.version == dataset.version + 1
|
||||||
|
assert replacement.status == "DRAFT"
|
||||||
|
assert replacement.metadata["replaces_dataset_id"] == str(dataset.id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_experiment_variants_are_immutable_and_preserve_target_lineage(tmp_path: Path) -> None:
|
||||||
|
arena, _, _, experiment, champion, challenger = experiment_with_variants(tmp_path)
|
||||||
|
|
||||||
|
assert champion.role == "CHAMPION"
|
||||||
|
assert champion.execution_graph_version.status == ExecutionGraphVersionStatus.CHAMPION
|
||||||
|
assert challenger.role == "CHALLENGER"
|
||||||
|
assert challenger.execution_graph_version.version == 2
|
||||||
|
assert challenger.configuration_snapshot["graph_spec"]["metadata"]["parent_version"] == 1
|
||||||
|
|
||||||
|
challenger.target_reference = "changed"
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
challenger.save()
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_version_variant_supported_with_policy_snapshot(tmp_path: Path) -> None:
|
||||||
|
SeedAgentsCommand().handle()
|
||||||
|
arena = ReplayArena(ModelRouter({"qwen": DeterministicCodingProvider()}), test_command=["python", "manage.py", "test"])
|
||||||
|
dataset, _ = frozen_dataset(tmp_path, arena)
|
||||||
|
coder = Agent.objects.get(role=AgentRole.CODER)
|
||||||
|
challenger_agent = AgentVersion.objects.create(agent=coder, version=99, model="qwen", system_contract="Coder challenger", promotion_status=PromotionStatus.CHALLENGER, context_policy={"window": "same"})
|
||||||
|
experiment = arena.create_experiment(candidate("AGENT"), dataset, success_criteria={"minimum_replay_cases": 1})
|
||||||
|
|
||||||
|
variant = arena.add_challenger(experiment, agent_version=challenger_agent)
|
||||||
|
|
||||||
|
assert variant.target_type == "AGENT"
|
||||||
|
assert variant.agent_version == challenger_agent
|
||||||
|
assert variant.configuration_snapshot["system_contract"] == "Coder challenger"
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_execution_uses_fresh_worktree_and_preserves_source_task(tmp_path: Path) -> None:
|
||||||
|
arena, _, cases, experiment, champion, _ = experiment_with_variants(tmp_path)
|
||||||
|
source = cases[0].source_task
|
||||||
|
|
||||||
|
run = arena.run_case(experiment, cases[0], champion)
|
||||||
|
|
||||||
|
source.refresh_from_db()
|
||||||
|
assert source.status == TaskStatus.COMPLETE
|
||||||
|
assert run.status == "COMPLETE", run.graph_run.metadata
|
||||||
|
assert run.replay_task != source
|
||||||
|
assert run.graph_run.execution_graph_version == champion.execution_graph_version
|
||||||
|
assert run.metadata["production_safe"] is True
|
||||||
|
assert run.metadata["commit_label"] == "REPLAY / EXPERIMENTAL"
|
||||||
|
assert CommitRecord.objects.filter(task=source).count() == 0
|
||||||
|
assert CommitRecord.objects.filter(task=run.replay_task).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_comparison_normalizes_metrics_and_detects_paired_regressions(tmp_path: Path) -> None:
|
||||||
|
arena, _, cases, experiment, champion, challenger = experiment_with_variants(tmp_path, count=2, minimum=2)
|
||||||
|
for replay_case in cases:
|
||||||
|
champion_run = ReplayRun.objects.create(experiment=experiment, variant=champion, replay_case=replay_case, status="COMPLETE")
|
||||||
|
ReplayResult.objects.create(replay_run=champion_run, completion_status="COMPLETE", accepted_candidate=True, metrics={"accepted_candidate": 1, "review_pass": 1, "judge_pass": 1, "runtime_seconds": 10})
|
||||||
|
first = ReplayRun.objects.create(experiment=experiment, variant=challenger, replay_case=cases[0], status="COMPLETE")
|
||||||
|
ReplayResult.objects.create(replay_run=first, completion_status="COMPLETE", accepted_candidate=True, metrics={"accepted_candidate": 1, "review_pass": 1, "judge_pass": 1, "runtime_seconds": 8})
|
||||||
|
second = ReplayRun.objects.create(experiment=experiment, variant=challenger, replay_case=cases[1], status="FAILED", failure_classification="VARIANT_FAILURE")
|
||||||
|
ReplayResult.objects.create(replay_run=second, completion_status="FAILED", accepted_candidate=False, metrics={"accepted_candidate": 0, "review_pass": 0, "judge_pass": 0, "runtime_seconds": 8})
|
||||||
|
|
||||||
|
comparison = arena.compare(experiment)
|
||||||
|
|
||||||
|
assert comparison.aggregate_metrics["CHAMPION"]["accepted_candidate_rate"] == 1
|
||||||
|
assert comparison.aggregate_metrics["CHALLENGER"]["accepted_candidate_rate"] == 0.5
|
||||||
|
assert comparison.paired_outcomes["BOTH_PASS"] == [str(cases[0].id)]
|
||||||
|
assert comparison.paired_outcomes["CHAMPION_ONLY_PASS"] == [str(cases[1].id)]
|
||||||
|
assert comparison.regression_cases == [str(cases[1].id)]
|
||||||
|
assert comparison.verdict == "REJECT_RECOMMENDED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infrastructure_failures_are_excluded_from_quality_comparison(tmp_path: Path) -> None:
|
||||||
|
arena, _, cases, experiment, champion, challenger = experiment_with_variants(tmp_path, count=1, minimum=1)
|
||||||
|
champion_run = ReplayRun.objects.create(experiment=experiment, variant=champion, replay_case=cases[0], status="FAILED", failure_classification="INFRASTRUCTURE_FAILURE")
|
||||||
|
ReplayResult.objects.create(replay_run=champion_run, completion_status="FAILED", accepted_candidate=False, metrics={"accepted_candidate": 0})
|
||||||
|
challenger_run = ReplayRun.objects.create(experiment=experiment, variant=challenger, replay_case=cases[0], status="COMPLETE")
|
||||||
|
ReplayResult.objects.create(replay_run=challenger_run, completion_status="COMPLETE", accepted_candidate=True, metrics={"accepted_candidate": 1})
|
||||||
|
|
||||||
|
comparison = arena.compare(experiment)
|
||||||
|
|
||||||
|
assert comparison.aggregate_metrics["CHAMPION"] == {"case_count": 0}
|
||||||
|
assert comparison.verdict in {"INCONCLUSIVE", "RUN_MORE_REPLAYS"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_verdicts_cover_promote_reject_inconclusive_and_more_replays(tmp_path: Path) -> None:
|
||||||
|
arena, _, _, experiment, _, _ = experiment_with_variants(tmp_path, count=1, minimum=3)
|
||||||
|
promote, _ = arena.judge_experiment(experiment, {"DELTA": {"accepted_candidate_rate": 0.2}}, {"case_count": 3, "CHAMPION_ONLY_PASS": []})
|
||||||
|
reject, _ = arena.judge_experiment(experiment, {"DELTA": {"accepted_candidate_rate": 0.2}}, {"case_count": 3, "CHAMPION_ONLY_PASS": ["case"]})
|
||||||
|
more, _ = arena.judge_experiment(experiment, {"DELTA": {"accepted_candidate_rate": 0.2}}, {"case_count": 1, "CHAMPION_ONLY_PASS": []})
|
||||||
|
inconclusive, _ = arena.judge_experiment(experiment, {"DELTA": {"accepted_candidate_rate": 0.0, "median_runtime_seconds": 0.0}}, {"case_count": 3, "CHAMPION_ONLY_PASS": []})
|
||||||
|
|
||||||
|
assert promote == "PROMOTE_RECOMMENDED"
|
||||||
|
assert reject == "REJECT_RECOMMENDED"
|
||||||
|
assert more == "RUN_MORE_REPLAYS"
|
||||||
|
assert inconclusive == "INCONCLUSIVE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_cap_stops_experiment_cleanly(tmp_path: Path) -> None:
|
||||||
|
arena, _, _, experiment, _, _ = experiment_with_variants(tmp_path, count=2)
|
||||||
|
experiment.metadata = {"budget": {"maximum_replay_cases": 1, "maximum_model_requests": 0}}
|
||||||
|
experiment.save(update_fields=["metadata", "updated_at"])
|
||||||
|
|
||||||
|
arena.run_experiment(experiment)
|
||||||
|
experiment.refresh_from_db()
|
||||||
|
|
||||||
|
assert experiment.status == "FAILED"
|
||||||
|
assert experiment.metadata["incomplete_reason"] == "budget_exceeded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_promotion_requires_approval_and_preserves_historical_lineage(tmp_path: Path) -> None:
|
||||||
|
arena, _, cases, experiment, champion, challenger = experiment_with_variants(tmp_path, count=1, minimum=1)
|
||||||
|
historical = GraphRun.objects.create(execution_graph_version=champion.execution_graph_version, current_node="complete", status=GraphRunStatus.COMPLETE)
|
||||||
|
champion_run = ReplayRun.objects.create(experiment=experiment, variant=champion, replay_case=cases[0], status="COMPLETE")
|
||||||
|
ReplayResult.objects.create(replay_run=champion_run, completion_status="COMPLETE", accepted_candidate=True, metrics={"accepted_candidate": 1, "runtime_seconds": 10})
|
||||||
|
challenger_run = ReplayRun.objects.create(experiment=experiment, variant=challenger, replay_case=cases[0], status="COMPLETE")
|
||||||
|
ReplayResult.objects.create(replay_run=challenger_run, completion_status="COMPLETE", accepted_candidate=True, metrics={"accepted_candidate": 1, "runtime_seconds": 5})
|
||||||
|
comparison = arena.compare(experiment)
|
||||||
|
|
||||||
|
assert challenger.execution_graph_version.status == ExecutionGraphVersionStatus.CHALLENGER
|
||||||
|
arena.approve_promotion(comparison, actor="tester")
|
||||||
|
historical.refresh_from_db()
|
||||||
|
champion.execution_graph_version.refresh_from_db()
|
||||||
|
challenger.execution_graph_version.refresh_from_db()
|
||||||
|
|
||||||
|
assert champion.execution_graph_version.status == ExecutionGraphVersionStatus.RETIRED
|
||||||
|
assert challenger.execution_graph_version.status == ExecutionGraphVersionStatus.CHAMPION
|
||||||
|
assert historical.execution_graph_version_id == champion.execution_graph_version_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_progeny_replay_experiment_graph_pauses_at_human_decision() -> None:
|
||||||
|
spec = progeny_replay_experiment_graph_v1()
|
||||||
|
definition = ExecutionGraphDefinition.objects.create(name=spec.name, graph_type=spec.graph_type)
|
||||||
|
version = ExecutionGraphVersion.objects.create(graph=definition, version=1, status=ExecutionGraphVersionStatus.CHAMPION, graph_spec=spec.to_dict())
|
||||||
|
dataset = ReplayArena().create_dataset("Pause Dataset")
|
||||||
|
experiment = ProgenyExperiment.objects.create(replay_dataset=dataset, target_type="EXECUTION_GRAPH", hypothesis="Pause", status="COMPLETE")
|
||||||
|
graph_run = GraphRun.objects.create(execution_graph_version=version, current_node="await_human_decision", metadata={"experiment_id": str(experiment.id)})
|
||||||
|
runtime = NativeGraphRuntime(replay_experiment_registry(ReplayArena()))
|
||||||
|
|
||||||
|
runtime.run_until_terminal_or_paused(graph_run)
|
||||||
|
graph_run.refresh_from_db()
|
||||||
|
assert graph_run.status == GraphRunStatus.PAUSED
|
||||||
|
|
||||||
|
runtime.signal_now(str(graph_run.id), {"action": "approve", "actor": "tester"})
|
||||||
|
runtime.run_until_terminal_or_paused(graph_run)
|
||||||
|
graph_run.refresh_from_db()
|
||||||
|
assert graph_run.status == GraphRunStatus.COMPLETE
|
||||||
Loading…
Add table
Reference in a new issue