Files
nextcraft/apps/ai-service/ai_service/grading/features.py
T
CIAgent 0fccb8d250 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---
2026-09-12 01:54:25 +00:00

252 lines
9.4 KiB
Python

"""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())),
)