Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ jobs:
- run: npm ci
- run: npm run check:node-version
- run: npm run check
- name: Deterministic Tutor quality hard gate
run: npm run test:tutor-quality
- run: npm run test:unit
- run: npm run build

Expand Down
4 changes: 3 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,9 @@ fixtures and authoring examples only. The normative details are in
[ssot_function_definition_CourseContent.md](../ssot/ssot_function_definition_CourseContent.md),
[adr/0006-unified-course-content-and-tutor-strategy.md](adr/0006-unified-course-content-and-tutor-strategy.md),
[adr/0007-mastery-driven-tutor-progression.md](adr/0007-mastery-driven-tutor-progression.md),
and [ssot_function_definition_LearningQuestions.md](../ssot/ssot_function_definition_LearningQuestions.md).
[ssot_function_definition_LearningQuestions.md](../ssot/ssot_function_definition_LearningQuestions.md),
and the deterministic quality gates in
[ssot_function_definition_TutorQuality.md](../ssot/ssot_function_definition_TutorQuality.md).

### Dynamic Course Content selection

Expand Down
96 changes: 96 additions & 0 deletions docs/superpowers/plans/2026-09-27-tutor-quality-stage-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Tutor Quality Stage 1 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Establish deterministic Tutor quality hard gates, a reusable scenario runner, the PWM regression case, and compatible Course Content validation in UnoSim and UnoSim-Examples.

**Architecture:** Keep runtime ownership in `TutorService`, `CurriculumTutorAdapter`, and existing Course Content schemas. Add one pure authoring validator that reuses production fact matching, one filesystem CLI adapter, and test-only scenario orchestration around the real Tutor service with only the provider faked. Pin the Examples CI consumer to the resulting UnoSim commit.

**Tech Stack:** TypeScript, Vitest, Zod/YAML, GitHub Actions, existing Course Content loader and Tutor planner.

---

Specification: `ssot/ssot_function_definition_TutorQuality.md`

### Task 1: Establish the normative quality contract

**Files:**
- Create: `ssot/ssot_function_definition_TutorQuality.md`
- Modify: `docs/ARCHITECTURE.md`
- Test: `scripts/check-docs.mjs`

1. Add the deterministic learning-support proxy, trust boundaries, runtime/content invariants, scenario contract, CI contract, and explicit Stage 2 exclusions.
2. Link the SSOT from the architecture documentation.
3. Run `npm run check:docs` and commit the documentation.

### Task 2: Build the reusable scenario runner and adversarial service cases

**Files:**
- Create: `tests/server/services/tutor/support/tutor-quality-scenario-runner.ts`
- Create: `tests/server/services/tutor/tutor-quality-scenarios.test.ts`
- Modify: `server/services/tutor/tutor-service.ts`

1. Write failing scenarios for provider errors before commit, invalid output, complete solutions, multiple primary questions, contradictory provider metadata, exact/near repetition, transition/blocked repair retention, and TutorPlan question authority.
2. Run the focused tests and confirm each new invariant fails for the intended reason.
3. Implement the minimal validation/metadata-boundary changes in `TutorService`.
4. Run the focused tests and existing Tutor service/planning tests; commit.

### Task 3: Preserve TQ-REG-001 with real PWM inputs

**Files:**
- Create: `tests/fixtures/tutor-quality/TQ-REG-001-pwm.ino`
- Create: `tests/server/services/tutor/tq-reg-001-pwm.test.ts`

1. Write a failing scenario using the real PWM sketch and the narrowed Topic activation from the reviewed Course Content.
2. Assert that `variables-and-serial` is not selected, a strong-answer repetition is repaired, and phase/revision/state metadata remains consistent.
3. Make only runner/fixture corrections if needed; production changes require a separate failing invariant test.
4. Run the focused regression test and commit.

### Task 4: Add deterministic Course Content quality validation

