Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/routers/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ experiments.py Experiment CRUD + lifecycle (created → prepping → ...)
projects.py Project CRUD
models.py Available model registry (proxies models.yml to the frontend)
registry.py Registered model CRUD
snapshots.py Run snapshots
snapshots.py Run snapshots + reproduce action (replay scripts, diff metrics)
compare.py Multi-experiment compare
data_explorer.py Dataset inspection + preview
lineage.py Lineage graph endpoints (raw → processed → model)
Expand Down
23 changes: 23 additions & 0 deletions backend/routers/snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

from fastapi import APIRouter, HTTPException

from schemas import ReproduceRequest, ReproduceReport
from services.reproduce import (
ManifestUnavailableError,
NoScriptsError,
SnapshotNotFoundError,
reproduce_snapshot,
)
from services.snapshot import get_snapshot, take_snapshot

router = APIRouter()
Expand All @@ -20,3 +27,19 @@ async def read_snapshot(session_id: str):
if not snap:
raise HTTPException(status_code=404, detail="No snapshot for this session yet")
return snap


@router.post(
"/sessions/{session_id}/snapshot/reproduce", response_model=ReproduceReport
)
async def reproduce(session_id: str, body: ReproduceRequest | None = None):
"""Re-execute the snapshot's captured scripts against the hashed data
and diff the resulting metrics against the original run, flagging drift."""
try:
return await reproduce_snapshot(
session_id, tolerance=(body or ReproduceRequest()).tolerance
)
except SnapshotNotFoundError:
raise HTTPException(status_code=404, detail="No snapshot for this session yet")
except (ManifestUnavailableError, NoScriptsError) as e:
raise HTTPException(status_code=409, detail=str(e))
61 changes: 61 additions & 0 deletions backend/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,67 @@ class ProjectUpdate(BaseModel):
sandbox_config: Optional[SandboxConfig] = None


class ReproduceRequest(BaseModel):
"""Optional knobs for the snapshot reproduce action."""

# Relative+absolute tolerance for "same metric value" (see
# services/reproduce.py). Widen for intentionally-stochastic runs.
tolerance: float = Field(default=1e-6, ge=0.0, le=1.0)


class ChangedFile(BaseModel):
path: str
expected_sha256: str
actual_sha256: Optional[str] = None # None => file no longer exists
Comment on lines +100 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 expected_sha256 is declared as str (not Optional[str]), but verify_inputs explicitly sets it to None for files that were added to the workspace after the snapshot was taken. Pydantic v2 will raise a ValidationError when FastAPI serialises the response through response_model=ReproduceReport, returning a 500 to the caller. The unit test test_verify_inputs_detects_added_files exercises this at the raw-dict level and passes, but any real request that hits this path (workspace has a new file) will fail at the HTTP boundary.

Suggested change
class ChangedFile(BaseModel):
path: str
expected_sha256: str
actual_sha256: Optional[str] = None # None => file no longer exists
class ChangedFile(BaseModel):
path: str
expected_sha256: Optional[str] = None # None => file was added (not in snapshot)
actual_sha256: Optional[str] = None # None => file no longer exists

Fix in Claude Code



class ReproduceInputs(BaseModel):
dataset_verified: bool
code_verified: bool
changed_files: list[ChangedFile]


class ReproduceExecution(BaseModel):
returncode: int
scripts: list[str]
stderr_tail: str


class MetricDiffRow(BaseModel):
name: str
original: Optional[float] = None
reproduced: Optional[float] = None
abs_diff: Optional[float] = None
rel_diff: Optional[float] = None
status: Literal["match", "drift", "missing", "new"]


class MetricDiffSummary(BaseModel):
matched: int
drifted: int
missing: int
new: int


class ReproduceMetrics(BaseModel):
original: dict[str, float]
reproduced: dict[str, float]
rows: list[MetricDiffRow]
summary: MetricDiffSummary
drift_detected: bool


class ReproduceReport(BaseModel):
session_id: str
snapshot_id: int
reproduced_at: str
tolerance: float
status: Literal["match", "drift", "error"]
inputs: ReproduceInputs
execution: ReproduceExecution
metrics: ReproduceMetrics


class ExperimentUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=_NAME_MAX)
description: Optional[str] = Field(default=None, max_length=_DESC_MAX)
Expand Down
Loading
Loading