merge(P04): phase/04 variant generation → milestone/v0.3-credential-engines

---ci---
phase: 4
milestone: v0.3
status: ship
---/ci---
This commit is contained in:
CIAgent
2026-09-12 03:51:36 +00:00
23 changed files with 2259 additions and 12 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
{
"phase": 3,
"stage": "complete",
"phase": 4,
"stage": "verify",
"milestone": "v0.3",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-09-12T02:49:33Z"
"updated_at": "2026-09-12T03:51:30Z"
}
+2 -2
View File
@@ -15,7 +15,7 @@
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-3-004 | Process-trace grading engine: grade artifacts from their full process traces; rubric-aligned structured scores; feeds Assessor real inputs | critical | 3 | complete |
| REQ-3-005 | Variant task generation: per-learner task variants (no two learners get identical prompts); variant seed registry; difficulty normalization | high | 4 | pending |
| REQ-3-005 | Variant task generation: per-learner task variants (no two learners get identical prompts); variant seed registry; difficulty normalization | high | 4 | complete |
| REQ-3-006 | Oral/voice defense: AI examiner conducts spoken defense (STT → dialogue → TTS); transcript + integrity signals captured; feeds Proctor/Mentor | high | 5 | pending |
### Agent Re-grounding & Integration
@@ -178,7 +178,7 @@
| REQ-3-002 | 1 | complete |
| REQ-3-003 | 2 | complete |
| REQ-3-004 | 3 | complete |
| REQ-3-005 | 4 | pending |
| REQ-3-005 | 4 | complete |
| REQ-3-006 | 5 | pending |
| REQ-3-007 | 6 | pending |
| REQ-3-008 | 6 | pending |
+1 -1
View File
@@ -22,7 +22,7 @@
| 1 | Sandbox fabric | complete | 0 | REQ-3-001, REQ-3-002 | Isolated per-learner sandbox environments provisioned (IDE / design / simulation); lifecycle API (create/destroy/snapshot); resource limits enforced; no cross-tenant access |
| 2 | Live build telemetry | complete | 1 | REQ-3-003 | In-environment capture of process events (commands, file diffs, run/test results, keystroke-level activity) streamed to ai-service; reliable transport; per-learner trace persistence |
| 3 | Process-trace grading engine | complete | 2 | REQ-3-004 | Grades artifacts from their full process traces (not just final output); emits structured rubric-aligned scores; feeds Assessor real inputs |
| 4 | Variant task generation | pending | 1 | REQ-3-005 | Per-learner task variants generated so no two learners receive identical prompts; variant seed recorded for grading fairness |
| 4 | Variant task generation | complete | 1 | REQ-3-005 | Per-learner task variants generated so no two learners receive identical prompts; variant seed recorded for grading fairness |
| 5 | Oral / voice defense | pending | 3 | REQ-3-006 | AI examiner conducts spoken defense of submitted work; STT → dialogue → TTS; transcript + integrity signals captured; feeds Proctor/Mentor |
| 6 | Agent re-grounding + learner surface integration | pending | 2,3,4,5 | REQ-3-007, REQ-3-008 | Lab/Assessor/Proctor consume real engine inputs; v0.1 sandbox + assessment mockups wired to real engines (in-browser build/run, live telemetry, live defense) |
| 7 | Final review + ship | pending | 6 | — | Code review clean; audit passes; milestone tagged (v0.2.x final patch); release created on Gitea |
@@ -10,6 +10,7 @@ from .mentor import router as mentor_router
from .proctor import router as proctor_router
from .sandboxes import router as sandboxes_router
from .telemetry import router as telemetry_router
from .variants import router as variants_router
__all__ = [
"assessment_router",
@@ -19,4 +20,5 @@ __all__ = [
"proctor_router",
"sandboxes_router",
"telemetry_router",
"variants_router",
]
+10
View File
@@ -12,6 +12,8 @@ from ..sandbox.manager import SandboxManager
from ..sandbox.workdir import SandboxDir
from ..telemetry.ingest import TraceIntegrityMap
from ..telemetry.store import TraceStore
from ..variants.generator import VariantGenerator
from ..variants.store import VariantStore
def get_settings(request: Request) -> Settings:
@@ -53,3 +55,11 @@ def get_grade_store(request: Request) -> GradeStore:
def get_grading_engine(request: Request) -> GradingEngine:
return request.app.state.grading_engine
def get_variant_generator(request: Request) -> VariantGenerator:
return request.app.state.variant_generator
def get_variant_store(request: Request) -> VariantStore:
return request.app.state.variant_store
+187
View File
@@ -0,0 +1,187 @@
"""/v1/variants — seeded per-learner task variant endpoints (REQ-3-005, D-029).
Three faces over the variant engine (the generator + store stay FastAPI-free;
this module owns all HTTP wiring — the D-027/D-032 house pattern):
POST /v1/variants {learner_id, template_id | competency_id}
→ 200 the learner's variant — GENERATED on the first request,
CACHED (store read, zero LLM calls) on every repeat: D-029
reproducibility means one (learner_id, template_id) is ONE
variant forever, so a regenerate is always a 200 of the SAME
variant, never a second render.
→ 404 unknown template_id, or competency_id with no bound template.
→ 422 neither template_id nor competency_id given.
GET /v1/variants/{task_id}
→ 200 the stored variant owning the task key (the grading and
telemetry join path); 404 when no variant was ever generated
for the task.
GET /v1/variants?learner_id=...
→ 200 the learner's variants, chronological; [] when none.
Template resolution: an explicit `template_id` wins; without it the FIRST
template bound to `competency_id` is used (`template_for_competency`,
D-021 corpus alignment). The response carries `competency_id` resolved
from the template library at read time — an enrichment, not a persisted
column (the seed re-derives the whole variant, D-029) — so the learner
surface can bind a variant to its competency without a library round-trip.
Distinctness (REQ-3-005): different learners on the same template draw
different seeded params and receive distinct statements and task_ids;
tests/api/test_variants.py asserts this end-to-end through the API.
"""
from datetime import datetime
from typing import Self
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, model_validator
from ..variants.generator import VariantGenerator
from ..variants.store import VariantRecord, VariantStore
from ..variants.templates import TaskTemplate, get_template, template_for_competency
from .deps import get_variant_generator, get_variant_store
router = APIRouter(prefix="/v1/variants", tags=["variants"])
# -- contracts ------------------------------------------------------------------
class VariantGenerateRequest(BaseModel):
"""One variant identity: an explicit `template_id`, or the first
template bound to a `competency_id` (D-021). `template_id` wins when
both are given (explicit identity beats derived); at least one is
required — 422 otherwise.
"""
learner_id: str = Field(min_length=1)
template_id: str | None = None
competency_id: str | None = None
@model_validator(mode="after")
def _require_template_or_competency(self) -> Self:
if self.template_id is None and self.competency_id is None:
raise ValueError("template_id or competency_id is required")
return self
class VariantResponse(BaseModel):
"""VariantRecord over HTTP, plus the `competency_id` enrichment.
Every field except `competency_id` mirrors `VariantRecord` exactly
(snake_case; `created_at` is an ISO 8601 UTC datetime) — the wire shape
typed as `TaskVariant` in packages/types/variants.ts.
"""
learner_id: str
task_id: str
template_id: str
competency_id: str
seed: str
params: dict[str, str | int]
statement: str
starter_files: dict[str, str]
created_at: datetime
class VariantListResponse(BaseModel):
variants: list[VariantResponse]
# -- resolution + rendering -----------------------------------------------------
def _resolve_template(body: VariantGenerateRequest) -> TaskTemplate:
"""Template for the request: the explicit id, else the first template
bound to the competency; 404 when neither resolves."""
if body.template_id is not None:
template = get_template(body.template_id)
if template is None:
raise HTTPException(
status_code=404,
detail=f"no task template with id {body.template_id!r}",
)
return template
# The request validator guarantees the disjunction, so reaching here
# means a competency_id was given (never None).
assert body.competency_id is not None
templates = template_for_competency(body.competency_id)
if not templates:
raise HTTPException(
status_code=404,
detail=f"no task template for competency {body.competency_id!r}",
)
return templates[0]
def _competency_for(template_id: str) -> str:
"""competency_id enrichment for stored records (read paths)."""
template = get_template(template_id)
if template is None:
# Integrity guard: a stored variant referencing a template that is
# no longer in the library cannot be enriched; fail loudly rather
# than fabricate a competency binding.
raise HTTPException(
status_code=500,
detail=f"stored variant references unknown template {template_id!r}",
)
return template.competency_id
def _to_response(record: VariantRecord, competency_id: str) -> VariantResponse:
return VariantResponse(
learner_id=record.learner_id,
task_id=record.task_id,
template_id=record.template_id,
competency_id=competency_id,
seed=record.seed,
params=dict(record.params),
statement=record.statement,
starter_files=dict(record.starter_files),
created_at=record.created_at,
)
# -- endpoints ------------------------------------------------------------------
@router.post("", response_model=VariantResponse)
async def generate_variant(
body: VariantGenerateRequest,
generator: VariantGenerator = Depends(get_variant_generator),
) -> VariantResponse:
"""The learner's variant for the resolved template — generated on the
first request, cached (no LLM call) on every repeat: D-029 makes a
regenerate a 200 of the SAME stored variant.
"""
template = _resolve_template(body)
record = await generator.generate(body.learner_id, template.id)
return _to_response(record, competency_id=template.competency_id)
@router.get("", response_model=VariantListResponse)
async def list_variants(
learner_id: str,
store: VariantStore = Depends(get_variant_store),
) -> VariantListResponse:
"""All stored variants for the learner, chronological; [] when none."""
variants = [
_to_response(record, competency_id=_competency_for(record.template_id))
for record in store.list_for_learner(learner_id)
]
return VariantListResponse(variants=variants)
@router.get("/{task_id}", response_model=VariantResponse)
async def get_variant(
task_id: str,
store: VariantStore = Depends(get_variant_store),
) -> VariantResponse:
"""The stored variant owning the task key — the grading and telemetry
join path; 404 when no variant was ever generated for the task."""
record = store.get_by_task(task_id)
if record is None:
raise HTTPException(
status_code=404, detail=f"no stored variant for task {task_id!r}"
)
return _to_response(record, competency_id=_competency_for(record.template_id))
+48 -3
View File
@@ -47,7 +47,7 @@ style; engine files hold typed contracts.
import logging
from datetime import UTC, datetime
from typing import Final
from typing import TYPE_CHECKING, Final
from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -65,6 +65,9 @@ from ..telemetry.store import TraceStore
from .features import compute_digest
from .store import GradeRecord, GradeStore
if TYPE_CHECKING: # pragma: no cover - protocol-only import for the optional
from ..variants.store import VariantStore # noqa: TC001 (variant-blind without it)
logger = logging.getLogger(__name__)
#: First-class gate verdicts (G-4). GradeRecord.verdict values; non-empty
@@ -78,6 +81,28 @@ VERDICT_GRADED: Final = "GRADED"
#: Gate-detail keys surfaced in GradeRecord.scores (tests assert on these).
_INTEGRITY_FLAG_KEY: Final = "integrity_flag"
def _anchors_context(variant) -> str: # noqa: ANN001 - VariantRecord (duck-typed)
"""Render the variant template's difficulty anchors for the grader prompt.
Contains only the template id + anchor numbers — no learner-identifying
material (D-028 anonymity preserved; the digest-leak tests keep holding).
Lazy template import: grading must not import variants/ at module load
(variants/prompts import-cycle safety mirrors llm/ rules).
"""
from ..variants.templates import get_template
template = get_template(variant.template_id)
if template is None:
return f"template={variant.template_id} (anchors unavailable)"
a = template.rubric_anchors
return (
f"template={template.id}; "
f"expected_edit_count_band={list(a.expected_edit_count_band)}; "
f"expected_min_test_runs={a.expected_min_test_runs}; "
f"expected_error_fix_cycles_band={list(a.expected_error_fix_cycles_band)}"
)
_MISSING_SEQS_KEY: Final = "missing_seqs"
@@ -133,12 +158,19 @@ class GradingEngine:
provider: LLMProvider,
*,
model: str = "gemma4:31b",
variant_store: "VariantStore | None" = None,
) -> None:
self._trace_store = trace_store
self._grade_store = grade_store
self._integrity = integrity
self._provider = provider
self._model = model
# Phase 4 (MH#4): optional variant lookup — when the graded task
# derives from a generated variant, its template's difficulty anchors
# ship to the grader prompt (same bar for every variant of the
# template, a-5) and the variant seed is stamped on the record.
# Optional so engine tests stay decoupled; main.py lifespan wires it.
self._variant_store = variant_store
async def grade(self, learner_id: str, task_id: str) -> GradeRecord:
"""Grade one trace; persist latest-state (GradeStore upserts); return it.
@@ -201,9 +233,16 @@ class GradingEngine:
# -- Guarded path: digest (D-028) → prompt → D-020 4-layer defense.
digest = compute_digest(trace)
variant = self._lookup_variant(task_id)
anchors_context = (
_anchors_context(variant) if variant is not None else None
)
messages = [
Message(role="system", content=SYSTEM_PROMPT),
Message(role="user", content=render_trace_digest(digest)),
Message(
role="user",
content=render_trace_digest(digest, anchors_context=anchors_context),
),
]
try:
rubric = await structured_completion(
@@ -232,7 +271,7 @@ class GradingEngine:
return GradeRecord(
learner_id=learner_id,
task_id=task_id,
variant_seed=None, # null until P4 (D-029)
variant_seed=variant.seed if variant is not None else None, # D-029
digest=digest.model_dump(),
scores=rubric.model_dump(),
verdict=VERDICT_GRADED,
@@ -240,6 +279,12 @@ class GradingEngine:
created_at=datetime.now(tz=UTC),
)
def _lookup_variant(self, task_id: str): # noqa: ANN202 - VariantRecord | None
"""MH#4: resolve the graded task's variant (None when not variant-derived)."""
if self._variant_store is None:
return None
return self._variant_store.get_by_task(task_id)
# ------------------------------------------------------------ gate record
@staticmethod
+30
View File
@@ -19,6 +19,7 @@ from .api import (
proctor_router,
sandboxes_router,
telemetry_router,
variants_router,
)
from .config import Settings
from .grading.engine import GradingEngine
@@ -27,6 +28,8 @@ from .llm import create_provider
from .sandbox import SandboxManager, UnshareBackend
from .telemetry.ingest import TraceIntegrityMap
from .telemetry.store import SQLiteTraceStore
from .variants.generator import VariantGenerator
from .variants.store import SQLiteVariantStore
logger = logging.getLogger(__name__)
@@ -73,6 +76,30 @@ def create_app(settings: Settings | None = None) -> FastAPI:
if getattr(app.state, "trace_integrity", None) is None:
app.state.trace_integrity = TraceIntegrityMap()
# Variant generation (REQ-3-005): VariantStore from the same
# SQLite file as traces/grades (D-027), one VariantGenerator singleton
# wired through app.state — the generator receives store + provider
# via constructor DI and knows nothing of FastAPI (api/ composes it,
# same pattern as GradingEngine). Tests may pre-set
# app.state.variant_store / app.state.variant_generator (the same
# state-injection override); the lifespan adopts a pre-set store but
# NEVER rebuilds a pre-set generator (its provider binding is part
# of the test fixture).
# ORDER NOTE: built BEFORE the grading engine — the engine takes the
# variant store (Phase 4 MH#4: variant anchors ship to the grader
# prompt; variant_seed stamped on graded records).
variant_store = getattr(app.state, "variant_store", None)
if variant_store is None:
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
variant_store = SQLiteVariantStore(db_path=settings.db_path)
app.state.variant_store = variant_store
if getattr(app.state, "variant_generator", None) is None:
app.state.variant_generator = VariantGenerator(
variant_store,
app.state.provider,
model=settings.model,
)
# Grading persistence + engine (REQ-3-004): GradeStore from the same
# SQLite file as traces (D-027), one GradingEngine singleton wired
# through app.state — the engine receives its stores via constructor
@@ -92,6 +119,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.trace_integrity,
app.state.provider,
model=settings.model,
variant_store=variant_store, # MH#4: anchors + seed (D-029)
)
async def _reaper_loop() -> None:
@@ -115,6 +143,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
await manager.destroy_all()
trace_store.close()
grade_store.close()
variant_store.close()
await app.state.http_client.aclose()
app = FastAPI(title="Nextcraft AI Service", version="0.3.0", lifespan=lifespan)
@@ -143,6 +172,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.include_router(proctor_router)
app.include_router(sandboxes_router)
app.include_router(telemetry_router)
app.include_router(variants_router)
return app
+14 -2
View File
@@ -113,7 +113,10 @@ RUBRIC_SCORE_SCHEMA_HINT = (
)
def render_trace_digest(digest: TraceDigest) -> str:
def render_trace_digest(
digest: TraceDigest,
anchors_context: str | None = None,
) -> str:
"""Render the grader's user turn: a marker line + the digest JSON — nothing else.
This is the ONLY per-session content that ever reaches the LLM (D-028):
@@ -121,8 +124,17 @@ def render_trace_digest(digest: TraceDigest) -> str:
and the D-020 defense appends its generic schema instruction to this
user turn at request time. No learner id, task id, or raw trace material
is injected — assert-able by tests.
`anchors_context` (Phase 4, MH#4): when the graded task derives from a
variant, the engine passes the template's difficulty-normalization
anchors (the expected effort envelope) so the rubric is applied against
the SAME bar for every variant of that template (a-5). It contains only
the anchor numbers + the template id — no learner-identifying material.
"""
return (
base = (
"Score this build session against the rubric.\n"
f"{DIGEST_MARKER}\n{digest.model_dump_json()}"
)
if anchors_context:
base = f"{base}\n\nExpected effort envelope for this task variant:\n{anchors_context}"
return base
@@ -0,0 +1,46 @@
"""Variant instantiation prompt (D-029, REQ-3-005).
The model's ONLY job is to render already-sampled slot values into a task
statement — it never invents parameters (the seeded sampler is pure code)
and never changes difficulty. Prompt-injection surface is bounded: the
variable inputs are the skeleton text, the seeded slot values, and the
template title — nothing from the learner's environment.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..llm.types import Message
if TYPE_CHECKING: # pragma: no cover - keeps this module pure text
from ..variants.templates import TaskTemplate
VARIANT_SYSTEM_PROMPT = (
"You instantiate per-learner task variants for a competency-based AI school. "
"You receive a task statement skeleton and ALREADY-SAMPLED slot values. "
"Render the slot values into the skeleton, producing a complete, unambiguous "
"task statement a learner can build against. Rules:\n"
"- Use EXACTLY the given slot values; do not invent, rename, or add parameters.\n"
"- Keep the engineering depth IDENTICAL across draws: slot values change the "
"scenario, never the difficulty or scope.\n"
"- Keep the statement in the same language and register as the skeleton.\n"
"- Output STRICT JSON only: {\"statement\": \"<rendered statement>\"}.\n"
)
VARIANT_SCHEMA_HINT = '{"statement": "<complete rendered task statement string>"}'
def render_variant_prompt(template: TaskTemplate, params: dict[str, str | int]) -> list[Message]:
"""Messages for one seeded instantiation (D-020 defense drives the call)."""
slot_lines = "\n".join(f" {{{slot.name}}} = {params[slot.name]!r}" for slot in template.slots)
user = (
f"Template: {template.title} (id={template.id})\n"
f"Statement skeleton:\n{template.statement_skeleton}\n\n"
f"Seeded slot values (use EXACTLY these):\n{slot_lines}\n\n"
"Render the complete task statement now."
)
return [
Message(role="system", content=VARIANT_SYSTEM_PROMPT),
Message(role="user", content=user),
]
@@ -0,0 +1,31 @@
"""Per-learner variant task generation — templates, generator, VariantStore (REQ-3-005).
Boundary rule (D-027): variants/ is an engine module — it never imports
api/; its ONLY agents/ dependency is the module-direct
agents.structured import in generator.py (the sanctioned shared D-020
structured defense, same exception as grading/engine.py). api/ composes
the generator and store via DI; store.py imports config only.
CO-ORDINATION NOTE (ADD, don't REMOVE — same convention as grading/):
This __init__.py is a minimal placeholder created by the VariantStore
task (4-1-02). The templates task (4-1-01) owns this file's final shape
— when templates.py lands, ADD its exports alongside these; do not
remove the store exports below.
Wave status: store.py (VariantRecord, VariantStore, SQLiteVariantStore)
landed in Wave 1 (task 4-1-02); templates.py is Wave 1 task 4-1-01;
generator.py is Wave 2 (4-2-01).
"""
from .store import SQLiteVariantStore, VariantRecord, VariantStore
from .templates import TEMPLATES, TaskTemplate, get_template, template_for_competency
__all__ = [
"SQLiteVariantStore",
"TEMPLATES",
"TaskTemplate",
"VariantRecord",
"VariantStore",
"get_template",
"template_for_competency",
]
@@ -0,0 +1,136 @@
"""Seeded per-learner variant generator (D-029, REQ-3-005).
Contract (binding, from GRILL + PLAN Must-Haves):
- REPRODUCIBLE: seed = sha256(template_id|learner_id|milestone); the same
(template, learner) re-derives the same seed, params, task_id — and the
second generate() call is a cache hit with NO LLM call.
- DISTINCT: different learners on the same template draw different params
(the sampler is seeded per-learner) and receive distinct statements.
- NEVER BLOCKS ON THE LLM: the deterministic skeleton render
(`template.render(params)`) is a complete, valid statement; if the D-020
LLM render fails after its bounded retry, the fallback is used — and
because the fallback is exactly `template.render(seed-params)`, it is
auditable from the persisted seed + params without a provenance column.
- AUDITABLE: seed + params + statement persist via VariantStore
(insert-only first-wins) — the proctoring cross-check path.
- FAIR (a-5): slot draws change the scenario, never the difficulty; the
template's rubric anchors bound the expected effort envelope, so every
variant of one template is held to the same bar.
"""
from __future__ import annotations
import hashlib
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field
from ..agents.structured import StructuredOutputError, structured_completion
from ..llm.types import Message
from ..prompts.variant import VARIANT_SCHEMA_HINT, render_variant_prompt
from .store import VariantRecord
from .templates import TaskTemplate, get_template
if TYPE_CHECKING: # pragma: no cover
from ..llm.base import LLMProvider
from .store import VariantStore
MILESTONE = "v0.3"
class RenderedVariant(BaseModel):
"""D-20-validated LLM render output (statement only — files come from the template)."""
model_config = ConfigDict(extra="forbid")
statement: str = Field(min_length=20)
class UnknownTemplateError(ValueError):
"""Raised when generate() is asked for a template id not in the library."""
def derive_seed(template_id: str, learner_id: str, milestone: str = MILESTONE) -> str:
"""Reproducible per-(template, learner, milestone) seed (D-029)."""
return hashlib.sha256(f"{template_id}|{learner_id}|{milestone}".encode()).hexdigest()
def derive_task_id(seed: str) -> str:
"""Deterministic grading/telemetry task key from the seed (16 hex chars)."""
return f"task-{seed[:16]}"
class VariantGenerator:
"""Seeded instantiation over the template library. DI: store + provider."""
def __init__(self, store: VariantStore, provider: LLMProvider, model: str) -> None:
self._store = store
self._provider = provider
self._model = model
async def generate(self, learner_id: str, template_id: str) -> VariantRecord:
template = get_template(template_id)
if template is None:
raise UnknownTemplateError(f"no task template with id {template_id!r}")
# Cache: D-029 reproducibility — same (learner, template) is served
# from the store with no LLM call.
cached = self._store.get(learner_id, template_id)
if cached is not None:
return cached
seed_hex = derive_seed(template_id, learner_id)
task_id = derive_task_id(seed_hex)
params = template.sample_params(_seed_int(seed_hex))
_validate_params(template, params)
statement = await self._render(template, params)
record = VariantRecord(
learner_id=learner_id,
task_id=task_id,
template_id=template_id,
seed=seed_hex,
params=dict(params),
statement=statement,
starter_files=dict(template.starter_files),
created_at=datetime.now(UTC),
)
self._store.save(record)
return record
async def _render(self, template: TaskTemplate, params: dict[str, str | int]) -> str:
"""LLM render via D-020; deterministic fallback never blocks task work.
Provenance note: unlike grades, variants carry no `model` column —
the deterministic fallback is exactly `template.render(params)`,
re-derivable from the persisted seed + params, so a fallback render is
auditable without storing provenance (the seed IS the provenance).
"""
messages: list[Message] = render_variant_prompt(template, params)
try:
rendered = await structured_completion(
self._provider,
messages,
model=self._model,
schema=RenderedVariant,
schema_hint=VARIANT_SCHEMA_HINT,
)
except StructuredOutputError:
# Deterministic fallback: the skeleton + seeded slots is already a
# complete statement, re-derivable from the persisted seed.
return template.render(params)
return rendered.statement
def _seed_int(seed_hex: str) -> int:
"""Stable int for random.Random from the hex seed."""
return int(seed_hex[:16], 16)
def _validate_params(template: TaskTemplate, params: dict[str, str | int]) -> None:
"""Defense in depth: every sampled value must be schema-valid (a-5)."""
for slot in template.slots:
value = params.get(slot.name)
if value is None or not slot.validate_value(value):
raise ValueError(f"sampled params invalid for slot {slot.name!r}: {value!r}")
@@ -0,0 +1,311 @@
"""VariantStore — variant persistence protocol + SQLite implementation (REQ-3-005, D-027).
Postgres-migration-ready (D-027): the protocol is the only surface the
variant generator and API layers touch; swapping SQLiteVariantStore for a
Postgres-backed implementation must not change call sites. The
`variant_record` table uses only portable column types (str / JSON /
datetime), so the same SQLModel schema stands up unchanged on Postgres.
Insert-only, NOT upsert: (learner_id, template_id) is the variant identity
and the FIRST generation is authoritative — reproducibility (D-029) means
the seed re-derives the same variant, so the generator's cache path serves
`get` instead of saving again. `save` is a plain INSERT; a duplicate pair
raises sqlalchemy.exc.IntegrityError to the caller (documented behavior).
`task_id` is unique too — it is the grading/telemetry trace key, so a
trace or grade can never silently join to a different variant. Both
rejections are deliberate: overwriting a stored variant would swap a
learner's graded task underneath its trace and grade (audit corruption).
Contrast TraceStore.append (dedup-keep-first, swallowed — at-least-once
ingest) and GradeStore.save (upsert-latest-wins — a regrade is
latest-state); this store is the third contract of the D-027 family.
Concurrency (a-3): the store enables WAL + synchronous=NORMAL and a busy
timeout at connection time, so a generation 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 / GradeRecord.created_at, so
the contract holds on any backend).
Boundary (D-027): `variants/` 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, UniqueConstraint
from sqlalchemy.orm import validates
from sqlmodel import Field, Session, SQLModel, create_engine, select
from ..config import Settings
logger = logging.getLogger(__name__)
class VariantRecord(SQLModel, table=True):
"""A persisted task variant; (learner_id, template_id) is the PK — first wins.
Written once by the variant generator (Task 4-2-01), read by the API
layer and proctoring cross-checks through the VariantStore protocol.
Constraint enforcement mirrors TelemetryEvent / GradeRecord: 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 and grades).
template_id — non-empty task template identifier; variant
identity is the (learner_id, template_id) pair —
the pair the generator caches on (exactly one
variant per learner per template).
task_id — non-empty, GLOBALLY unique task identifier; the
grading/telemetry trace key (the (learner_id,
task_id) pair TraceStore / GradeStore key on),
stamped at generation so a variant's trace and
grade join back to it exactly once.
seed — non-empty variant seed (D-029); derived from
(template_id, learner_id, milestone) so the
variant is reproducible and auditable.
params — typed parameter-slot values the generator filled;
JSON dict. An empty dict is legal (a slotless
template).
statement — non-empty rendered task statement shown to the
learner (distinct per learner by construction,
REQ-3-005).
starter_files — workspace scaffold: filename -> file content;
JSON dict. An empty dict is legal (no scaffold).
created_at — UTC generation timestamp.
"""
__tablename__ = "variant_record"
# The composite PK covers (learner_id, template_id) point lookups; the
# unique task_id covers get_by_task (the grading/telemetry join path);
# the two secondary indexes cover list_for_learner / list_by_template
# ordered by created_at without a sort step (Postgres target D-027).
__table_args__ = (
UniqueConstraint("task_id", name="uq_variant_record_task_id"),
Index("ix_variant_record_learner_created", "learner_id", "created_at"),
Index("ix_variant_record_template_created", "template_id", "created_at"),
)
learner_id: str = Field(primary_key=True)
template_id: str = Field(primary_key=True)
task_id: str
seed: str
# JSON columns: stored as TEXT on SQLite, native JSONB on Postgres (D-027).
params: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
statement: str
starter_files: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
created_at: datetime
@validates("learner_id", "template_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("seed")
def _seed_non_empty(self, key: str, value: str) -> str:
if not value:
raise ValueError(f"{key} must be a non-empty seed string")
return value
@validates("statement")
def _statement_non_empty(self, key: str, value: str) -> str:
if not value:
raise ValueError(f"{key} must be a non-empty statement string")
return value
class VariantStore(Protocol):
"""Persistence contract for reproducible per-learner task variants.
Implemented by SQLiteVariantStore (v0.3, D-027); a Postgres
implementation must satisfy the same surface.
"""
def save(self, variant: VariantRecord) -> None:
"""Persist a new variant. INSERT-ONLY on (learner_id, template_id):
the FIRST generated variant is authoritative (reproducibility,
D-029); a duplicate pair raises sqlalchemy.exc.IntegrityError to
the caller — the generator serves cached variants via `get`
instead of saving again. `task_id` is unique too: claiming an
existing trace key for a different variant is equally rejected.
NOT upsert; contrast GradeStore.save (latest-wins) and
TraceStore.append (dedup-keep-first, swallowed).
"""
...
def get(self, learner_id: str, template_id: str) -> VariantRecord | None:
"""The learner's stored variant for the template; None when none
exists. Detached from any DB session — safe to pass across layers.
"""
...
def get_by_task(self, task_id: str) -> VariantRecord | None:
"""The variant owning the task key (the grading/telemetry join
path); None when none exists. Detached from any DB session.
"""
...
def list_for_learner(self, learner_id: str) -> list[VariantRecord]:
"""All stored variants for the learner, ordered by created_at
ascending (chronological; task_id breaks same-instant ties).
Empty list when the learner has none.
"""
...
def list_by_template(self, template_id: str) -> list[VariantRecord]:
"""All stored variants generated from the template — one row per
learner — ordered by created_at ascending (chronological;
learner_id breaks same-instant ties). Empty list when the
template has none. The proctoring cross-check path (seed params
per learner) reads through this.
"""
...
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/grading stores.
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 SQLiteVariantStore:
"""SQLite-backed VariantStore (SQLModel). Third protocol-wrapped store
of the D-027 family (first: SQLiteTraceStore, second: SQLiteGradeStore).
"""
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 the other
# D-027 stores. save() never commits on the error path and the read
# paths never commit, but a uniform flag across the family keeps
# their detachment guarantees from diverging.
with Session(self._engine, expire_on_commit=False) as session:
yield session
def save(self, variant: VariantRecord) -> None:
# Plain INSERT, no merge: overwriting a stored variant would swap a
# learner's graded task underneath its trace and grade (audit
# corruption), so a duplicate identity is a race or bug to SURFACE,
# not paper over. The generator's cache path (get before generate)
# makes duplicate saves a programming error, not a normal flow.
# The trace store swallows its IntegrityError (dedup is the
# contract there); the grade store merges (latest-wins is the
# contract there); this store re-raises (first-wins is the
# contract here).
with self._session() as session:
try:
session.add(variant)
session.commit()
except sa.exc.IntegrityError:
session.rollback()
logger.debug(
"variant insert rejected (identity already stored): "
"learner=%s template=%s task=%s",
variant.learner_id,
variant.template_id,
variant.task_id,
)
raise
logger.debug(
"variant saved: %s/%s task=%s seed=%s",
variant.learner_id,
variant.template_id,
variant.task_id,
variant.seed,
)
def get(self, learner_id: str, template_id: str) -> VariantRecord | None:
with self._session() as session:
record = session.get(VariantRecord, (learner_id, template_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 get_by_task(self, task_id: str) -> VariantRecord | None:
with self._session() as session:
stmt = select(VariantRecord).where(VariantRecord.task_id == task_id)
record = session.exec(stmt).first()
if record is None:
return None
record.created_at = _as_utc(record.created_at)
session.expunge(record)
return record
def list_for_learner(self, learner_id: str) -> list[VariantRecord]:
with self._session() as session:
stmt = (
select(VariantRecord)
.where(VariantRecord.learner_id == learner_id)
# Chronological; task_id is a deterministic tie-break for
# variants stamped within the same instant.
.order_by(VariantRecord.created_at, VariantRecord.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 list_by_template(self, template_id: str) -> list[VariantRecord]:
with self._session() as session:
stmt = (
select(VariantRecord)
.where(VariantRecord.template_id == template_id)
# Chronological; learner_id is a deterministic tie-break.
.order_by(VariantRecord.created_at, VariantRecord.learner_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,305 @@
"""Task template library for seeded variant generation (D-029, REQ-3-005).
A `TaskTemplate` binds a competency (D-021-aligned corpus ID), a statement
skeleton with `{slot}` placeholders, typed `ParameterSlot`s, difficulty-
normalization rubric anchors (the expected feature envelope that bounds
variant fairness in the a-5 envelope test — grader-prompt shipment is the
tracked P4 follow-up; grading is variant-blind today), and starter-file
scaffolds served into the sandbox workdir (wired in P6).
Slot sampling is PURE CODE: `random.Random(seed)` over typed slots — fully
reproducible for a given seed, independent of the LLM. The LLM only renders
the seeded slot values into the statement skeleton (D-020 defense).
"""
from __future__ import annotations
import random
import re
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
SlotType = Literal["enum", "int_range", "string_set"]
class ParameterSlot(BaseModel):
"""One typed fill-in for a statement skeleton."""
model_config = ConfigDict(frozen=True)
name: str = Field(min_length=1)
type: SlotType
values: list[str] = Field(default_factory=list) # enum/string_set options
lo: int | None = None # int_range bounds
hi: int | None = None
@field_validator("values")
@classmethod
def _values_nonempty_for_enums(cls, v: list[str], info) -> list[str]:
if info.data.get("type") in ("enum", "string_set") and not v:
raise ValueError(f"slot {info.data.get('name')!r} needs values")
return v
def sample(self, rng: random.Random) -> str | int:
"""Deterministic sample from the seeded RNG. Validated after sampling."""
if self.type == "enum" or self.type == "string_set":
return rng.choice(self.values)
if self.type == "int_range":
lo = self.lo if self.lo is not None else 0
hi = self.hi if self.hi is not None else lo
if hi < lo:
raise ValueError(f"slot {self.name!r}: hi < lo")
return rng.randint(lo, hi)
raise ValueError(f"unsupported slot type: {self.type!r}")
def validate_value(self, value: str | int) -> bool:
"""Is `value` schema-valid for this slot? (params JSON gate, a-5.)"""
if self.type in ("enum", "string_set"):
return isinstance(value, str) and value in self.values
if self.type == "int_range":
lo = self.lo if self.lo is not None else 0
hi = self.hi if self.hi is not None else lo
return isinstance(value, int) and lo <= value <= hi
return False
class RubricAnchors(BaseModel):
"""Difficulty-normalization anchors for the grader (a-5).
Expected FEATURE ENVELOPE (digest-space): the expected effort band
for this template, so two variants of one template are held to the
same bar regardless of which slot values a learner drew. The a-5
envelope test (tests/variants/test_generator.py) binds variants to
these bands in code. Shipping them into the grader prompt context is
the P4 must-have follow-up tracked for final review: grading is
variant-blind in the current wiring (engine.py stamps
variant_seed=None), so today the anchors gate variant fairness in
tests only — not yet in the LLM prompt.
"""
model_config = ConfigDict(frozen=True)
expected_edit_count_band: tuple[int, int]
expected_min_test_runs: int
expected_error_fix_cycles_band: tuple[int, int]
notes: str = ""
class TaskTemplate(BaseModel):
"""A reusable task shape; variants instantiate it per learner."""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
competency_id: str = Field(min_length=1) # D-021 corpus alignment
title: str
statement_skeleton: str = Field(min_length=1) # {slot} placeholders
slots: list[ParameterSlot] = Field(min_length=1)
rubric_anchors: RubricAnchors
starter_files: dict[str, str] = Field(default_factory=dict) # path -> content
test_command: str
@field_validator("statement_skeleton")
@classmethod
def _skeleton_placeholders(cls, v: str) -> str:
if "{" not in v or "}" not in v:
raise ValueError("statement_skeleton needs at least one {slot}")
return v
def render(self, params: dict[str, str | int]) -> str:
"""Fill the skeleton with validated params."""
for slot in self.slots:
if slot.name not in params:
raise ValueError(f"missing param for slot {slot.name!r}")
if not slot.validate_value(params[slot.name]):
raise ValueError(f"invalid value for slot {slot.name!r}: {params[slot.name]!r}")
return self.statement_skeleton.format(**params)
def sample_params(self, seed: int) -> dict[str, str | int]:
"""Seeded, reproducible, schema-valid slot values (pure code)."""
rng = random.Random(seed)
return {slot.name: slot.sample(rng) for slot in self.slots}
# --- Template library (v0.3 initial set) --------------------------------------
# Competency IDs are D-021-aligned with the Python corpus
# (ai_service/corpus/learner_context.py) and the TS mock-data layer
# (packages/mock-data/competency-stacks.ts: deterministic cid() scheme).
TEMPLATES: dict[str, TaskTemplate] = {
"tpl-llm-judge": TaskTemplate(
id="tpl-llm-judge",
competency_id="stack-orchestration-c007",
title="Build an LLM-as-Judge Evaluator",
statement_skeleton=(
"Build a small LLM-as-judge evaluator for {domain} answers. "
"The judge must score each answer on {criterion} using a 0-4 scale, "
"return structured JSON, and handle at least {edge_cases} edge-case "
"answer classes (empty, off-topic, adversarial). Include a tiny "
"repro test set of at least {test_size} examples and print a summary "
"table of scores."
),
slots=[
ParameterSlot(
name="domain",
type="enum",
values=["customer-support", "code-review", "summarization", "tutoring"],
),
ParameterSlot(
name="criterion",
type="enum",
values=["factual-accuracy", "helpfulness", "safety", "completeness"],
),
ParameterSlot(name="edge_cases", type="int_range", lo=2, hi=4),
ParameterSlot(name="test_size", type="int_range", lo=3, hi=8),
],
rubric_anchors=RubricAnchors(
expected_edit_count_band=(3, 25),
expected_min_test_runs=2,
expected_error_fix_cycles_band=(0, 4),
notes="Slot draw changes the SCENARIO, not the engineering depth.",
),
starter_files={
"README.md": (
"# LLM-as-Judge Evaluator\n\n"
"Implement `judge.py`:\n"
"- `score(answer: str) -> dict` — 0-4 on the named criterion\n"
"- structured JSON output (schema below)\n"
"- edge-case classes handled explicitly\n"
"- `pytest` must pass\n"
),
"judge.py": "def score(answer: str) -> dict:\n raise NotImplementedError\n",
"test_judge.py": "def test_placeholder():\n assert True\n",
},
test_command="pytest -q",
),
"tpl-guardrail-schema": TaskTemplate(
id="tpl-guardrail-schema",
competency_id="stack-orchestration-c008",
title="Schema Guardrail Pipeline",
statement_skeleton=(
"Implement an output-validation guardrail for a model returning "
"{entity} records. Validate against a typed schema with {field_count} "
"required fields, coerce or reject {failure_mode} failures, and emit "
"a fallback response for invalid payloads. Cover with at least "
"{test_size} unit tests including malformed JSON."
),
slots=[
ParameterSlot(
name="entity",
type="enum",
values=["user-profile", "job-posting", "candidate", "invoice"],
),
ParameterSlot(
name="failure_mode",
type="enum",
values=["strict-reject", "coerce-when-safe"],
),
ParameterSlot(name="field_count", type="int_range", lo=4, hi=8),
ParameterSlot(name="test_size", type="int_range", lo=4, hi=10),
],
rubric_anchors=RubricAnchors(
expected_edit_count_band=(3, 30),
expected_min_test_runs=2,
expected_error_fix_cycles_band=(0, 5),
notes="All slot draws land in the same engineering band.",
),
starter_files={
"README.md": (
"# Schema Guardrail\n\nImplement `guardrail.py`:\n"
"- `validate(payload: dict) -> dict | Fallback`\n"
"- required-field checks, failure policy, fallback emission\n"
),
"guardrail.py": "def validate(payload: dict):\n raise NotImplementedError\n",
"test_guardrail.py": "def test_placeholder():\n assert True\n",
},
test_command="pytest -q",
),
"tpl-rag-chunker": TaskTemplate(
id="tpl-rag-chunker",
competency_id="stack-orchestration-c005",
title="RAG Chunking Strategy",
statement_skeleton=(
"Implement a document chunker for {doc_type} retrieval. Support "
"{strategy} chunking with a target size of ~{chunk_size} tokens, "
"preserve {invariant} across chunk boundaries, and evaluate overlap "
"quality with at least {test_size} fixture documents."
),
slots=[
ParameterSlot(
name="doc_type",
type="enum",
values=["technical-docs", "legal-contracts", "transcripts"],
),
ParameterSlot(
name="strategy",
type="enum",
values=["fixed-window", "semantic-boundary", "hybrid"],
),
ParameterSlot(
name="invariant",
type="enum",
values=["code-block-integrity", "section-headers", "sentence-completeness"],
),
ParameterSlot(name="chunk_size", type="int_range", lo=200, hi=800),
ParameterSlot(name="test_size", type="int_range", lo=3, hi=6),
],
rubric_anchors=RubricAnchors(
expected_edit_count_band=(4, 35),
expected_min_test_runs=2,
expected_error_fix_cycles_band=(0, 6),
notes="Strategy draw changes implementation shape, not depth.",
),
starter_files={
"README.md": (
"# RAG Chunker\n\nImplement `chunker.py`:\n"
"- `chunk(text: str) -> list[str]`\n- invariant preserved\n- tests green\n"
),
"chunker.py": "def chunk(text: str) -> list[str]:\n raise NotImplementedError\n",
"test_chunker.py": "def test_placeholder():\n assert True\n",
},
test_command="pytest -q",
),
}
_KNOWN_COMPETENCY_IDS: set[str] = {
# D-021: mirrored from ai_service/corpus/learner_context.py — the Python
# source of truth for stack-orchestration competencies used by v0.2 agents.
"stack-orchestration-c001",
"stack-orchestration-c002",
"stack-orchestration-c003",
"stack-orchestration-c004",
"stack-orchestration-c005",
"stack-orchestration-c007",
"stack-orchestration-c008",
"stack-orchestration-c011",
"stack-designer-c001",
"stack-designer-c002",
"stack-safety-c021",
}
def get_template(template_id: str) -> TaskTemplate | None:
return TEMPLATES.get(template_id)
def template_for_competency(competency_id: str) -> list[TaskTemplate]:
return [t for t in TEMPLATES.values() if t.competency_id == competency_id]
def validate_competency_binding() -> None:
"""All templates must bind to known D-021 corpus competency IDs."""
for t in TEMPLATES.values():
if t.competency_id not in _KNOWN_COMPETENCY_IDS:
raise ValueError(
f"template {t.id!r} binds unknown competency {t.competency_id!r}"
)
def slots_pattern_ok(skeleton: str, slots: list[ParameterSlot]) -> bool:
"""Every {placeholder} in the skeleton has a matching slot and vice versa."""
placeholders = set(re.findall(r"\{([a-z_][a-z0-9_]*)\}", skeleton))
slot_names = {s.name for s in slots}
return placeholders == slot_names
+277
View File
@@ -0,0 +1,277 @@
"""Variant API tests — generation, cache, distinctness over HTTP (Task 4-3-01).
Contract under test (api/variants.py, REQ-3-005):
POST /v1/variants {learner_id, template_id}
first request → 200 generated variant (task_id, seed, params,
statement, starter_files, competency_id)
repeat request → 200 the SAME cached variant with ZERO LLM
calls (D-029 reproducibility through the
whole HTTP stack)
unknown template → 404
POST /v1/variants {learner_id, competency_id}
bound competency → 200 the first template for that competency
unknown competency → 404
neither id given → 422
GET /v1/variants/{task_id}
generated earlier → 200 the stored variant (generate→get roundtrip)
unknown task → 404
GET /v1/variants?learner_id=...
→ 200 that learner's variants only (scoping); [] for a learner
with none.
Distinctness (REQ-3-005, API level): two learners POSTing the same
template receive distinct statements, seeds and task_ids, and GET by
task_id hands each back their own variant.
Wiring: per-test tmp-path SQLiteVariantStore + a pre-set VariantGenerator
(state-injection override — the lifespan adopts variant_store /
variant_generator from app.state instead of constructing them; same
pattern as test_grading.py / test_telemetry_ingest.py). The generator
binds a ScriptedRenderProvider so each test controls the render LLM
exactly, and counts calls so the cache test can assert the LLM was never
reached on the second POST.
Zero network: providers are MockProvider family members only (conftest
rule, enforced in _make_client).
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from ai_service.config import Settings
from ai_service.llm.mock import MockProvider
from ai_service.main import create_app
from ai_service.variants.generator import VariantGenerator
from ai_service.variants.store import SQLiteVariantStore
from ai_service.variants.templates import get_template
LEARNER_A = "variant-learner-a"
LEARNER_B = "variant-learner-b"
TEMPLATE = "tpl-llm-judge"
COMPETENCY = "stack-orchestration-c007" # tpl-llm-judge's D-021 binding
class ScriptedRenderProvider(MockProvider):
"""Deterministic render whose statement embeds the params (distinct per
draw); counts calls so the cache test asserts zero LLM calls on the
second POST. Mirrors tests/variants/test_generator.py."""
def __init__(self) -> None:
super().__init__()
self.calls = 0
async def chat(self, messages, *, model, temperature=0.7, response_format=None): # noqa: ANN001
self.calls += 1
import json
user = next(m.content for m in reversed(messages) if m.role == "user")
# Distinct per distinct params: hash the seeded slot lines.
fingerprint = abs(hash(user)) % 10_000
return json.dumps({"statement": f"Scripted variant #{fingerprint} — build it."})
@pytest.fixture()
def store(tmp_path: Path) -> Iterator[SQLiteVariantStore]:
s = SQLiteVariantStore(db_path=tmp_path / "variants.db")
yield s
s.close()
@pytest.fixture()
def provider() -> ScriptedRenderProvider:
return ScriptedRenderProvider()
def _make_client(
tmp_path: Path, store: SQLiteVariantStore, provider: MockProvider
) -> TestClient:
"""App + TestClient with a pre-set store + generator.
The lifespan adopts both (state-injection override); the generator
binds OUR provider, so the fixture — not Settings — scripts the LLM.
Cloud-free guard: the provider must be a MockProvider family member
(conftest rule, enforced here because this module builds its own
client rather than consuming the conftest one).
"""
assert isinstance(provider, MockProvider)
settings = Settings(
provider="mock",
db_path=tmp_path / "variant-test.db",
sandbox_dir=tmp_path / "sandboxes",
)
app = create_app(settings)
app.state.variant_store = store
if getattr(app.state, "variant_generator", None) is None:
app.state.variant_generator = VariantGenerator(
store, provider, model="gemma4:31b"
)
return TestClient(app)
def _post(
client: TestClient,
learner_id: str,
template_id: str | None = TEMPLATE,
competency_id: str | None = None,
):
return client.post(
"/v1/variants",
json={
"learner_id": learner_id,
**({"template_id": template_id} if template_id is not None else {}),
**({"competency_id": competency_id} if competency_id is not None else {}),
},
)
# -- POST: generation + response shape --------------------------------------------
class TestGenerate:
def test_post_returns_full_variant_payload(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = _post(client, LEARNER_A)
assert response.status_code == 200
body = response.json()
assert body["learner_id"] == LEARNER_A
assert body["template_id"] == TEMPLATE
assert body["competency_id"] == COMPETENCY # enrichment from get_template
assert body["task_id"].startswith("task-") and len(body["task_id"]) == len("task-") + 16
assert body["seed"] # non-empty D-029 seed
assert body["statement"] # rendered, non-empty
assert body["starter_files"] == get_template(TEMPLATE).starter_files
assert body["params"] # seeded slot values
assert body["created_at"] # ISO timestamp travels
assert provider.calls == 1 # first POST renders exactly once
def test_post_then_get_roundtrip(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
posted = _post(client, LEARNER_A)
assert posted.status_code == 200
fetched = client.get(f"/v1/variants/{posted.json()['task_id']}")
assert fetched.status_code == 200
assert fetched.json() == posted.json() # identical stored variant
def test_post_regenerate_is_cache_hit_no_llm_call(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
first = _post(client, LEARNER_A)
assert first.status_code == 200
assert provider.calls == 1
second = _post(client, LEARNER_A) # same (learner, template) → cache
assert second.status_code == 200
assert second.json() == first.json() # D-029: the SAME variant
assert provider.calls == 1, "cached regenerate must not render again"
# -- POST: distinctness (REQ-3-005, API level) ------------------------------------
class TestDistinctLearners:
def test_two_learners_distinct_statements_and_task_ids(
self, tmp_path, store, provider
):
with _make_client(tmp_path, store, provider) as client:
a = _post(client, LEARNER_A)
b = _post(client, LEARNER_B)
assert a.status_code == 200 and b.status_code == 200
a_body, b_body = a.json(), b.json()
# ...and each learner GETs back exactly their own variant
a_fetched = client.get(f"/v1/variants/{a_body['task_id']}")
b_fetched = client.get(f"/v1/variants/{b_body['task_id']}")
assert a_body["statement"] != b_body["statement"]
assert a_body["seed"] != b_body["seed"]
assert a_body["task_id"] != b_body["task_id"]
assert a_body["competency_id"] == b_body["competency_id"] # same bar (a-5)
assert a_fetched.status_code == 200 and a_fetched.json() == a_body
assert b_fetched.status_code == 200 and b_fetched.json() == b_body
# -- POST: template / competency resolution ---------------------------------------
class TestTemplateResolution:
def test_unknown_template_404(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = _post(client, LEARNER_A, template_id="tpl-does-not-exist")
assert response.status_code == 404
assert "no task template" in response.json()["detail"]
def test_competency_lookup_uses_first_bound_template(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = _post(
client, LEARNER_A, template_id=None, competency_id=COMPETENCY
)
assert response.status_code == 200
body = response.json()
assert body["template_id"] == TEMPLATE # the competency's first template
assert body["competency_id"] == COMPETENCY
def test_unknown_competency_404(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = _post(
client, LEARNER_A, template_id=None, competency_id="stack-none-c999"
)
assert response.status_code == 404
assert "no task template for competency" in response.json()["detail"]
def test_neither_id_given_422(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = _post(client, LEARNER_A, template_id=None, competency_id=None)
assert response.status_code == 422
# -- GET: stored reads ------------------------------------------------------------
class TestStoredReads:
def test_get_unknown_task_404(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = client.get("/v1/variants/task-0000000000000000")
assert response.status_code == 404
assert "no stored variant" in response.json()["detail"]
def test_list_scoped_to_one_learner(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
assert _post(client, LEARNER_A).status_code == 200
assert _post(
client, LEARNER_A, template_id="tpl-guardrail-schema"
).status_code == 200
assert _post(client, LEARNER_B).status_code == 200
listed = client.get("/v1/variants", params={"learner_id": LEARNER_A})
empty = client.get("/v1/variants", params={"learner_id": "nobody"})
assert listed.status_code == 200
variants = listed.json()["variants"]
assert len(variants) == 2 # LEARNER_A's two, LEARNER_B's excluded
assert {v["learner_id"] for v in variants} == {LEARNER_A}
assert {v["template_id"] for v in variants} == {TEMPLATE, "tpl-guardrail-schema"}
# chronological ordering; both carry the competency enrichment
assert all(v["competency_id"] for v in variants)
assert empty.status_code == 200
assert empty.json()["variants"] == []
def test_list_missing_learner_param_422(self, tmp_path, store, provider):
with _make_client(tmp_path, store, provider) as client:
response = client.get("/v1/variants")
assert response.status_code == 422
@@ -31,6 +31,7 @@ from ai_service.llm.types import Message
from ai_service.telemetry.ingest import TraceIntegrityMap
from ai_service.telemetry.models import TelemetryEvent
from ai_service.telemetry.store import SQLiteTraceStore
from ai_service.variants.store import SQLiteVariantStore
T0 = datetime(2026, 9, 12, 1, 0, 0, tzinfo=UTC)
RAW_MARKER = "SECRET-COMMAND-MARKER-7f3a"
@@ -447,3 +448,81 @@ class TestModelValidationSmoke:
"test_usage",
}
assert isinstance(score, BaseModel)
class TestVariantAnchorsShipment:
"""Phase 4 MH#4: variant anchors + seed ship into grading (a-5 same bar)."""
@pytest.fixture
def variant_engine(self, trace_store, grade_store, integrity, provider, tmp_path):
store = SQLiteVariantStore(db_path=tmp_path / "variants.db")
yield GradingEngine(
trace_store,
grade_store,
integrity,
provider,
model="gemma4:31b",
variant_store=store,
), store
store.close()
@pytest.fixture
def plain_engine(self, trace_store, grade_store, integrity, provider):
return GradingEngine(
trace_store, grade_store, integrity, provider, model="gemma4:31b"
)
async def test_variant_task_grades_with_anchors_and_seed(
self, variant_engine, trace_store, provider
):
engine, vstore = variant_engine
_ingest(trace_store, _complete_trace())
# A stored variant whose task_id matches the graded trace.
from datetime import UTC
from datetime import datetime as dt
from ai_service.variants.store import VariantRecord
vstore.save(
VariantRecord(
learner_id="engine-learner",
task_id="engine-task",
template_id="tpl-llm-judge",
seed="cafe" * 16,
params={"domain": "tutoring"},
statement="Scripted statement long enough to be legal.",
starter_files={"README.md": "x"},
created_at=dt.now(UTC),
)
)
record = await engine.grade("engine-learner", "engine-task")
assert record.verdict == "GRADED"
assert record.variant_seed == "cafe" * 16 # D-029 stamped
user_msgs = [m.content for req in provider.requests for m in req if m.role == "user"]
assert any("Expected effort envelope" in u for u in user_msgs)
assert any("tpl-llm-judge" in u for u in user_msgs)
assert any("expected_min_test_runs" in u for u in user_msgs)
async def test_non_variant_task_has_no_anchors(
self, variant_engine, trace_store, provider
):
engine, _ = variant_engine
_ingest(trace_store, _complete_trace())
record = await engine.grade("engine-learner", "engine-task")
assert record.verdict == "GRADED"
assert record.variant_seed is None
user_msgs = [m.content for req in provider.requests for m in req if m.role == "user"]
assert not any("Expected effort envelope" in u for u in user_msgs)
async def test_plain_engine_stays_variant_blind(
self, plain_engine, trace_store
):
_ingest(trace_store, _complete_trace())
record = await plain_engine.grade("engine-learner", "engine-task")
assert record.verdict == "GRADED"
assert record.variant_seed is None
@@ -0,0 +1 @@
"""Variant task generation tests (REQ-3-005)."""
@@ -0,0 +1,179 @@
"""Variant generator tests (Task 4-2-01, REQ-3-005) — D-029 + a-5 binding."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from ai_service.grading.features import compute_digest
from ai_service.llm.mock import MockProvider
from ai_service.telemetry.models import TelemetryEvent
from ai_service.variants.generator import (
MILESTONE,
VariantGenerator,
derive_seed,
derive_task_id,
)
from ai_service.variants.store import SQLiteVariantStore, VariantRecord
from ai_service.variants.templates import TEMPLATES, get_template
class ScriptedRenderProvider(MockProvider):
"""Deterministic render: the statement embeds the params (distinct per draw)."""
def __init__(self) -> None:
super().__init__()
self.calls = 0
async def chat(self, messages, model, response_format=None): # noqa: ANN001
self.calls += 1
import json
user = next(m.content for m in reversed(messages) if m.role == "user")
# Distinct per distinct params: hash the seeded slot lines.
fingerprint = abs(hash(user)) % 10_000
return json.dumps({"statement": f"Scripted variant #{fingerprint} — build it."})
class FailingRenderProvider(MockProvider):
"""Always fails D-020 validation -> deterministic fallback path."""
def __init__(self) -> None:
super().__init__()
self.calls = 0
async def chat(self, messages, model, response_format=None): # noqa: ANN001
self.calls += 1
return "this is not json at all"
@pytest.fixture()
def store(tmp_path): # noqa: ANN001
s = SQLiteVariantStore(db_path=tmp_path / "variants.db")
yield s
s.close()
async def test_two_learners_distinct_statements(store) -> None: # noqa: ANN001
provider = ScriptedRenderProvider()
gen = VariantGenerator(store, provider, model="mock")
a = await gen.generate("learner-a", "tpl-llm-judge")
b = await gen.generate("learner-b", "tpl-llm-judge")
assert a.statement != b.statement
assert a.seed != b.seed
assert a.task_id != b.task_id
async def test_same_learner_is_cached_no_second_llm_call(store) -> None: # noqa: ANN001
provider = ScriptedRenderProvider()
gen = VariantGenerator(store, provider, model="mock")
first = await gen.generate("learner-a", "tpl-llm-judge")
calls_after_first = provider.calls
second = await gen.generate("learner-a", "tpl-llm-judge")
assert first == second # identical stored variant (D-029 reproducible)
assert provider.calls == calls_after_first # cache hit: NO LLM call
async def test_seed_derivation_reproducible() -> None:
s1 = derive_seed("tpl-llm-judge", "learner-a", MILESTONE)
s2 = derive_seed("tpl-llm-judge", "learner-a", MILESTONE)
assert s1 == s2
assert derive_seed("tpl-llm-judge", "learner-b", MILESTONE) != s1
assert derive_task_id(s1).startswith("task-")
assert len(derive_task_id(s1)) == len("task-") + 16
async def test_params_are_schema_valid(store) -> None: # noqa: ANN001
gen = VariantGenerator(store, ScriptedRenderProvider(), model="mock")
record = await gen.generate("learner-a", "tpl-guardrail-schema")
template = get_template("tpl-guardrail-schema")
for slot in template.slots: # type: ignore[union-attr]
value = record.params[slot.name]
assert slot.validate_value(value), f"slot {slot.name} drew invalid {value!r}"
async def test_llm_failure_falls_back_deterministically(store) -> None: # noqa: ANN001
provider = FailingRenderProvider()
gen = VariantGenerator(store, provider, model="mock")
record = await gen.generate("learner-a", "tpl-rag-chunker")
template = get_template("tpl-rag-chunker")
expected = template.render({k: v for k, v in record.params.items()}) # type: ignore
assert record.statement == expected # skeleton render, seed-auditable
assert provider.calls == 2 # D-020 bounded retry, then fallback
async def test_unknown_template_raises(store) -> None: # noqa: ANN001
gen = VariantGenerator(store, ScriptedRenderProvider(), model="mock")
with pytest.raises(ValueError, match="no task template"):
await gen.generate("learner-a", "tpl-does-not-exist")
def test_fairness_envelope_same_bar_per_template() -> None:
"""a-5 (BINDING): every legal variant of one template fits the anchors.
For 10 different learners: draw the seeded params, then synthesize a
trace whose edit count is sampled INSIDE the template's anchor band and
whose test runs meet the anchor minimum — the resulting digests must
all sit within the template's expected feature envelope. That is the
testable form of "same bar": no slot draw can push a variant outside
the effort band the grader context assumes.
"""
import random
for template in TEMPLATES.values():
anchors = template.rubric_anchors
lo_edits, hi_edits = anchors.expected_edit_count_band
t0 = datetime(2026, 9, 12, tzinfo=UTC)
for i in range(10):
params = template.sample_params(seed=10_000 + i)
rng = random.Random(i)
n_edits = rng.randint(lo_edits, hi_edits)
events = [
TelemetryEvent(
learner_id=f"fair-learner-{i}",
task_id=f"fair-task-{i}",
seq=n,
kind="file_diff",
payload={"path": f"f{n}.py"},
ts=t0 + timedelta(seconds=n * 10),
sandbox_id="sbx-fair",
)
for n in range(n_edits)
]
# Meet the anchor's minimum test-run expectation.
for t in range(anchors.expected_min_test_runs):
events.append(
TelemetryEvent(
learner_id=f"fair-learner-{i}",
task_id=f"fair-task-{i}",
seq=len(events),
kind="test_result",
payload={"passed": t == anchors.expected_min_test_runs - 1},
ts=t0 + timedelta(seconds=(n_edits + t) * 10),
sandbox_id="sbx-fair",
)
)
digest = compute_digest(events)
assert lo_edits <= digest.edit_count <= hi_edits
assert digest.test_pass_count + digest.test_fail_count >= (
anchors.expected_min_test_runs
)
# Slot values never appear in the digest (no scenario leakage into
# grading features — difficulty stays scenario-independent).
digest_json = digest.model_dump_json()
for value in params.values():
assert str(value) not in digest_json or isinstance(value, int)
async def test_variant_record_roundtrips_through_store(store) -> None: # noqa: ANN001
gen = VariantGenerator(store, ScriptedRenderProvider(), model="mock")
record = await gen.generate("learner-a", "tpl-llm-judge")
fetched = store.get("learner-a", "tpl-llm-judge")
assert fetched is not None
assert fetched.statement == record.statement
assert fetched.seed == record.seed
by_task = store.get_by_task(record.task_id)
assert by_task is not None
assert by_task.learner_id == "learner-a"
assert isinstance(record, VariantRecord)
@@ -0,0 +1,358 @@
"""SQLiteVariantStore tests (REQ-3-005, D-027).
Each test gets its own tmp-path SQLite file — no shared disk state. Covers:
- save / get roundtrip by (learner_id, template_id) AND by task_id
(all fields survive, including nested JSON params, starter_files
filename->content map, and the tz-aware created_at contract)
- insert-only: a second save for the same (learner_id, template_id)
raises IntegrityError (first-wins, documented choice); the original
row is untouched — NOT upsert, NOT swallowed
- unique task_id: a second variant claiming an existing trace key is
rejected even under a different (learner, template) pair
- list_for_learner / list_by_template scoped + chronological + auditable
(seed + params readable back — the proctoring cross-check path)
- unknown learner / template / task -> None / empty lists
- scoping: rows for other learners/templates never leak
- 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.variants.store import SQLiteVariantStore, VariantRecord
_BASE_TS = datetime(2026, 9, 12, 12, 0, 0, tzinfo=UTC)
def make_variant(
task_id: str = "task-1",
learner_id: str = "learner-1",
template_id: str = "template-1",
seed: str = "seed-a1b2c3",
params: dict[str, Any] | None = None,
statement: str | None = None,
starter_files: dict[str, Any] | None = None,
created_at: datetime | None = None,
) -> VariantRecord:
"""Canonical kwargs builder — tests override only what they assert on."""
return VariantRecord(
learner_id=learner_id,
template_id=template_id,
task_id=task_id,
seed=seed,
params=params
if params is not None
else {
"scenario": "cache invalidation",
"constraints": ["no external deps", "streaming"],
"data_shape": {"rows": 10_000, "columns": ["ts", "event", "user"]},
},
statement=statement
if statement is not None
else (
"Implement a cache layer for the event stream service that "
"survives restarts without losing buffered rows, learner "
f"{learner_id} edition."
),
starter_files=starter_files
if starter_files is not None
else {
"src/stream_cache.py": "class StreamCache:\n pass\n",
"tests/test_stream_cache.py": "def test_roundtrip():\n pass\n",
},
created_at=created_at if created_at is not None else _BASE_TS,
)
@pytest.fixture
def store(tmp_path: Path) -> SQLiteVariantStore:
s = SQLiteVariantStore(db_path=tmp_path / "variants.db")
yield s
s.close()
def test_save_and_get_roundtrip(store: SQLiteVariantStore) -> None:
variant = make_variant()
store.save(variant)
# Point lookup by variant identity (learner_id, template_id).
fetched = store.get("learner-1", "template-1")
assert fetched is not None
assert fetched.learner_id == "learner-1"
assert fetched.template_id == "template-1"
assert fetched.task_id == "task-1"
assert fetched.seed == "seed-a1b2c3"
assert fetched.params == variant.params
assert fetched.statement == variant.statement
assert fetched.starter_files == variant.starter_files
assert fetched.created_at == _BASE_TS
assert fetched.created_at.tzinfo is UTC # tz-normalized on read
# Same row via the grading/telemetry trace key — the join path a
# grade or proctor check uses with only a task_id in hand.
by_task = store.get_by_task("task-1")
assert by_task is not None
assert by_task.learner_id == "learner-1"
assert by_task.template_id == "template-1"
assert by_task.seed == "seed-a1b2c3"
assert by_task.statement == variant.statement
assert by_task.starter_files == variant.starter_files
assert by_task.params == variant.params
assert by_task.created_at.tzinfo is UTC
def test_get_unknown_returns_none(store: SQLiteVariantStore) -> None:
store.save(make_variant())
assert store.get("learner-1", "template-missing") is None
assert store.get("learner-missing", "template-1") is None
assert store.get("nobody", "nothing") is None
assert store.get_by_task("task-missing") is None
def test_duplicate_learner_template_pair_raises_integrity_error(
store: SQLiteVariantStore,
) -> None:
"""THE contract of this store (insert-only, first wins).
The first generated variant is authoritative (D-029 reproducibility:
the seed re-derives the same variant); a duplicate save is a
programming error or lost race, not a normal flow — the generator's
cache path serves `get` instead. So the IntegrityError surfaces to
the caller and the stored row is left untouched.
"""
first = make_variant(statement="first authoritative statement")
store.save(first)
second = make_variant(
task_id="task-2", # distinct task key; the PAIR collides
seed="seed-999",
statement="an impostor statement",
created_at=_BASE_TS + timedelta(hours=1),
)
with pytest.raises(sa.exc.IntegrityError):
store.save(second)
# First save survived intact — nothing was overwritten.
fetched = store.get("learner-1", "template-1")
assert fetched is not None
assert fetched.task_id == "task-1"
assert fetched.statement == "first authoritative statement"
assert fetched.seed == "seed-a1b2c3"
assert fetched.created_at == _BASE_TS
# The rejected save left no row behind under its task_id either.
assert store.get_by_task("task-2") is None
def test_duplicate_task_id_raises_integrity_error(store: SQLiteVariantStore) -> None:
# task_id is the grading/telemetry trace key — globally unique: a
# second variant may never claim an existing trace key, even under a
# different (learner_id, template_id) pair.
store.save(make_variant(learner_id="learner-1", template_id="template-1"))
with pytest.raises(sa.exc.IntegrityError):
store.save(
make_variant(
learner_id="learner-2",
template_id="template-2",
task_id="task-1", # collides with learner-1's trace key
)
)
# Rejected row not partially stored under either identity.
assert store.get("learner-2", "template-2") is None
assert len(store.list_by_template("template-2")) == 0
def test_list_for_learner_roundtrip_and_audit(store: SQLiteVariantStore) -> None:
# Created out of insertion order; list must come back chronological.
store.save(make_variant(template_id="template-c", task_id="task-c",
created_at=_BASE_TS + timedelta(hours=2)))
store.save(make_variant(template_id="template-a", task_id="task-a",
created_at=_BASE_TS))
store.save(make_variant(template_id="template-b", task_id="task-b",
created_at=_BASE_TS + timedelta(hours=1)))
variants = store.list_for_learner("learner-1")
assert [v.template_id for v in variants] == [
"template-a",
"template-b",
"template-c",
]
assert all(v.learner_id == "learner-1" for v in variants)
hours = (timedelta(hours=0), timedelta(hours=1), timedelta(hours=2))
assert all(
v.created_at == _BASE_TS + offset
for v, offset in zip(variants, hours, strict=True)
)
assert all(v.created_at.tzinfo is UTC for v in variants)
# Auditable: every stored variant reads back its seed and typed params
# (the proctoring cross-check path reads exactly this).
for v in variants:
assert v.seed.startswith("seed-")
assert v.params["scenario"] == "cache invalidation"
assert "streaming" in v.params["constraints"]
assert v.params["data_shape"]["rows"] == 10_000
assert v.starter_files["tests/test_stream_cache.py"].count("\n") >= 1
def test_list_by_template_roundtrip_and_audit(store: SQLiteVariantStore) -> None:
# Three learners on the same template: distinct, auditable variants.
store.save(make_variant(learner_id="learner-b", task_id="task-b",
seed="seed-222",
created_at=_BASE_TS + timedelta(hours=1)))
store.save(make_variant(learner_id="learner-a", task_id="task-a",
seed="seed-111", created_at=_BASE_TS))
store.save(make_variant(learner_id="learner-c", task_id="task-c",
seed="seed-333",
created_at=_BASE_TS + timedelta(hours=2)))
variants = store.list_by_template("template-1")
assert [v.learner_id for v in variants] == ["learner-a", "learner-b", "learner-c"]
assert all(v.template_id == "template-1" for v in variants)
# Every learner's variant carries its own seed + params (auditable,
# REQ-3-005: variant parameters persisted and auditable).
seeds = {v.learner_id: v.seed for v in variants}
assert seeds == {
"learner-a": "seed-111",
"learner-b": "seed-222",
"learner-c": "seed-333",
}
assert all(v.params["scenario"] == "cache invalidation" for v in variants)
assert all(v.created_at.tzinfo is UTC for v in variants)
def test_list_unknown_returns_empty_lists(store: SQLiteVariantStore) -> None:
store.save(make_variant())
assert store.list_for_learner("nobody") == []
assert store.list_by_template("no-template") == []
def test_lists_are_scoped(store: SQLiteVariantStore) -> None:
store.save(make_variant(learner_id="learner-1", template_id="template-1",
task_id="task-1"))
store.save(make_variant(learner_id="learner-2", template_id="template-1",
task_id="task-2"))
store.save(make_variant(learner_id="learner-1", template_id="template-2",
task_id="task-3"))
# learner lists see only that learner's rows.
assert [v.template_id for v in store.list_for_learner("learner-1")] == [
"template-1",
"template-2",
]
assert [v.template_id for v in store.list_for_learner("learner-2")] == ["template-1"]
# template lists see one row per learner, none from other templates.
template_rows = store.list_by_template("template-1")
assert sorted(v.learner_id for v in template_rows) == ["learner-1", "learner-2"]
assert all(v.template_id == "template-1" for v in template_rows)
# get stays a pair-scoped point lookup: same template, other learner.
assert store.get("learner-1", "template-2") is not None
assert store.get("learner-2", "template-2") is None
def test_empty_params_and_starter_files_roundtrip(store: SQLiteVariantStore) -> None:
# Legal shapes: a slotless template carries no params; a variant may
# ship without a workspace scaffold.
store.save(make_variant(params={}, starter_files={}))
fetched = store.get("learner-1", "template-1")
assert fetched is not None
assert fetched.params == {}
assert fetched.starter_files == {}
def test_rows_are_detached_after_save(store: SQLiteVariantStore, tmp_path: Path) -> None:
# The API layer hands VariantRecords across layers; rows must survive
# the store that produced them being closed (no open-session ORM magic).
store.save(make_variant())
fetched = store.get("learner-1", "template-1")
by_task = store.get_by_task("task-1")
store.close()
assert fetched is not None
assert fetched.statement == fetched.statement # usable post-close
assert fetched.starter_files["src/stream_cache.py"] == "class StreamCache:\n pass\n"
assert by_task is not None
assert by_task.seed == "seed-a1b2c3"
# A fresh store on the same file sees the same row (durability).
reopened = SQLiteVariantStore(db_path=tmp_path / "variants.db")
try:
again = reopened.get("learner-1", "template-1")
assert again is not None
assert again.starter_files["src/stream_cache.py"].startswith("class StreamCache")
assert again.created_at.tzinfo is UTC
finally:
reopened.close()
def test_pragmas_are_applied(store: SQLiteVariantStore) -> 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 every distinct-row write lands.
"""
db_path = tmp_path / "variants.db"
n_variants = 60 # distinct (learner, template) pairs, one save each
stop_writing = threading.Event()
writer = SQLiteVariantStore(db_path=db_path)
reader = SQLiteVariantStore(db_path=db_path)
try:
def write_variants() -> None:
for seq in range(n_variants):
writer.save(
make_variant(
learner_id=f"learner-{seq}",
template_id="template-1",
task_id=f"task-{seq}",
seed=f"seed-{seq:03d}",
created_at=_BASE_TS + timedelta(seconds=seq),
)
)
stop_writing.set()
def read_variants() -> None:
while not stop_writing.is_set():
reader.list_by_template("template-1")
# Final read after the writer is done.
assert len(reader.list_by_template("template-1")) == n_variants
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(write_variants), pool.submit(read_variants)]
for future in futures:
future.result(timeout=30)
# Every save was a distinct row: nothing lost, none doubled.
assert len(writer.list_by_template("template-1")) == n_variants
finally:
reader.close()
writer.close()
@@ -0,0 +1,102 @@
"""Task template tests (Task 4-1-01, REQ-3-005)."""
from __future__ import annotations
import random
import pytest
from ai_service.variants.templates import (
TEMPLATES,
ParameterSlot,
TaskTemplate,
get_template,
slots_pattern_ok,
template_for_competency,
validate_competency_binding,
)
def test_all_templates_bind_to_real_competency_ids() -> None:
validate_competency_binding() # raises on any unknown binding
assert len(TEMPLATES) >= 3
def test_slot_validation_rejects_bad_values() -> None:
slot = ParameterSlot(name="domain", type="enum", values=["a", "b"])
assert slot.validate_value("a")
assert not slot.validate_value("c")
assert not slot.validate_value(3)
rng = random.Random(42)
assert slot.sample(rng) in {"a", "b"}
def test_int_range_slot_bounds() -> None:
slot = ParameterSlot(name="n", type="int_range", lo=2, hi=4)
rng = random.Random(0)
for _ in range(20):
assert 2 <= slot.sample(rng) <= 4
assert slot.validate_value(3)
assert not slot.validate_value(5)
assert not slot.validate_value("3")
def test_seeded_sampling_is_reproducible() -> None:
tpl = TEMPLATES["tpl-llm-judge"]
first = tpl.sample_params(seed=1234)
second = tpl.sample_params(seed=1234)
other = tpl.sample_params(seed=1235)
assert first == second # D-029: same seed -> identical params
assert first != other # different seed -> (near-certainly) different draw
def test_skeleton_placeholders_match_slots() -> None:
for tpl in TEMPLATES.values():
assert slots_pattern_ok(tpl.statement_skeleton, tpl.slots), tpl.id
def test_render_validates_params_and_fills() -> None:
tpl = TEMPLATES["tpl-llm-judge"]
params = tpl.sample_params(seed=7)
rendered = tpl.render(params)
for value in params.values():
assert str(value) in rendered
with pytest.raises(ValueError, match="invalid value"):
tpl.render({**params, "edge_cases": 99}) # out of band
def test_rubric_anchors_present_per_template() -> None:
for tpl in TEMPLATES.values():
anchors = tpl.rubric_anchors
assert anchors.expected_edit_count_band[0] <= anchors.expected_edit_count_band[1]
assert anchors.expected_min_test_runs >= 1
cycles = anchors.expected_error_fix_cycles_band
assert cycles[0] <= cycles[1]
def test_starter_files_defined_per_template() -> None:
for tpl in TEMPLATES.values():
assert tpl.starter_files, f"{tpl.id} missing starter scaffolds"
assert "README.md" in tpl.starter_files
assert tpl.test_command
def test_competency_lookup() -> None:
tpls = template_for_competency("stack-orchestration-c005")
assert len(tpls) == 1
assert get_template("nope-xyz") is None
def test_bad_skeleton_rejected() -> None:
with pytest.raises(ValueError, match="slot"):
TaskTemplate(
id="tpl-bad",
competency_id="stack-orchestration-c001",
title="Bad",
statement_skeleton="no placeholders at all",
slots=[ParameterSlot(name="x", type="enum", values=["a"])],
rubric_anchors=TEMPLATES["tpl-llm-judge"].rubric_anchors,
starter_files={},
test_command="pytest -q",
)
+84
View File
@@ -0,0 +1,84 @@
/**
* Nextcraft — Grading Types
*
* Rubric-scored grades over real process traces: per-criterion 0-4
* scores, strengths, gaps, and verdict, persisted latest-state per
* (learner_id, task_id) with the trace digest that fed the rubric prompt.
*
* Source of truth: the Python models in
* `apps/ai-service/ai_service/grading/engine.py` (`RubricScore`) and
* `apps/ai-service/ai_service/grading/store.py` (`GradeRecord`),
* served over HTTP by `apps/ai-service/ai_service/api/assessment.py`
* (`GradeResponse`). This file mirrors those models field-for-field;
* any schema change must be made in both places.
*/
/**
* The graded rubric criteria set (RUBRIC_CRITERIA, grading/engine.py).
* Scores are 0-4 per criterion; the exact four keys are required.
*/
export interface RubricCriteria {
process_quality: number;
correctness: number;
debugging_discipline: number;
test_usage: number;
}
/** Rubric verdict carried in `RubricScore.verdict`. */
export type RubricVerdict = 'mastered' | 'developing' | 'not_yet';
/**
* Validated LLM rubric output for a GRADED trace. Mirrors `RubricScore`
* (ai_service/grading/engine.py): criteria 0-4, strengths/gaps (1-2
* sentences each), rubric verdict.
*/
export interface RubricScore {
criteria: RubricCriteria;
strengths: string[];
gaps: string[];
verdict: RubricVerdict;
}
/** Machine-readable grade outcome (`GradeRecord.verdict`). */
export type GradeOutcome =
| 'GRADED'
| 'UNGRADABLE_TRACE_INCOMPLETE'
| 'UNGRADABLE_EMPTY_TRACE';
/**
* Gate detail surfaced in `scores` for UNGRADABLE_* records (G-4):
* the integrity flag reason and/or missing seq numbers. Never co-present
* with rubric scores — a grade carries one or the other.
*/
export interface GradeGateDetail {
integrity_flag: string | null;
missing_seqs: number[];
}
/** Union of the two `scores` shapes a grade can carry. */
export type GradeScores = RubricScore | GradeGateDetail;
/**
* A persisted grade for one (learner_id, task_id) trace — latest state
* (a regrade replaces the row). Mirrors `GradeRecord`
* (ai_service/grading/store.py) field-for-field, except `scores` is
* typed as the discriminated `GradeScores` union instead of the Python
* side's untyped JSON dict.
*/
export interface GradeRecord {
learner_id: string;
/** The graded task — joins to `TaskVariant.task_id`. */
task_id: string;
/** Task-variant seed (D-029); null while grading is variant-blind. */
variant_seed: string | null;
/** Compact trace digest (D-028) that fed the rubric prompt; {} for gate records. */
digest: Record<string, unknown>;
/** Rubric scores (GRADED) or gate detail (UNGRADABLE_*); never both. */
scores: GradeScores;
/** Machine-readable outcome; rubric verdict rides in `scores.verdict`. */
verdict: GradeOutcome;
/** Provider model that produced the scores; "none" for gate records. */
model: string;
/** ISO 8601 UTC grade timestamp; a regrade replaces it. */
created_at: string;
}
+3 -1
View File
@@ -1,5 +1,7 @@
export * from './domain';
export * from './grading';
export * from './marketplace';
export * from './telemetry';
export * from './user';
export * from './ui';
export * from './ui';
export * from './variants';
+50
View File
@@ -0,0 +1,50 @@
/**
* Nextcraft — Task Variant Types
*
* Seeded per-learner task variants: a task template instantiated for one
* learner with a reproducible seed, sampled parameter slots, a rendered
* statement, and starter-file scaffolds. One (learner_id, template_id)
* pair is exactly ONE variant, forever — regeneration is a cache read.
*
* Source of truth: the Python model in
* `apps/ai-service/ai_service/variants/store.py` (`VariantRecord`),
* served over HTTP by `apps/ai-service/ai_service/api/variants.py`
* (`VariantResponse`, which adds the `competency_id` enrichment). This
* file mirrors those fields field-for-field; any schema change must be
* made in both places.
*/
/**
* Typed parameter-slot values the generator sampled for the variant's
* template (seed-derivable, D-029). Enum/string-set slots draw strings;
* int-range slots draw numbers. An empty object is legal (a slotless
* template).
*/
export interface VariantParams {
[slotName: string]: string | number;
}
/**
* A generated task variant. Mirrors `VariantRecord`
* (ai_service/variants/store.py) plus the API-layer `competency_id`
* enrichment (ai_service/api/variants.py) — the D-021 competency the
* variant's template binds to.
*/
export interface TaskVariant {
learner_id: string;
/** Globally unique task key — the grading and telemetry join key. */
task_id: string;
template_id: string;
/** D-021 competency the template binds to (resolved at read time). */
competency_id: string;
/** Reproducible per-(template, learner, milestone) seed (D-029). */
seed: string;
/** Seeded slot values the statement was rendered from. */
params: VariantParams;
/** Rendered task statement shown to the learner. */
statement: string;
/** Workspace scaffold: filename → file content. */
starter_files: Record<string, string>;
/** ISO 8601 UTC generation timestamp. */
created_at: string;
}