Files
nextcraft/apps/ai-service/tests/grading/test_calibration.py
T
CIAgent 1a46606827 feat(P03): rubric scoring engine + calibration (Wave 2)
Task 3-2-01: prompts/grading.py (grader-v1 rubric, 4 criteria x 0-4 anchors, a-4 churn
advisory) + grading/engine.py — GradingEngine with the BINDING G-4 gate-first ordering
(INCOMPLETE_FLOODED -> gaps -> empty; LLM unreachable for gated traces; first-class
UNGRADABLE_* GradeRecords, model="none" provenance), digest-only prompts (D-028; planted
marker proven absent from all provider messages), D-020 reused via one module-direct
import of agents/structured (grep-auditable). RubricScore validated per-criterion.
Task 3-2-02: corpus/trace_fixtures.py (D-021-aligned archetype IDs) + ordering-contract
calibration test (strong>=lazy on process; strong>struggling on correctness; digest
feature separation asserted deterministically).

54 grading tests green; suite 272 green; ruff clean.

---ci---
phase: 3
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-004], partial: []}
---/ci---
2026-09-12 02:17:06 +00:00

362 lines
14 KiB
Python

"""Grading calibration CONTRACT test (Task 3-2-02, REQ-3-004, D-021).
HONEST SCOPE — what this test is and is NOT:
This is a CALIBRATION CONTRACT test, not an LLM quality test. The mock
provider scripts archetype-mapped rubric scores for each fixture; the
assertions verify that:
1. the ENGINE pipeline (gate → digest → prompt → D-020 → validate →
persist) runs each archetype end-to-end and preserves the score
ordering the scripts impose;
2. the fixtures' digest FEATURES actually separate the archetypes
deterministically (the non-LLM half of the calibration: paste-and-run
vs iterative debugging vs never-passing MUST produce observably
different digests — otherwise no rubric could ever separate them);
3. fixture IDs are valid corpus-aligned strings (D-021).
It does NOT prove the real LLM scores archetypes in this order — that
requires live-model evaluation, which is out of scope for v0.3 tests
(tests NEVER call the cloud; conftest enforces). What the contract pins
is the ORDERING the rubric + digest must eventually enforce:
strong.process_quality >= lazy.process_quality
strong.correctness > struggling.correctness
If a future prompt/digest change makes the scripted mapping unachievable
by ANY model (e.g. the digest stops separating the archetypes), test 2
fails — that is the calibration signal.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from ai_service.corpus.telemetry import LAB_SCENARIOS
from ai_service.corpus.trace_fixtures import (
CALIBRATION_LEARNER_ID,
LAZY_BUILDER,
STRONG_BUILDER,
STRUGGLING_BUILDER,
TRACE_FIXTURES,
digest_of,
)
from ai_service.grading.engine import GradingEngine
from ai_service.grading.store import SQLiteGradeStore
from ai_service.telemetry.ingest import TraceIntegrityMap
from ai_service.telemetry.store import SQLiteTraceStore
#: Archetype-mapped rubric scripts (the ORDERING CONTRACT in numbers).
#: strong: iterative verified building — highest process quality.
#: lazy: ends pass with zero iteration — the a-4 negative (churn with no
#: test progress caps process_quality at 1; here it's paste-churn +
#: one late test, so 1). correctness lands high (final pass, fast).
#: struggling: never passes — lowest correctness.
ARCHETYPE_SCORES: dict[str, dict] = {
"strong": {
"criteria": {
"process_quality": 4,
"correctness": 4,
"debugging_discipline": 4,
"test_usage": 4,
},
"strengths": ["tight edit-test loops; every failure cycle closed"],
"gaps": ["session ended without a final full-suite run"],
"verdict": "mastered",
},
"lazy": {
"criteria": {
"process_quality": 1, # a-4: bulk paste + churn, one late test
"correctness": 3,
"debugging_discipline": 1,
"test_usage": 1,
},
"strengths": ["final tests pass on the first run"],
"gaps": ["one bulk paste with zero verified increments"],
"verdict": "developing",
},
"struggling": {
"criteria": {
"process_quality": 2,
"correctness": 0, # never passes
"debugging_discipline": 1, # cycles never close
"test_usage": 3,
},
"strengths": ["persisted through repeated failures"],
"gaps": ["the same failure recurred three times"],
"verdict": "not_yet",
},
}
class ArchetypeProvider:
"""Mock provider scripting archetype-mapped scores keyed by fixture.
Keying on the digest embedded in the user turn (not on call order)
keeps the mapping robust to gate-path noise and asserts the prompt
really carries the fixture's digest. Records the last user turn so
tests can re-assert D-028 (digest in prompt; raw events out).
"""
def __init__(self) -> None:
self.digest_to_archetype: dict[str, str] = {}
self.calls = 0
self.last_user_prompt: str = ""
def register(self, fixture) -> None:
self.digest_to_archetype[digest_of(fixture).model_dump_json()] = (
fixture.archetype
)
async def chat(self, messages, *, model, temperature=0.7, response_format=None):
self.calls += 1
user = next((m for m in reversed(messages) if m.role == "user"), None)
assert user is not None
self.last_user_prompt = user.content
archetype = None
for digest_json, arch in self.digest_to_archetype.items():
if digest_json in user.content:
archetype = arch
break
assert archetype is not None, "fixture digest not found in prompt"
return json.dumps(ARCHETYPE_SCORES[archetype])
async def stream_chat(self, messages, *, model, temperature=0.7, response_format=None):
yield await self.chat(
messages, model=model, temperature=temperature, response_format=response_format
)
@pytest.fixture
def trace_store(tmp_path: Path) -> SQLiteTraceStore:
store = SQLiteTraceStore(db_path=tmp_path / "traces.db")
yield store
store.close()
@pytest.fixture
def grade_store(tmp_path: Path) -> SQLiteGradeStore:
store = SQLiteGradeStore(db_path=tmp_path / "grades.db")
yield store
store.close()
@pytest.fixture
def provider() -> ArchetypeProvider:
p = ArchetypeProvider()
for fixture in (STRONG_BUILDER, LAZY_BUILDER, STRUGGLING_BUILDER):
p.register(fixture)
return p
@pytest.fixture
def engine(trace_store, grade_store, provider) -> GradingEngine:
return GradingEngine(
trace_store, grade_store, TraceIntegrityMap(), provider, model="gemma4:31b"
)
async def _grade_fixture(engine, trace_store, fixture) -> dict:
for event in fixture.events:
trace_store.append(event)
record = await engine.grade(fixture.learner_id, fixture.task_id)
assert record.verdict == "GRADED", (
f"fixture {fixture.fixture_id} did not grade cleanly: {record.verdict}"
)
return record.scores
class TestFixtureAlignment:
"""D-021: fixture IDs align with the v0.2 corpus scenario IDs."""
def test_every_fixture_id_is_corpus_scenario_scoped(self):
for fixture in TRACE_FIXTURES.values():
assert fixture.fixture_id.count("::") == 1
scenario_part, trace_part = fixture.fixture_id.split("::")
assert scenario_part in LAB_SCENARIOS, (
f"{scenario_part!r} is not a v0.2 lab scenario id"
)
assert trace_part.endswith("-trace")
# double-brace-free single slug
assert "-" in trace_part
def test_every_fixture_aligns_to_an_existing_scenario(self):
assert set(f.aligned_scenario_id for f in TRACE_FIXTURES.values()) <= set(
LAB_SCENARIOS
)
def test_fixture_ids_are_valid_strings(self):
for fixture in TRACE_FIXTURES.values():
assert fixture.fixture_id and isinstance(fixture.fixture_id, str)
assert fixture.learner_id == CALIBRATION_LEARNER_ID
assert fixture.learner_id.startswith("learner-00")
def test_lazy_alignment_is_the_flagged_scenario(self):
# v0.2's paste-and-run archetype is the flagged lab scenario (D-021).
assert LAZY_BUILDER.aligned_scenario_id == "lab-scenario-flagged"
assert LAZY_BUILDER.archetype == "lazy"
def test_all_three_archetypes_present(self):
assert {f.archetype for f in TRACE_FIXTURES.values()} == {
"strong",
"lazy",
"struggling",
}
def test_events_are_valid_telemetry_events(self):
from ai_service.telemetry.models import TelemetryEvent
for fixture in TRACE_FIXTURES.values():
assert len(fixture.events) > 0
for e in fixture.events:
assert isinstance(e, TelemetryEvent)
assert e.seq >= 0
# contiguous seqs: a grading trace must clear the G-4 gate
seqs = [e.seq for e in fixture.events]
assert seqs == list(range(len(seqs))), (
f"{fixture.fixture_id} has seq gaps — it would trip the gate"
)
class TestDigestSeparatesArchetypes:
"""The non-LLM half of calibration: digests differ in the RIGHT direction.
If these deterministic assertions ever fail, no rubric — however good —
could separate the archetypes: the digest would be lossy in the wrong
places. These are the features the a-4 advisory and the rubric anchors
speak about, computed over REAL trace fixtures.
"""
def test_strong_shows_iterative_cycles_lazy_shows_none(self):
strong = digest_of(STRONG_BUILDER)
lazy = digest_of(LAZY_BUILDER)
assert strong.error_fix_cycles >= 1
assert lazy.error_fix_cycles == 0
def test_strong_tests_early_lazy_tests_late(self):
strong = digest_of(STRONG_BUILDER)
lazy = digest_of(LAZY_BUILDER)
assert strong.first_test_pass_offset_s is not None
assert lazy.first_test_pass_offset_s is not None
assert strong.first_test_pass_offset_s < lazy.first_test_pass_offset_s
def test_struggling_never_passes(self):
struggling = digest_of(STRUGGLING_BUILDER)
assert struggling.final_test_status == "fail"
assert struggling.test_pass_count == 0
assert struggling.test_fail_count >= 3
def test_strong_and_struggling_both_end(self):
strong = digest_of(STRONG_BUILDER)
assert strong.final_test_status == "pass"
assert strong.test_fail_count >= 1 # honest failure, then closed
def test_lazy_churns_without_test_progress(self):
"""a-4 anchor: bulk edit + command churn with (almost) no test signal."""
lazy = digest_of(LAZY_BUILDER)
assert lazy.edit_count >= 2
assert lazy.test_fail_count == 0
assert lazy.command_count >= 2
assert lazy.final_test_status == "pass" # ...and still one late test
class TestOrderingContract:
"""The scripted ORDERING CONTRACT through the full engine pipeline."""
async def test_strong_at_least_lazy_on_process_quality(
self, engine, trace_store
):
strong = await _grade_fixture(engine, trace_store, STRONG_BUILDER)
lazy = await _grade_fixture(engine, trace_store, LAZY_BUILDER)
assert (
strong["criteria"]["process_quality"]
>= lazy["criteria"]["process_quality"]
)
async def test_strong_above_struggling_on_correctness(self, engine, trace_store):
strong = await _grade_fixture(engine, trace_store, STRONG_BUILDER)
struggling = await _grade_fixture(engine, trace_store, STRUGGLING_BUILDER)
assert strong["criteria"]["correctness"] > struggling["criteria"]["correctness"]
async def test_full_ordering_across_all_three_archetypes(
self, engine, trace_store, grade_store
):
scores = {
arch: await _grade_fixture(engine, trace_store, f)
for arch, f in (
("strong", STRONG_BUILDER),
("lazy", LAZY_BUILDER),
("struggling", STRUGGLING_BUILDER),
)
}
# the required ordering contract (task 3-2-02):
assert (
scores["strong"]["criteria"]["process_quality"]
>= scores["lazy"]["criteria"]["process_quality"]
)
assert (
scores["strong"]["criteria"]["correctness"]
> scores["struggling"]["criteria"]["correctness"]
)
# Deliberate NON-ordering, asserted to document the calibration
# intent: struggling > lazy on process quality. Process quality is
# not outcome — the struggling builder iterated (edits between
# runs, three test runs), the lazy builder only pasted once and
# verified once (a-4 caps that at 1). A rubric that scores a
# never-passing iterator BELOW a paste-and-runner on process is
# miscalibrated on its own anchors.
assert (
scores["struggling"]["criteria"]["process_quality"]
> scores["lazy"]["criteria"]["process_quality"]
)
# verdicts also ordered: mastered >= developing > not_yet
assert scores["strong"]["verdict"] == "mastered"
assert scores["lazy"]["verdict"] == "developing"
assert scores["struggling"]["verdict"] == "not_yet"
# all three persisted under the calibration learner, latest-state
grades = grade_store.list_for_learner(CALIBRATION_LEARNER_ID)
assert len(grades) == 3
assert {g.task_id for g in grades} == {
"task-calibration-strong",
"task-calibration-lazy",
"task-calibration-struggling",
}
async def test_prompt_carries_fixture_digest_not_raw_events(
self, engine, trace_store, provider
):
"""D-028 on the calibration path: digest in the prompt, raw events
never — the lazy fixture plants distinctive payload strings
("eval.py", the bulk 240-line paste) that must NOT appear."""
for event in LAZY_BUILDER.events:
trace_store.append(event)
await engine.grade(LAZY_BUILDER.learner_id, LAZY_BUILDER.task_id)
assert provider.calls == 1
prompt = provider.last_user_prompt
# the fixture's digest reached the LLM...
assert "PROCESS TRACE DIGEST (JSON):" in prompt
assert '"error_fix_cycles"' in prompt
# ...but no raw trace material or identity did (D-028)
assert "eval.py" not in prompt
assert LAZY_BUILDER.learner_id not in prompt
assert LAZY_BUILDER.task_id not in prompt
async def test_struggling_digests_reach_engine_without_gate(
self, engine, trace_store, provider
):
for event in STRUGGLING_BUILDER.events:
trace_store.append(event)
record = await engine.grade(
STRUGGLING_BUILDER.learner_id, STRUGGLING_BUILDER.task_id
)
assert record.verdict == "GRADED" # honest struggle is gradable
assert record.scores["criteria"]["correctness"] == 0
# a never-passing session still yields a real verdict — the grade
# is low, not missing
assert record.scores["verdict"] == "not_yet"