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:
@@ -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())),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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)
|
assert set(buffered_seqs).isdisjoint(e["seq"] for e in fake_server.events)
|
||||||
|
|
||||||
held_link.resume() # outage ends → supervisor reconnects, flushes spool
|
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(
|
assert _wait_until(
|
||||||
lambda: all(
|
lambda: all(
|
||||||
seq in {e["seq"] for e in fake_server.events} for seq in buffered_seqs
|
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"
|
), "spooled events never flushed after reconnect"
|
||||||
test_agent.stop()
|
test_agent.stop()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user