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
18 changes: 18 additions & 0 deletions evals/tutor-quality/semantic-case-review-matrix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Stage-2B Semantic Case Review Matrix

The cases below are reserved Stage-2B intents, not executable Semantic Corpus
records. They become executable only after the exact sketch, frozen question,
answer binding, ordered history, declared application context, allowed
evidence, and human-reviewed interpretation are pinned. All initial observed
cases are known/exposed development material; none is Gold or held out.

| Case | Required pinned inputs and review | Current status |
| --- | --- | --- |
| `TQ-SEM-002` — `millis()` / `delay()` | Exact timing sketch; exact preceding question and correct answer; complete bounded turn order; phase/difficulty; reviewed timing facts and a human-defined range for a genuinely new non-blocking reasoning demand. | Reserved intent only; not executable. |
| `TQ-SEM-003` — `lastReport` | Exact declaration, assignments, and use; preceding question/answer/history; application context and difficulty; reviewed distinction between stored timestamp and elapsed duration. | Reserved intent only; not executable. |
| `TQ-SEM-004` — `unsigned long`, `millis()`, overflow | Exact type declaration and uses; ordered timing questions and answers; phase/Strategy/difficulty; reviewed return/type/wraparound facts and acceptable transfer beyond prior reasoning. | Reserved intent only; not executable. |
| `TQ-SEM-005` — matrix `int` versus `byte` | Exact matrix declaration, indexing, and use; preceding question/answer/history; Course Content/Strategy context or explicit absence; reviewed platform type/range facts and interpretation of the actual learner misconception. | Reserved intent only; not executable. |

No case details are inferred from these names. Adding any case or changing its
evidence/reference is a Semantic Corpus version change. Candidate-specific
exposure remains a separate `CaseExposureRecord`, never a corpus field.
77 changes: 77 additions & 0 deletions evals/tutor-quality/semantic-corpus.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
schemaVersion: tutor-quality-semantic-corpus-v1
corpusId: tutor-quality-semantic
corpusVersion: 1
digest: 00f7848de47e08547b86a5da0210ded6b3eb1e83f31a0ba34f7d56ad4205dac8
cases:
- id: TQ-SEM-001
caseVersion: 1
digest: 04ba6e31a87e4595fc11b72c5a1f9ac260fa22fcd0d7186a6dcd4729d3e6a0b9
purpose: Check that a substantively correct INPUT_PULLUP answer is recognized without false correction.
role: development
sketch:
reference: semantic-fixtures/TQ-SEM-001-input-pullup.ino
digest: e0ab46c8cadd3bbf5cab80b0237d8303918e152023de1793b2d1f40ae698d9c5
frozenPreTurnContext:
schemaVersion: tutor-quality-frozen-context-v1
sketchRef: semantic-fixtures/TQ-SEM-001-input-pullup.ino
sketchDigest: e0ab46c8cadd3bbf5cab80b0237d8303918e152023de1793b2d1f40ae698d9c5
question: Welche logische Bedingung muss erfüllt sein, damit die LED eingeschaltet wird?
learnerAnswer:
text: buttonPin, also an PIN2 muss GND anliegen!
category: fully-correct
bindsToQuestion: Welche logische Bedingung muss erfüllt sein, damit die LED eingeschaltet wird?
priorDialog: []
courseContent:
kind: free-tutor
difficulty: 30
digest: f89c3200af9ff90d7ab282b8e90f511b50c6c8593b20745bc51a005639340e0a
expectedAnswerInterpretation: When the button connects buttonPin to GND, INPUT_PULLUP makes the input read LOW; this sketch writes HIGH to ledPin when digitalRead(buttonPin) == LOW.
acceptableTutorResponses:
diagnoses:
- Recognizes the learner's core condition as correct, allowing substantively equivalent wording.
feedbackApproaches:
- May clarify the active-low input without claiming the learner's answer is wrong.
followUps:
- May ask a sketch-grounded question that adds a distinct reasoning demand.
answerCategory: fully-correct
knownFailurePatterns:
- correct-answer-rejected
assessableDimensions:
- factual-correctness
- sketch-code-grounding
- learner-answer-diagnosis
- precision
dimensionEvidenceLimitations:
instructional-usefulness: No learner history beyond this synthetic answer is represented.
scaffolding: No learner history beyond this synthetic answer is represented.
dialogic-progression: The case has no preceding dialog turn.
non-repetition: The case has no preceding dialog turn.
difficulty-appropriateness: The case scopes any judgment to its fixed synthetic input only.
evidenceSources:
- id: sketch
kind: repository/sketch
reference: semantic-fixtures/TQ-SEM-001-input-pullup.ino
digest: e0ab46c8cadd3bbf5cab80b0237d8303918e152023de1793b2d1f40ae698d9c5
- id: input-pullup-reference-draft
kind: draft-factual-reference
reference: TQ-SEM-001-factual-reference
digest: e0d1f86e092f980a8adf4b771cfc673d943a5492129f95dc850409a3123b5af0
permittedExternalKnowledge: false
factualReferenceBundle:
id: TQ-SEM-001-factual-reference
version: 1
sourceKind: draft-factual-reference
reviewStatus: pending-review
digest: e0d1f86e092f980a8adf4b771cfc673d943a5492129f95dc850409a3123b5af0
provenance: Draft facts derived from the pinned sketch and requiring reviewer confirmation of board and wiring assumptions.
limitations:
- Confirm the target board, button-to-GND wiring, and LED_BUILTIN active-high behavior before Gold use.
facts:
- id: internal-pullup
statement: pinMode(buttonPin, INPUT_PULLUP) enables the microcontroller's internal pull-up for buttonPin.
- id: grounded-input-low
statement: With the declared button-to-GND wiring, pressing the button makes digitalRead(buttonPin) return LOW.
- id: sketch-condition
statement: The sketch writes HIGH to ledPin, which is LED_BUILTIN, when digitalRead(buttonPin) == LOW.
humanReference:
status: pending-review
14 changes: 14 additions & 0 deletions evals/tutor-quality/semantic-fixtures/TQ-SEM-001-input-pullup.ino
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const int buttonPin = 2;
const int ledPin = LED_BUILTIN;