**Files:**
- Create: `server/services/course-content/tutor-quality-schema.ts`
- Create: `server/services/course-content/tutor-quality-validator.ts`
- Create: `server/services/course-content/tutor-quality-directory-validator.ts`
- Create: `tests/server/services/course-content/tutor-quality-validator.test.ts`
- Create: `scripts/validate-tutor-course-content.mjs`
- Create: `tests/server/services/course-content/tutor-quality-directory-validator.test.ts`
- Modify: `package.json`

1. Write failing unit tests for activation cases, missing coverage, unreachable Concepts/Indicators, mastery probe/kind shortages, prerequisite reachability, DEEPEN exhaustion/kind shortages, and a valid bundle.
2. Implement a pure validator using existing facts/matcher/question applicability/deepening defaults.
3. Write a failing CLI test for local bundle loading, hashes, and quality-case parsing.
4. Implement the filesystem fetch adapter and machine-readable/nonzero CLI result.
5. Run focused tests, typecheck, and commit.

### Task 5: Make UnoSim-Examples pass the authoring hard gate

**Files (UnoSim-Examples worktree):**
- Create: `tutor/quality-cases.yaml`
- Modify: `tutor/topics/variables-and-serial.yaml`
- Modify: `tutor/topics/long-values.yaml`
- Modify: `tutor/manifest.yaml`

1. Add minimal positive/negative activation cases, including PWM as a negative case for `variables-and-serial`.
2. Run the validator and observe the expected mastery/DEEPEN exhaustion failures.
3. Add the smallest structurally distinct application/transfer probes needed for reachable mastery and default DEEPEN; update hashes.
4. Re-run the pinned local validator and commit.

### Task 6: Add explicit CI hard gates and compatible-version pinning

**Files:**
- Modify (UnoSim): `.github/workflows/ci.yml`, `package.json`
- Create (UnoSim-Examples): `.github/workflows/tutor-quality.yml`, `.unosim-compatible-commit`

1. Add `test:tutor-quality` as a deterministic, credential-free UnoSim PR step.
2. Pin the Examples workflow to the final UnoSim Stage 1 commit and invoke its Course Content validator against the Examples checkout.
3. Validate workflow syntax structurally and run both local commands.
4. Commit CI changes.

### Task 7: Verify and review the complete change

1. Run `npm run check`, `npm run check:docs`, `npm run test:tutor-quality`, focused Tutor/Course Content tests, and `npm run test:unit` in UnoSim.
2. Run the exact pinned validator against the UnoSim-Examples worktree and verify hashes/status are clean.
3. Inspect both diffs and commits for accidental changes, secrets, scope creep, or reliance on a real provider.
4. Perform a fresh self-review because parallel review agents are disabled for this task; fix verified issues and repeat affected gates.
5. Report both branch heads without pushing or merging.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"test:deployment": "tsx scripts/deployment/gateway-deployment.ts",
"capacity:calibrate": "tsx scripts/calibrate-capacity.ts",
"test:security:inputs": "TEST_BUDGET_SUITE=security-inputs TEST_BUDGET_MS=15000 LOG_LEVEL=warn vitest run --project=unit-node --project=unit-node-http tests/shared/input-limits.test.ts tests/shared/websocket-direction-schemas.test.ts tests/server/security/safe-paths.test.ts tests/server/routes/compiler.routes.test.ts tests/integration/simulation-state-sequence.test.ts --reporter=default --reporter=./scripts/test-budget-reporter.mjs",
"test:tutor-quality": "TEST_BUDGET_SUITE=tutor-quality TEST_BUDGET_MS=15000 LOG_LEVEL=warn vitest run --project=unit-node tests/server/services/tutor tests/server/services/course-content --reporter=default --reporter=./scripts/test-budget-reporter.mjs",
"validate:tutor-course-content": "tsx scripts/validate-tutor-course-content.mjs",
"test:all": "npm run test:unit && npm run test:integration && npm run test:docker",
"test:watch": "LOG_LEVEL=info vitest --project=unit-client --project=unit-node --project=unit-node-http",
"test:coverage": "node scripts/coverage-guard.mjs prepare && TEST_BUDGET_SUITE=unit-coverage TEST_BUDGET_WARN_MS=75000 TEST_BUDGET_FAIL_MS=90000 LOG_LEVEL=warn vitest run --coverage --project=unit-client --project=unit-node --project=unit-node-http --reporter=default --reporter=./scripts/test-budget-reporter.mjs && node scripts/coverage-guard.mjs validate",
Expand Down
20 changes: 20 additions & 0 deletions scripts/validate-tutor-course-content.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import path from "node:path";
import { validateTutorCourseContentDirectory } from "../server/services/course-content/tutor-quality-directory-validator.ts";

