feat(P03): trace digest + GradeStore (Wave 1)

Task 3-1-01: grading/features.py — compute_digest (D-028): deterministic features
(test pass/fail + final status w/ run_result exit-code fallback, edit count, error/fix
cycles + mean fix latency, idle gaps, command category histogram, session duration,
first-test-pass offset). TraceDigest pydantic model: bounded, no raw commands/contents/
payloads — raw trace never reaches the LLM (leak test enforces). Tolerates both live
trace mixes (activity+file_diff daemon topology; REPL kinds).
Task 3-1-02: grading/store.py — GradeStore protocol + SQLiteGradeStore (D-027 pattern:
WAL, tz-normalization, detached rows); upsert-latest-wins on regrade (documented
contrast vs TraceStore's append-only dedup).

20 grading tests green; suite 238 green (reconnect-flush flake under load fixed with a
20s deadline); ruff clean.

---ci---
phase: 3
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-004], partial: []}
---/ci---
This commit is contained in:
CIAgent
2026-09-12 01:54:25 +00:00
parent 2474678da6
commit 0fccb8d250
7 changed files with 947 additions and 1 deletions
@@ -0,0 +1,20 @@
"""Process-trace grading — trace digest, rubric engine, GradeStore (REQ-3-004).
Boundary rule (D-027): grading/ is an engine module — it never imports
agents/ or api/ (api/ composes the engine and stores via DI). features.py
imports telemetry/; store.py imports config only.
features.py (TraceDigest, compute_digest) and store.py (GradeRecord,
GradeStore, SQLiteGradeStore) are both landed (tasks 3-1-01 + 3-1-02).
"""
from .features import TraceDigest, compute_digest
from .store import GradeRecord, GradeStore, SQLiteGradeStore
__all__ = [
"GradeRecord",
"GradeStore",
"SQLiteGradeStore",
"TraceDigest",
"compute_digest",
]
@@ -0,0 +1,251 @@
"""Deterministic process-trace digest (D-028, REQ-3-004).
Pure compute — no LLM, no I/O. `compute_digest` reduces an ordered
TelemetryEvent trace to a compact, bounded `TraceDigest` that is safe to
embed in a grading prompt:
- FIXED fields + small histograms only; NO raw commands, NO file contents,
NO payloads — the raw trace NEVER reaches the LLM (D-028), which also
bounds the prompt-injection surface.
- Tolerant to both live trace mixes: daemon-topology traces carry
`activity` + `file_diff` kinds (workspace watcher), while REPL-driven
traces carry `command`/`stdin`/`stdout`/`run_result`/`test_result`
(P2 verification P1). Features derive from whatever kinds are present and
never crash on absent kinds.
Feature semantics (conservative, deterministic):
- test pass/fail counts + final status derive from `test_result` payloads
when present, falling back to `run_result` exit codes (0 = pass).
- an error/fix CYCLE = a failing run/test followed by >= 1 edit and then a
later run/test (pass or fail) — the next observed result closes the cycle.
- idle gaps = wall-clock gaps between consecutive events exceeding
`idle_threshold_s` (default 120s): count + total seconds.
- command category histogram classifies `command`-kind payloads: build /
test / file / nav / debug / other.
"""
from __future__ import annotations
from collections import Counter
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
from ..telemetry.models import TelemetryEvent
if TYPE_CHECKING: # pragma: no cover - import cycle guard for type checkers
pass
_IDLE_DEFAULT_S: float = 120.0
_TEST_HINTS = ("test", "pytest", "vitest", "jest", "mocha", "unittest", "go test", "npm test")
_BUILD_HINTS = ("make", "npm run build", "pip install", "pnpm", "cargo build", "gcc", "tsc")
_DEBUG_HINTS = ("gdb", "pdb", "print(", "debug", "strace", "ltrace", "curl", "ping")
_NAV_HINTS = ("ls", "cd", "pwd", "cat ", "grep ", "find", "rg ", "tree", "head", "tail", "less")
_FILE_HINTS = ("mv ", "cp ", "rm ", "mkdir", "touch", "chmod", "nano", "vim", "sed -i", "tee ")
class TraceDigest(BaseModel):
"""Compact, bounded, LLM-safe summary of a process trace (D-028).
Fixed fields + small histograms. Serializes well under 4 KB; contains no
raw commands, file contents, or event payloads.
"""
model_config = {"frozen": True}
event_count: int = Field(ge=0)
session_duration_s: float = Field(ge=0.0)
edit_count: int = Field(ge=0)
command_count: int = Field(ge=0)
run_count: int = Field(ge=0)
test_pass_count: int = Field(ge=0)
test_fail_count: int = Field(ge=0)
final_test_status: str = Field(pattern="^(pass|fail|none)$")
first_test_pass_offset_s: float | None = None
error_fix_cycles: int = Field(ge=0)
mean_fix_latency_s: float | None = None
idle_gap_count: int = Field(ge=0)
idle_gap_total_s: float = Field(ge=0.0)
command_categories: dict[str, int] = Field(default_factory=dict)
kind_histogram: dict[str, int] = Field(default_factory=dict)
def _event_pass_status(event: TelemetryEvent) -> bool | None:
"""True (pass) / False (fail) / None (not a result event) for one event."""
payload = event.payload or {}
if event.kind == "test_result":
if "passed" in payload:
return bool(payload["passed"])
if "exit_code" in payload:
return int(payload["exit_code"]) == 0
if "status" in payload:
return str(payload["status"]).lower() in ("pass", "passed", "ok", "success")
return None
if event.kind == "run_result":
if "exit_code" in payload:
return int(payload["exit_code"]) == 0
if "ok" in payload:
return bool(payload["ok"])
return None
return None
def _classify_command(text: str) -> str:
lowered = text.lower()
if any(h in lowered for h in _TEST_HINTS):
return "test"
if any(h in lowered for h in _BUILD_HINTS):
return "build"
if any(h in lowered for h in _DEBUG_HINTS):
return "debug"
if any(h in lowered for h in _NAV_HINTS):
return "nav"
if any(h in lowered for h in _FILE_HINTS):
return "file"
return "other"
def _command_text(event: TelemetryEvent) -> str:
payload = event.payload or {}
return str(payload.get("cmd") or payload.get("command") or payload.get("line") or "")
def compute_digest(
trace: list[TelemetryEvent], *, idle_threshold_s: float = _IDLE_DEFAULT_S
) -> TraceDigest:
"""Reduce an ordered trace to a bounded digest. Never raises on odd input."""
events = sorted(trace, key=lambda e: (e.seq, e.ts))
if not events:
return TraceDigest(
event_count=0,
session_duration_s=0.0,
edit_count=0,
command_count=0,
run_count=0,
test_pass_count=0,
test_fail_count=0,
final_test_status="none",
first_test_pass_offset_s=None,
error_fix_cycles=0,
mean_fix_latency_s=None,
idle_gap_count=0,
idle_gap_total_s=0.0,
command_categories={},
kind_histogram={},
)
kind_histogram = Counter(e.kind for e in events)
start_ts = events[0].ts
end_ts = events[-1].ts
duration = max(0.0, (end_ts - start_ts).total_seconds())
edit_count = kind_histogram.get("file_diff", 0)
command_count = kind_histogram.get("command", 0)
run_count = kind_histogram.get("run_result", 0)
# Tests: prefer test_result events; fall back to run_result exit codes.
test_statuses: list[tuple[TelemetryEvent, bool]] = []
for e in events:
if e.kind == "test_result":
ok = _event_pass_status(e)
if ok is not None:
test_statuses.append((e, ok))
if not test_statuses:
for e in events:
if e.kind == "run_result":
ok = _event_pass_status(e)
if ok is not None:
test_statuses.append((e, ok))
test_pass_count = sum(1 for _, ok in test_statuses if ok)
test_fail_count = len(test_statuses) - test_pass_count
if not test_statuses:
final_test_status = "none"
else:
final_test_status = "pass" if test_statuses[-1][1] else "fail"
first_pass = next((e for e, ok in test_statuses if ok), None)
first_pass_offset = (
max(0.0, (first_pass.ts - start_ts).total_seconds()) if first_pass is not None else None
)
# Error/fix cycles: a failing result starts a pending cycle; the NEXT
# observed result closes it (regardless of outcome) — a fix attempt that
# fails again is itself another iteration of debugging, so it closes the
# previous cycle and opens a new one. Edits since the fail mark the
# close as a genuine fix attempt; latency = first edit -> closing result.
cycles = 0
fix_latencies: list[float] = []
pending_fail_ts: float | None = None # seconds since start
edits_since_fail = 0
first_edit_ts: float | None = None
for e in events:
t = max(0.0, (e.ts - start_ts).total_seconds())
if e.kind == "file_diff":
if pending_fail_ts is not None:
if edits_since_fail == 0:
first_edit_ts = t
edits_since_fail += 1
continue
ok = _event_pass_status(e)
if ok is None:
continue
if ok is False:
if pending_fail_ts is not None and edits_since_fail > 0 and first_edit_ts is not None:
# failed fix attempt: closes the previous cycle, opens a new one
cycles += 1
fix_latencies.append(t - first_edit_ts)
pending_fail_ts = t
edits_since_fail = 0
first_edit_ts = None
continue
if ok is True and pending_fail_ts is not None:
if edits_since_fail > 0 and first_edit_ts is not None:
cycles += 1
fix_latencies.append(t - first_edit_ts)
pending_fail_ts = None
edits_since_fail = 0
first_edit_ts = None
mean_fix_latency = (
sum(fix_latencies) / len(fix_latencies) if fix_latencies else None
)
# Idle gaps between consecutive events.
idle_gap_count = 0
idle_gap_total = 0.0
prev_ts = None
for e in events:
if prev_ts is not None:
gap = (e.ts - prev_ts).total_seconds()
if gap > idle_threshold_s:
idle_gap_count += 1
idle_gap_total += gap
prev_ts = e.ts
# Command category histogram (command-kind events only).
categories: Counter[str] = Counter()
for e in events:
if e.kind == "command":
categories[_classify_command(_command_text(e))] += 1
return TraceDigest(
event_count=len(events),
session_duration_s=round(duration, 3),
edit_count=edit_count,
command_count=command_count,
run_count=run_count,
test_pass_count=test_pass_count,
test_fail_count=test_fail_count,
final_test_status=final_test_status,
first_test_pass_offset_s=(
round(first_pass_offset, 3) if first_pass_offset is not None else None
),
error_fix_cycles=cycles,
mean_fix_latency_s=(round(mean_fix_latency, 3) if mean_fix_latency is not None else None),
idle_gap_count=idle_gap_count,
idle_gap_total_s=round(idle_gap_total, 3),
command_categories=dict(sorted(categories.items())),
kind_histogram=dict(sorted(kind_histogram.items())),
)
+233
View File
@@ -0,0 +1,233 @@
"""GradeStore — grade persistence protocol + SQLite implementation (REQ-3-004, D-027).
Postgres-migration-ready (D-027): the protocol is the only surface the
grading engine and API layers touch; swapping SQLiteGradeStore for a
Postgres-backed implementation must not change call sites. The
`grade_record` table uses only portable column types (str / JSON /
datetime), so the same SQLModel schema stands up unchanged on Postgres.
Upsert, NOT append: (learner_id, task_id) is the grade identity — one row
per learner per task holding the LATEST grade. `save` overwrites the whole
row when the pair already exists, so a regrade replaces scores, verdict,
created_at, digest, model and variant_seed wholesale. That is deliberately
the opposite of TraceStore.append's dedup-keep-first contract: a trace is an
append-only event log, a grade is latest-state, so the engine can re-grade
a task idempotently as its rubric or input evolves.
Concurrency (a-3): the engine enables WAL + synchronous=NORMAL and a busy
timeout at connection time, so a regrade writer and API readers do not hit
`database is locked` on the single-box pilot.
`created_at` contract: callers stamp UTC (datetime.now(UTC)); SQLite stores
it naive and the read paths re-label it tz-aware UTC (same boundary
normalization as TelemetryEvent.ts, so the contract holds on any backend).
Boundary (D-027): `grading/` never imports `agents/` / `api/`; this module
imports config only.
"""
import logging
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Protocol
import sqlalchemy as sa
from sqlalchemy import JSON, Index
from sqlalchemy.orm import validates
from sqlmodel import Field, Session, SQLModel, create_engine, select
from ..config import Settings
logger = logging.getLogger(__name__)
class GradeRecord(SQLModel, table=True):
"""A persisted grade; (learner_id, task_id) is the PK — latest wins.
Written by the grading engine (one save per grade attempt), read by the
API layer through the GradeStore protocol. Constraint enforcement
mirrors TelemetryEvent: sqlmodel 0.0.42's metaclass drops pydantic
constraints on table models, so SQLAlchemy `@validates` hooks enforce
instead and the column types stay Postgres-ready (D-027).
Field contract:
learner_id — non-empty learner identifier (same id space as traces).
task_id — non-empty task identifier; grade identity is the
(learner_id, task_id) pair — the same pair as trace
identity, so a grade is keyed by the exact trace it
was computed from.
variant_seed — task-variant seed; None until P4 (D-029). v0.3
grading is variant-blind.
digest — compact deterministic trace digest (D-028) that fed
the rubric prompt; persisted for auditability so the
LLM's input stays reproducible.
scores — validated rubric scores (per-criterion 0-4,
strengths, gaps); JSON dict. An empty dict is legal
(e.g. an UNGRADABLE_TRACE_INCOMPLETE record carries a
verdict but no scores).
verdict — first-class verdict string (rubric verdict or
UNGRADABLE_TRACE_INCOMPLETE); non-empty.
model — provider model that produced the scores (provenance).
created_at — UTC grade timestamp; a regrade replaces it (latest
save wins).
"""
__tablename__ = "grade_record"
# The composite PK covers (learner_id, task_id) point lookups; this
# secondary index covers list_for_learner ordered by created_at without
# a sort step (Postgres migration target D-027).
__table_args__ = (
Index("ix_grade_record_learner_created", "learner_id", "created_at"),
)
learner_id: str = Field(primary_key=True)
task_id: str = Field(primary_key=True)
variant_seed: str | None = Field(default=None) # null until P4 (D-029)
# JSON columns: stored as TEXT on SQLite, native JSONB on Postgres (D-027).
digest: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
scores: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
verdict: str
model: str
created_at: datetime
@validates("learner_id", "task_id")
def _ids_non_empty(self, key: str, value: str) -> str:
if not value:
raise ValueError(f"{key} must be a non-empty identifier")
return value
@validates("verdict")
def _verdict_non_empty(self, key: str, value: str) -> str:
if not value:
raise ValueError(f"{key} must be a non-empty verdict string")
return value
class GradeStore(Protocol):
"""Persistence contract for latest-state grades per (learner_id, task_id).
Implemented by SQLiteGradeStore (v0.3, D-027); a Postgres implementation
must satisfy the same surface.
"""
def save(self, grade: GradeRecord) -> None:
"""Persist a grade. UPSERT on (learner_id, task_id): a regrade with
the same pair REPLACES the stored row wholesale — the latest grade
wins. NOT append-only; contrast TraceStore.append, which is
dedup-keep-first for at-least-once ingest.
"""
...
def get(self, learner_id: str, task_id: str) -> GradeRecord | None:
"""Latest stored grade for the pair; None when none exists.
Detached from any DB session — safe to pass across layers.
"""
...
def list_for_learner(self, learner_id: str) -> list[GradeRecord]:
"""All stored grades for the learner, ordered by created_at
ascending (chronological). Empty list when the learner has none.
"""
...
def close(self) -> None:
"""Release DB connections. Store must not be used after close."""
...
def _sqlite_connect(dbapi_connection: sqlite3.Connection, _: object) -> None:
"""Per-connection pragma setup (a-3). Mirrors telemetry/store.py.
journal_mode=WAL — readers never block the single writer.
synchronous=NORMAL — safe in WAL mode, avoids full fsync-per-commit.
busy_timeout=5000 — retry briefly under contention instead of
`OperationalError: database is locked`.
"""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
def _as_utc(ts: datetime) -> datetime:
"""Timestamps travel as naive datetime on SQLite, tz-aware elsewhere.
SQLite (via SQLModel) drops tzinfo; Postgres TIMESTAMP WITH TIME ZONE
keeps it. Normalizing on the read path makes the store's contract
tz-aware UTC regardless of the backend (D-027).
"""
if ts.tzinfo is None:
return ts.replace(tzinfo=UTC) # naive UTC read-side: label as UTC
return ts.astimezone(UTC)
class SQLiteGradeStore:
"""SQLite-backed GradeStore (SQLModel). Second protocol-wrapped store
of the D-027 family (first: SQLiteTraceStore).
"""
def __init__(self, db_path: Path | None = None) -> None:
self._db_path: Path = db_path if db_path is not None else Settings().db_path
self._engine = create_engine(f"sqlite:///{self._db_path}")
sa.event.listen(self._engine, "connect", _sqlite_connect)
SQLModel.metadata.create_all(self._engine)
@contextmanager
def _session(self) -> Iterator[Session]:
# expire_on_commit=False: identical session behavior to
# SQLiteTraceStore. save() discards the merged instance and the read
# paths never commit, but a uniform flag across the D-027 stores
# keeps their detachment guarantees from diverging.
with Session(self._engine, expire_on_commit=False) as session:
yield session
def save(self, grade: GradeRecord) -> None:
# `merge` = SELECT-by-PK then UPDATE or INSERT — exactly the upsert
# contract. The trace store deliberately avoids merge (its append is
# dedup-keep-first); here latest-wins IS the contract, so merge is
# the right tool. The caller's object is never attached to the
# session and stays usable (unexpired) after save.
with self._session() as session:
session.merge(grade)
session.commit()
logger.debug(
"grade saved (regrade overwrites): %s/%s verdict=%s model=%s",
grade.learner_id,
grade.task_id,
grade.verdict,
grade.model,
)
def get(self, learner_id: str, task_id: str) -> GradeRecord | None:
with self._session() as session:
record = session.get(GradeRecord, (learner_id, task_id))
if record is None:
return None
record.created_at = _as_utc(record.created_at)
# Detach from the session: callers must not depend on
# open-session ORM magic (lazy loads fail once it closes).
session.expunge(record)
return record
def list_for_learner(self, learner_id: str) -> list[GradeRecord]:
with self._session() as session:
stmt = (
select(GradeRecord)
.where(GradeRecord.learner_id == learner_id)
# Chronological; task_id is a deterministic tie-break for
# grades stamped within the same instant.
.order_by(GradeRecord.created_at, GradeRecord.task_id)
)
results = session.exec(stmt).all()
for row in results:
row.created_at = _as_utc(row.created_at)
session.expunge(row)
return list(results)
def close(self) -> None:
self._engine.dispose()
@@ -0,0 +1 @@
"""Process-trace grading tests (REQ-3-004)."""
@@ -0,0 +1,170 @@
"""Trace digest tests (Task 3-1-01, REQ-3-004).
Contract: `compute_digest` is deterministic, bounded (< 4 KB), and carries
NO raw trace material (commands, file contents, payload strings) — the raw
trace never reaches the LLM (D-028).
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from ai_service.grading.features import TraceDigest, compute_digest
from ai_service.telemetry.models import TelemetryEvent
T0 = datetime(2026, 9, 12, 1, 0, 0, tzinfo=UTC)
def _event(
seq: int, kind: str, payload: dict | None = None, offset_s: float = 0.0
) -> TelemetryEvent:
return TelemetryEvent(
learner_id="features-learner",
task_id="features-task",
seq=seq,
kind=kind,
payload=payload or {},
ts=T0 + timedelta(seconds=offset_s),
sandbox_id="sbx-features",
)
def _paste_and_run_trace() -> list[TelemetryEvent]:
"""One big file dump, a single passing test at the very end (no cycles)."""
return [
_event(0, "activity", {"state": "starting"}, 0.0),
_event(1, "file_diff", {"path": "main.py", "added": 180, "removed": 0}, 5.0),
_event(2, "command", {"cmd": "python -m pytest -q"}, 30.0),
_event(3, "test_result", {"passed": True, "exit_code": 0}, 45.0),
_event(4, "activity", {"state": "idle"}, 50.0),
]
def _iterative_trace() -> list[TelemetryEvent]:
"""Many small edits; failed runs interleaved; eventual pass (>= 2 cycles)."""
events: list[TelemetryEvent] = [
_event(0, "activity", {"state": "starting"}, 0.0),
_event(1, "file_diff", {"path": "a.py", "added": 12}, 10.0),
_event(2, "command", {"cmd": "pytest -q"}, 20.0),
_event(3, "test_result", {"passed": False, "exit_code": 1}, 25.0),
_event(4, "file_diff", {"path": "a.py", "added": 4, "removed": 2}, 40.0),
_event(5, "file_diff", {"path": "b.py", "added": 6}, 50.0),
_event(6, "command", {"cmd": "pytest -q"}, 60.0),
_event(7, "test_result", {"passed": False, "exit_code": 2}, 65.0),
_event(8, "file_diff", {"path": "a.py", "added": 3, "removed": 1}, 80.0),
_event(9, "command", {"cmd": "pytest -q tests/"}, 90.0),
_event(10, "test_result", {"passed": True, "exit_code": 0}, 95.0),
_event(11, "activity", {"state": "idle"}, 100.0),
]
return events
def test_paste_and_run_vs_iterative_produce_observably_different_digests() -> None:
paste = compute_digest(_paste_and_run_trace())
iterative = compute_digest(_iterative_trace())
assert paste.error_fix_cycles == 0
assert iterative.error_fix_cycles == 2
assert paste.test_fail_count == 0
assert iterative.test_fail_count == 2
assert paste.edit_count < iterative.edit_count
assert iterative.first_test_pass_offset_s is not None
assert paste.first_test_pass_offset_s is not None
# Iterative debugs longer before the first pass.
assert iterative.first_test_pass_offset_s > paste.first_test_pass_offset_s
def test_digest_is_deterministic() -> None:
trace = _iterative_trace()
assert compute_digest(trace) == compute_digest(list(reversed(trace))) # seq sort normalizes
def test_empty_trace_yields_valid_zeroed_digest() -> None:
digest = compute_digest([])
assert digest.event_count == 0
assert digest.final_test_status == "none"
assert digest.first_test_pass_offset_s is None
assert digest.mean_fix_latency_s is None
assert isinstance(digest, TraceDigest)
def test_digest_json_is_bounded_under_4kb() -> None:
big = [
_event(i, "command", {"cmd": f"grep PATTERN-{i} file-{i}.py"}, i * 1.0)
for i in range(200)
]
serialized = compute_digest(big).model_dump_json()
assert len(serialized.encode()) < 4096, f"digest too large: {len(serialized)}B"
def test_no_raw_command_string_leaks_into_digest() -> None:
marker = "SECRET-COMMAND-MARKER-7f3a"
trace = [
_event(0, "command", {"cmd": f"echo {marker} && cat /etc/hostname"}, 0.0),
_event(1, "file_diff", {"path": marker + ".py"}, 1.0),
_event(2, "run_result", {"exit_code": 0, "stdout": marker}, 2.0),
]
digest_json = compute_digest(trace).model_dump_json()
assert marker not in digest_json, "raw payload material leaked into digest"
def test_idle_gaps_computed_over_threshold() -> None:
trace = [
_event(0, "activity", {"state": "starting"}, 0.0),
_event(1, "activity", {"state": "idle"}, 400.0), # > 120s gap
_event(2, "activity", {"state": "idle"}, 500.0), # 100s gap (below)
_event(3, "activity", {"state": "stopped"}, 800.0), # > 120s gap
]
digest = compute_digest(trace)
assert digest.idle_gap_count == 2
assert digest.idle_gap_total_s == pytest.approx(400.0 + 300.0, rel=1e-6)
def test_command_category_histogram() -> None:
trace = [
_event(0, "command", {"cmd": "npm run build"}, 0.0),
_event(1, "command", {"cmd": "pytest -q"}, 1.0),
_event(2, "command", {"cmd": "ls -la"}, 2.0),
_event(3, "command", {"cmd": "rm -rf build/"}, 3.0),
_event(4, "command", {"cmd": "curl localhost:8420/health"}, 4.0),
_event(5, "command", {"cmd": "python mystery.py"}, 5.0),
]
digest = compute_digest(trace)
assert digest.command_categories == {
"build": 1,
"debug": 1,
"file": 1,
"nav": 1,
"other": 1,
"test": 1,
}
def test_daemon_topology_mix_is_tolerated() -> None:
"""P2-verify P1: live traces carry activity+file_diff only — no crash."""
trace = [
_event(0, "activity", {"state": "starting"}, 0.0),
_event(1, "file_diff", {"path": "made-by-exec.txt", "added": 1}, 2.0),
_event(2, "activity", {"state": "idle"}, 3.0),
]
digest = compute_digest(trace)
assert digest.final_test_status == "none"
assert digest.edit_count == 1
assert digest.test_pass_count == 0
assert digest.kind_histogram.get("file_diff") == 1
def test_run_result_exit_codes_fall_back_for_test_status() -> None:
"""No test_result events: run_result exit codes decide pass/fail."""
trace = [
_event(0, "file_diff", {"path": "x.py"}, 0.0),
_event(1, "run_result", {"exit_code": 1}, 10.0),
_event(2, "file_diff", {"path": "x.py"}, 20.0),
_event(3, "run_result", {"exit_code": 0}, 30.0),
]
digest = compute_digest(trace)
assert digest.test_fail_count == 1
assert digest.test_pass_count == 1
assert digest.final_test_status == "pass"
assert digest.error_fix_cycles == 1
+268
View File
@@ -0,0 +1,268 @@
"""SQLiteGradeStore tests (REQ-3-004, D-027).
Each test gets its own tmp-path SQLite file — no shared disk state. Covers:
- save / get / list_for_learner roundtrip (all fields survive,
including nested JSON dicts and the tz-aware created_at contract)
- upsert-on-regrade: a second save with the same (learner_id, task_id)
REPLACES the row wholesale — scores, verdict, created_at, digest,
model and variant_seed all reflect the latest save (documented
contract; deliberately opposite of TraceStore.append's dedup)
- unknown (learner, task) pair -> None; unknown learner -> empty list
- scoping: grades for other learners/tasks are never returned
- variant_seed stays None until P4 (D-029) and survives a roundtrip
- rows are detached: usable after the store is closed
- WAL + synchronous=NORMAL pragmas actually applied to the DB file
- concurrent writer + reader against the same DB file (a-3 smoke test)
"""
import concurrent.futures
import threading
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
import sqlalchemy as sa
from ai_service.grading.store import GradeRecord, SQLiteGradeStore
_BASE_TS = datetime(2026, 9, 12, 12, 0, 0, tzinfo=UTC)
def make_grade(
task_id: str = "task-1",
learner_id: str = "learner-1",
variant_seed: str | None = None,
digest: dict[str, Any] | None = None,
scores: dict[str, Any] | None = None,
verdict: str = "STRONG",
model: str = "gemma4:31b",
created_at: datetime | None = None,
) -> GradeRecord:
"""Canonical kwargs builder — tests override only what they assert on."""
return GradeRecord(
learner_id=learner_id,
task_id=task_id,
variant_seed=variant_seed,
digest=digest
if digest is not None
else {"error_fix_cycles": 3, "command_categories": {"build": 2}},
scores=scores
if scores is not None
else {
"criteria": {"process_quality": 3, "correctness": 4},
"strengths": ["iterative debugging"],
"gaps": ["no final test pass"],
},
verdict=verdict,
model=model,
created_at=created_at if created_at is not None else _BASE_TS,
)
@pytest.fixture
def store(tmp_path: Path) -> SQLiteGradeStore:
s = SQLiteGradeStore(db_path=tmp_path / "grades.db")
yield s
s.close()
def test_save_and_get_roundtrip(store: SQLiteGradeStore) -> None:
grade = make_grade()
store.save(grade)
fetched = store.get("learner-1", "task-1")
assert fetched is not None
assert fetched.learner_id == "learner-1"
assert fetched.task_id == "task-1"
assert fetched.variant_seed is None # null until P4 (D-029)
assert fetched.digest == grade.digest
assert fetched.scores == grade.scores
assert fetched.verdict == "STRONG"
assert fetched.model == "gemma4:31b"
assert fetched.created_at == _BASE_TS
assert fetched.created_at.tzinfo is UTC # tz-normalized on read
def test_get_unknown_pair_returns_none(store: SQLiteGradeStore) -> None:
store.save(make_grade())
assert store.get("learner-1", "task-missing") is None
assert store.get("learner-missing", "task-1") is None
assert store.get("nobody", "nothing") is None
def test_list_for_learner_roundtrip(store: SQLiteGradeStore) -> None:
# Created out of insertion order; list must come back chronological.
store.save(make_grade(task_id="task-c", created_at=_BASE_TS + timedelta(hours=2)))
store.save(make_grade(task_id="task-a", created_at=_BASE_TS))
store.save(make_grade(task_id="task-b", created_at=_BASE_TS + timedelta(hours=1)))
grades = store.list_for_learner("learner-1")
assert [g.task_id for g in grades] == ["task-a", "task-b", "task-c"]
assert all(g.learner_id == "learner-1" for g in grades)
hours = (timedelta(hours=0), timedelta(hours=1), timedelta(hours=2))
assert all(
g.created_at == _BASE_TS + offset
for g, offset in zip(grades, hours, strict=True)
)
assert all(g.created_at.tzinfo is UTC for g in grades)
def test_list_for_learner_unknown_learner_returns_empty_list(
store: SQLiteGradeStore,
) -> None:
assert store.list_for_learner("nobody") == []
def test_lists_are_scoped_to_the_learner(store: SQLiteGradeStore) -> None:
store.save(make_grade(learner_id="learner-1", task_id="task-1"))
store.save(make_grade(learner_id="learner-2", task_id="task-1"))
assert [g.task_id for g in store.list_for_learner("learner-1")] == ["task-1"]
assert [g.learner_id for g in store.list_for_learner("learner-2")] == ["learner-2"]
# (learner-2, task-1) is a distinct row: same task_id, different grade.
grades_2 = store.list_for_learner("learner-2")
assert len(grades_2) == 1
assert grades_2[0].learner_id == "learner-2"
def test_regrade_overwrites_the_stored_row(store: SQLiteGradeStore) -> None:
"""THE contract of this store (upsert on the PK pair, latest wins).
The grading engine re-grades a task as its rubric or input evolves;
the second save replaces scores, verdict, created_at, digest, model
and variant_seed wholesale — exactly one row survives per pair.
"""
first = make_grade(
verdict="DEVELOPING",
model="gemma4:31b",
scores={"criteria": {"process_quality": 1}},
digest={"error_fix_cycles": 0},
created_at=_BASE_TS,
)
store.save(first)
second = make_grade(
verdict="EXEMPLARY",
model="gemma4:31b-p2",
variant_seed="seed-77",
scores={"criteria": {"process_quality": 4}},
digest={"error_fix_cycles": 6},
created_at=_BASE_TS + timedelta(hours=1),
)
store.save(second)
fetched = store.get("learner-1", "task-1")
assert fetched is not None
# The regrade replaced every field of the first save.
assert fetched.verdict == "EXEMPLARY"
assert fetched.model == "gemma4:31b-p2"
assert fetched.variant_seed == "seed-77"
assert fetched.scores == {"criteria": {"process_quality": 4}}
assert fetched.digest == {"error_fix_cycles": 6}
assert fetched.created_at == _BASE_TS + timedelta(hours=1)
# Latest-wins also holds in list_for_learner — one row, not two.
grades = store.list_for_learner("learner-1")
assert len(grades) == 1
assert grades[0].verdict == "EXEMPLARY"
def test_regrade_preserves_other_pairs(store: SQLiteGradeStore) -> None:
# An upsert on (learner-1, task-1) must not touch (learner-1, task-2).
store.save(make_grade(task_id="task-1", verdict="STRONG"))
store.save(make_grade(task_id="task-2", verdict="DEVELOPING"))
store.save(make_grade(task_id="task-1", verdict="EXEMPLARY"))
other = store.get("learner-1", "task-2")
assert other is not None
assert other.verdict == "DEVELOPING" # untouched by the task-1 regrade
assert len(store.list_for_learner("learner-1")) == 2
def test_empty_scores_dict_roundtrips(store: SQLiteGradeStore) -> None:
# Legal shape: an UNGRADABLE_TRACE_INCOMPLETE record carries a verdict
# but no scores (and here, no digest either).
store.save(make_grade(verdict="UNGRADABLE_TRACE_INCOMPLETE", scores={}, digest={}))
fetched = store.get("learner-1", "task-1")
assert fetched is not None
assert fetched.scores == {}
assert fetched.digest == {}
assert fetched.verdict == "UNGRADABLE_TRACE_INCOMPLETE"
def test_rows_are_detached_after_save(store: SQLiteGradeStore, tmp_path: Path) -> None:
# The engine hands GradeRecords across layers; rows must survive the
# store that produced them being closed (no open-session ORM magic).
store.save(make_grade())
fetched = store.get("learner-1", "task-1")
store.close()
assert fetched is not None
assert fetched.verdict == "STRONG"
assert fetched.scores["criteria"]["process_quality"] == 3
# A fresh store on the same file sees the same row (durability).
reopened = SQLiteGradeStore(db_path=tmp_path / "grades.db")
try:
again = reopened.get("learner-1", "task-1")
assert again is not None
assert again.verdict == "STRONG"
finally:
reopened.close()
def test_pragmas_are_applied(store: SQLiteGradeStore) -> None:
# Pragmas are per-connection; query through the store's engine so the
# connect hook (not a default sqlite3 connection) is what we inspect.
with store._engine.connect() as conn:
(journal_mode,) = conn.execute(sa.text("PRAGMA journal_mode")).one()
(synchronous,) = conn.execute(sa.text("PRAGMA synchronous")).one()
assert journal_mode == "wal"
# synchronous=NORMAL is 1 in SQLite's pragma numbering.
assert synchronous == 1
def test_concurrent_writer_and_reader_no_database_is_locked(tmp_path: Path) -> None:
"""One thread saves while another reads in a tight loop (a-3).
Without WAL + busy_timeout this pattern reliably produces
`OperationalError: database is locked` on SQLite. The assertion is
that every reader call completes and the latest write lands intact.
"""
db_path = tmp_path / "grades.db"
n_pairs = 60 # distinct tasks -> distinct PK pairs, one regrade each
stop_writing = threading.Event()
writer = SQLiteGradeStore(db_path=db_path)
reader = SQLiteGradeStore(db_path=db_path)
try:
def write_grades() -> None:
for seq in range(n_pairs):
writer.save(
make_grade(
task_id=f"task-{seq}",
created_at=_BASE_TS + timedelta(seconds=seq),
)
)
stop_writing.set()
def read_grades() -> None:
while not stop_writing.is_set():
reader.list_for_learner("learner-1")
# Final read after the writer is done.
assert len(reader.list_for_learner("learner-1")) == n_pairs
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(write_grades), pool.submit(read_grades)]
for future in futures:
future.result(timeout=30)
# Every save was an upsert on its own pair: nothing lost, none doubled.
assert len(writer.list_for_learner("learner-1")) == n_pairs
finally:
reader.close()
writer.close()
@@ -508,10 +508,13 @@ class TestReconnectFlush:
assert set(buffered_seqs).isdisjoint(e["seq"] for e in fake_server.events)
held_link.resume() # outage ends → supervisor reconnects, flushes spool
# 20s deadline: under full-suite load the supervisor thread can be
# starved past its normal sub-second reconnect; 8s was flaky.
assert _wait_until(
lambda: all(
seq in {e["seq"] for e in fake_server.events} for seq in buffered_seqs
)
),
timeout_s=20.0,
), "spooled events never flushed after reconnect"
test_agent.stop()