void setup() {
pinMode(buttonPin, INPUT_PULLUP);
pinMode(ledPin, OUTPUT);
}

void loop() {
digitalWrite(
ledPin,
digitalRead(buttonPin) == LOW ? HIGH : LOW
);
}
234 changes: 234 additions & 0 deletions server/services/tutor/evaluation/semantic/case-exposure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { canonicalSemanticDigest, canonicalSemanticJson, deepFreeze, isSha256Digest } from "./semantic-canonical";
import type { CaseExposureStatus } from "./semantic-types";

export type CaseExposureAvailability = "available" | "unavailable" | "unknown";
export type CaseExposureArtifact = "caseDefinition" | "tutorOutput" | "humanReference" | "judgeResult";

export interface CaseExposureSnapshot {
readonly recordedAt: string;
readonly availableArtifacts: Readonly<Record<CaseExposureArtifact, CaseExposureAvailability>>;
readonly targetedChange: {
readonly status: "informed" | "not-informed" | "unknown";
readonly rationale?: string;
};
}

export interface CaseExposureRecordInput {
readonly candidateIdentity: string;
readonly corpusId: string;
readonly corpusVersion: number;
readonly caseId: string;
readonly semanticCaseDigest: string;
readonly history: readonly CaseExposureSnapshot[];
}

export interface CaseExposureRecord extends CaseExposureRecordInput {
readonly schemaVersion: "tutor-quality-case-exposure-v1";
readonly recordVersion: number;
readonly exposureStatus: CaseExposureStatus;
readonly identity: string;
readonly digest: string;
readonly previousRecordDigest?: string;
}

export interface CaseExposureBinding {
readonly candidateIdentity: string;
readonly corpusId: string;
readonly corpusVersion: number;
readonly caseId: string;
readonly semanticCaseDigest: string;
}