const directory = process.argv[2];
if (!directory) {
console.error("Usage: npm run validate:tutor-course-content -- <course-content-directory>");
process.exitCode = 2;
} else {
const resolved = path.resolve(directory);
const issues = await validateTutorCourseContentDirectory(resolved);
if (issues.length === 0) {
console.log(`Tutor Course Content quality passed: ${resolved}`);
} else {
for (const issue of issues) {
const scope = [issue.caseId, issue.topicId, issue.conceptId, issue.indicatorId].filter(Boolean).join("/");
console.error(`${issue.code}${scope ? ` [${scope}]` : ""}: ${issue.message}`);
}
process.exitCode = 1;
}
}
108 changes: 108 additions & 0 deletions server/services/course-content/tutor-quality-directory-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { parse as parseYaml } from "yaml";
import type { FullCommitSha, RepositorySlug } from "@shared/examples";
import { CourseContentLoader, type LoadedCourseContentSnapshot } from "./course-content-loader";
import { tutorQualityCasesSchema } from "./tutor-quality-schema";
import { BUILT_IN_TUTOR_STRATEGY, type EffectiveTutorStrategy } from "../tutor/strategy/effective-tutor-strategy";
import {
validateTutorContentQuality,
type ResolvedTutorQualityCase,
type TutorContentQualityIssue,
} from "./tutor-quality-validator";

const LOCAL_REVISION = "0".repeat(40) as FullCommitSha;
const LOCAL_REPOSITORY = "local/course-content" as RepositorySlug;

export async function validateTutorCourseContentDirectory(directory: string): Promise<TutorContentQualityIssue[]> {
const root = path.resolve(directory);
let loaded: Awaited<ReturnType<CourseContentLoader["load"]>>;
try {
const loader = new CourseContentLoader({
fetchText: async (url, maxBytes) => readBoundedLocalFile(root, url, maxBytes),
});
loaded = await loader.load(LOCAL_REPOSITORY, LOCAL_REVISION);
} catch {
return [directoryIssue("invalid-course-content-bundle", "Course Content bundle could not be loaded")];
}
if (loaded.tutor.status !== "valid") {
return [directoryIssue("invalid-course-content-bundle", "Course Content Tutor bundle is absent or invalid")];
}
try {
const qualitySource = await readFile(path.join(root, "tutor/quality-cases.yaml"), "utf8");
const parsed = tutorQualityCasesSchema.safeParse(parseYaml(qualitySource));
if (!parsed.success) {
return [directoryIssue("invalid-quality-cases", "Tutor quality case manifest is invalid")];
}
const resolution = resolveCases(parsed.data.cases, loaded);
if (resolution.issues.length > 0) return resolution.issues;
return validateTutorContentQuality(loaded.tutor.topics, resolution.cases);
} catch {
return [directoryIssue("invalid-quality-cases", "Tutor quality case manifest could not be loaded")];
}
}

async function readBoundedLocalFile(root: string, url: URL, maxBytes: number): Promise<string> {
const revisionMarker = `/${LOCAL_REVISION}/`;
const markerIndex = url.pathname.indexOf(revisionMarker);
if (markerIndex < 0) throw new Error("Invalid local Course Content URL");
const relativePath = decodeURIComponent(url.pathname.slice(markerIndex + revisionMarker.length));
const filePath = path.resolve(root, relativePath);
if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) throw new Error("Path escapes Course Content root");
const source = await readFile(filePath, "utf8");
if (Buffer.byteLength(source, "utf8") > maxBytes) throw new Error("Course Content file exceeds size limit");
return source;
}