export type CaseExposureRecordValidation =
| { readonly valid: true; readonly record: CaseExposureRecord }
| { readonly valid: false; readonly reason: string };

const ARTIFACTS: readonly CaseExposureArtifact[] = ["caseDefinition", "tutorOutput", "humanReference", "judgeResult"];
const OUTCOME_ARTIFACTS: readonly CaseExposureArtifact[] = ["tutorOutput", "humanReference", "judgeResult"];

function recordIdentity(input: CaseExposureRecordInput, recordVersion: number, previousRecordDigest?: string): string {
return canonicalSemanticDigest({
schemaVersion: "tutor-quality-case-exposure-v1",
candidateIdentity: input.candidateIdentity,
corpusId: input.corpusId,
corpusVersion: input.corpusVersion,
caseId: input.caseId,
semanticCaseDigest: input.semanticCaseDigest,
recordVersion,
...(previousRecordDigest ? { previousRecordDigest } : {}),
});
}

function exposureStatus(snapshot: CaseExposureSnapshot): CaseExposureStatus {
if (snapshot.targetedChange.status === "informed") return "used-for-targeted-change";
if (snapshot.targetedChange.status === "unknown" || ARTIFACTS.some((artifact) => snapshot.availableArtifacts[artifact] === "unknown")) return "unknown";
if (OUTCOME_ARTIFACTS.some((artifact) => snapshot.availableArtifacts[artifact] === "available")) return "outcome-exposed";
if (snapshot.availableArtifacts.caseDefinition === "available") return "case-known";
return "unexposed";
}

function validateSnapshotObject(snapshot: CaseExposureSnapshot, index: number): void {
if (snapshot === null || typeof snapshot !== "object") throw new Error(`Exposure history[${index}] must be an object`);
if (Object.keys(snapshot).some((key) => !["recordedAt", "availableArtifacts", "targetedChange"].includes(key))) throw new Error(`Exposure history[${index}] contains an unsupported field`);
}

function validateSnapshotTimestamp(snapshot: CaseExposureSnapshot, index: number, previous?: CaseExposureSnapshot): void {
if (typeof snapshot.recordedAt !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(snapshot.recordedAt) || Number.isNaN(Date.parse(snapshot.recordedAt))) {
throw new Error(`Exposure history[${index}] recordedAt must be an explicit UTC timestamp`);
}
if (previous && Date.parse(snapshot.recordedAt) <= Date.parse(previous.recordedAt)) throw new Error("Exposure history timestamps must be strictly increasing");
}

function validateSnapshotArtifacts(snapshot: CaseExposureSnapshot, index: number): void {
if (!snapshot.availableArtifacts || Object.keys(snapshot.availableArtifacts).length !== ARTIFACTS.length || ARTIFACTS.some((artifact) => Object.getOwnPropertyDescriptor(snapshot.availableArtifacts, artifact) === undefined || !["available", "unavailable", "unknown"].includes(snapshot.availableArtifacts[artifact]))) {
throw new Error(`Exposure history[${index}] must record availability for every artifact`);
}
}

function validateSnapshotTargetedChange(snapshot: CaseExposureSnapshot, index: number): void {
if (!snapshot.targetedChange || !["informed", "not-informed", "unknown"].includes(snapshot.targetedChange.status)) {
throw new Error(`Exposure history[${index}] targeted-change state is invalid`);
}
if (Object.keys(snapshot.targetedChange).some((key) => !["status", "rationale"].includes(key))) throw new Error(`Exposure history[${index}] targeted-change state contains an unsupported field`);
if (snapshot.targetedChange.status === "informed" && (!snapshot.targetedChange.rationale || snapshot.targetedChange.rationale.trim().length === 0)) {
throw new Error(`Exposure history[${index}] requires the rationale for a targeted change`);
}
if (snapshot.targetedChange.status !== "informed" && snapshot.targetedChange.rationale !== undefined) {
throw new Error(`Exposure history[${index}] cannot attach a targeted-change rationale to a non-targeted state`);
}
}

function validateSnapshotHistory(snapshot: CaseExposureSnapshot, previous?: CaseExposureSnapshot): void {
if (previous) {
for (const artifact of ARTIFACTS) {
if (previous.availableArtifacts[artifact] === "available" && snapshot.availableArtifacts[artifact] !== "available") {
throw new Error(`Exposure history cannot erase known availability of ${artifact}`);
}
}
if (previous.targetedChange.status === "informed" && snapshot.targetedChange.status !== "informed") {
throw new Error("Exposure history cannot erase a previously recorded targeted change");
}
}
}

function validateSnapshot(snapshot: CaseExposureSnapshot, index: number, previous?: CaseExposureSnapshot): void {
validateSnapshotObject(snapshot, index);
validateSnapshotTimestamp(snapshot, index, previous);
validateSnapshotArtifacts(snapshot, index);
validateSnapshotTargetedChange(snapshot, index);
validateSnapshotHistory(snapshot, previous);
}

function validateInput(input: CaseExposureRecordInput, record = false): void {
const allowedKeys = record
? ["schemaVersion", "candidateIdentity", "corpusId", "corpusVersion", "caseId", "semanticCaseDigest", "history", "recordVersion", "exposureStatus", "identity", "digest", "previousRecordDigest"]
: ["candidateIdentity", "corpusId", "corpusVersion", "caseId", "semanticCaseDigest", "history"];
if (input === null || typeof input !== "object" || Object.keys(input).some((key) => !allowedKeys.includes(key))) throw new Error("CaseExposureRecord input contains an unsupported field");
if (!isSha256Digest(input.candidateIdentity)) throw new Error("Candidate identity must be a lowercase SHA-256 digest");
if (!isSha256Digest(input.semanticCaseDigest)) throw new Error("Semantic Case digest must be a lowercase SHA-256 digest");
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(input.corpusId)) throw new Error("Corpus ID is invalid");
if (!Number.isInteger(input.corpusVersion) || input.corpusVersion < 1) throw new Error("Corpus version must be a positive integer");
if (!/^[A-Z0-9][A-Z0-9-]{0,63}$/.test(input.caseId)) throw new Error("Semantic Case ID is invalid");
if (!Array.isArray(input.history) || input.history.length === 0) throw new Error("Exposure history must contain at least one version snapshot");
input.history.forEach((snapshot, index) => validateSnapshot(snapshot, index, input.history[index - 1]));
const versions = input.history.length;
if (versions > 10_000) throw new Error("Exposure history exceeds the bounded record limit");
}

function sameBinding(left: CaseExposureBinding, right: CaseExposureBinding): boolean {
return left.candidateIdentity === right.candidateIdentity
&& left.corpusId === right.corpusId
&& left.corpusVersion === right.corpusVersion
&& left.caseId === right.caseId
&& left.semanticCaseDigest === right.semanticCaseDigest;
}

function recordDigestContent(record: Omit<CaseExposureRecord, "digest"> | CaseExposureRecord): Record<string, unknown> {
const { digest: _digest, ...source } = record as CaseExposureRecord;
return source;
}

function buildCaseExposureRecord(input: CaseExposureRecordInput, previousRecordDigest?: string): CaseExposureRecord {
const recordVersion = input.history.length;
const base = {
schemaVersion: "tutor-quality-case-exposure-v1" as const,
...input,
history: input.history.map((snapshot) => ({
...snapshot,
availableArtifacts: { ...snapshot.availableArtifacts },
targetedChange: { ...snapshot.targetedChange },
})),
recordVersion,
exposureStatus: exposureStatus(input.history.at(-1)!),
identity: recordIdentity(input, recordVersion, previousRecordDigest),
...(previousRecordDigest ? { previousRecordDigest } : {}),
};
return { ...base, digest: canonicalSemanticDigest(base) };
}