function resolveCases(
cases: readonly { id: string; example: string; expectedTopics: readonly string[]; forbiddenTopics: readonly string[] }[],
loaded: LoadedCourseContentSnapshot,
): { cases: ResolvedTutorQualityCase[]; issues: TutorContentQualityIssue[] } {
const resolved: ResolvedTutorQualityCase[] = [];
const issues: TutorContentQualityIssue[] = [];
for (const qualityCase of cases) {
const example = loaded.examples.find(({ id }) => id === qualityCase.example);
const main = example?.files.find(({ name }) => name === example.main);
if (!example || !main) {
issues.push({
code: "quality-case-example-not-found",
message: `Quality case ${qualityCase.id} references missing Example ${qualityCase.example}`,
caseId: qualityCase.id,
});
continue;
}
resolved.push({
id: qualityCase.id,
exampleId: qualityCase.example,
code: main.content,
expectedTopics: qualityCase.expectedTopics,
forbiddenTopics: qualityCase.forbiddenTopics,
learnStrategy: resolveCaseStrategy(loaded, example.tutorAnnotation?.strategy, "LEARN"),
deepenStrategy: resolveCaseStrategy(loaded, example.tutorAnnotation?.strategy, "DEEPEN"),
});
}
return { cases: resolved, issues };
}

function resolveCaseStrategy(
loaded: LoadedCourseContentSnapshot,
exampleStrategyId: string | undefined,
phase: "LEARN" | "DEEPEN",
): EffectiveTutorStrategy {
if (loaded.tutor.status !== "valid") return BUILT_IN_TUTOR_STRATEGY;
const perExample = loaded.tutor.strategies.find(({ id }) => id === exampleStrategyId);
if (perExample) return perExample;
const manifest = loaded.tutor.manifest;
const phaseStrategyId = phase === "DEEPEN" && manifest.schemaVersion === 2
? manifest.phaseStrategies?.deepen
: undefined;
const strategyId = phaseStrategyId ?? manifest.defaultStrategy;
return loaded.tutor.strategies.find(({ id }) => id === strategyId) ?? BUILT_IN_TUTOR_STRATEGY;
}

function directoryIssue(
code: "invalid-course-content-bundle" | "invalid-quality-cases",
message: string,
): TutorContentQualityIssue {
return { code, message };
}
29 changes: 29 additions & 0 deletions server/services/course-content/tutor-quality-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { z } from "zod";

const SAFE_ID = /^[a-z][a-z0-9-]{0,63}$/;

const tutorQualityCaseSchema = z.object({
id: z.string().regex(SAFE_ID),
example: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/),
expectedTopics: z.array(z.string().regex(SAFE_ID)).max(64).default([]),
forbiddenTopics: z.array(z.string().regex(SAFE_ID)).max(64).default([]),
}).strict().superRefine((value, context) => {
if (value.expectedTopics.length === 0 && value.forbiddenTopics.length === 0) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "A quality case must declare an expected or forbidden Topic" });
}
const overlap = value.expectedTopics.find((topicId) => value.forbiddenTopics.includes(topicId));
if (overlap) {
context.addIssue({ code: z.ZodIssueCode.custom, message: `Topic cannot be expected and forbidden: ${overlap}` });
}
});

export const tutorQualityCasesSchema = z.object({
schemaVersion: z.literal(1),
cases: z.array(tutorQualityCaseSchema).min(1).max(256),
}).strict().superRefine((value, context) => {
if (new Set(value.cases.map(({ id }) => id)).size !== value.cases.length) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["cases"], message: "Quality case ids must be unique" });
}
});

export type TutorQualityCases = z.infer<typeof tutorQualityCasesSchema>;
Loading
Loading