function rebuildExposureHistoryPrefix(input: CaseExposureRecordInput): CaseExposureRecord {
let previous: CaseExposureRecord | undefined;
for (let end = 1; end <= input.history.length; end += 1) {
const prefix: CaseExposureRecordInput = {
candidateIdentity: input.candidateIdentity,
corpusId: input.corpusId,
corpusVersion: input.corpusVersion,
caseId: input.caseId,
semanticCaseDigest: input.semanticCaseDigest,
history: input.history.slice(0, end),
};
previous = buildCaseExposureRecord(prefix, previous?.digest);
}
if (!previous) throw new Error("Exposure history prefix must not be empty");
return previous;
}

export function createCaseExposureRecord(input: CaseExposureRecordInput, previous?: CaseExposureRecord): CaseExposureRecord {
validateInput(input);
const recordVersion = input.history.length;
if (previous) {
if (!sameBinding(input, previous)) throw new Error("CaseExposureRecord revision cannot change Candidate or Semantic Case binding");
const priorValidation = validateCaseExposureRecord(previous, input);
if (!priorValidation.valid) throw new Error(`CaseExposureRecord revision requires a valid prior record: ${priorValidation.reason}`);
if (recordVersion !== previous.recordVersion + 1) throw new Error("CaseExposureRecord revision must append exactly one new history snapshot");
if (canonicalSemanticJson(input.history.slice(0, previous.history.length)) !== canonicalSemanticJson(previous.history)) {
throw new Error("CaseExposureRecord revision must append to prior history without rewriting it");
}
} else if (recordVersion !== 1) {
throw new Error("Initial CaseExposureRecord must start at version 1");
}
const previousRecordDigest = previous?.digest;
if (previousRecordDigest !== undefined && !isSha256Digest(previousRecordDigest)) throw new Error("Previous CaseExposureRecord digest is invalid");
return deepFreeze(buildCaseExposureRecord(input, previousRecordDigest));
}

export function validateCaseExposureRecord(input: unknown, expected: CaseExposureBinding): CaseExposureRecordValidation {
try {
if (input === null || typeof input !== "object" || Array.isArray(input)) return { valid: false, reason: "record-must-be-object" };
const record = input as CaseExposureRecord;
validateInput(record, true);
if (record.schemaVersion !== "tutor-quality-case-exposure-v1") return { valid: false, reason: "unsupported-record-schema" };
if (record.recordVersion !== record.history.length) return { valid: false, reason: "record-version-history-mismatch" };
if (!sameBinding(record, expected)) return { valid: false, reason: "candidate-or-case-binding-mismatch" };
if (record.exposureStatus !== exposureStatus(record.history.at(-1)!)) return { valid: false, reason: "exposure-status-mismatch" };
if (record.recordVersion === 1 ? record.previousRecordDigest !== undefined : !isSha256Digest(record.previousRecordDigest)) {
return { valid: false, reason: "previous-record-digest-history-mismatch" };
}
if (record.recordVersion > 1) {
const prefix: CaseExposureRecordInput = {
candidateIdentity: record.candidateIdentity,
corpusId: record.corpusId,
corpusVersion: record.corpusVersion,
caseId: record.caseId,
semanticCaseDigest: record.semanticCaseDigest,
history: record.history.slice(0, -1),
};
if (rebuildExposureHistoryPrefix(prefix).digest !== record.previousRecordDigest) return { valid: false, reason: "previous-record-digest-history-mismatch" };
}
if (!isSha256Digest(record.identity) || record.identity !== recordIdentity(record, record.recordVersion, record.previousRecordDigest)) return { valid: false, reason: "record-identity-mismatch" };
if (!isSha256Digest(record.digest) || record.digest !== canonicalSemanticDigest(recordDigestContent(record))) return { valid: false, reason: "record-digest-mismatch" };
return { valid: true, record };
} catch (error) {
return { valid: false, reason: error instanceof Error ? error.message : "invalid-case-exposure-record" };
}
}
Loading
Loading