import type { LessonContract, VisualAction, VisualBeatGraph } from "./contracts"; export type EngineValidationIssue = { severity: "error" | "warning"; code: string; message: string; beatId?: string; }; const IMPLEMENTED_ACTIONS = new Set([ "add_object", "remove_object", "move_object", "draw_arrow", "write_text", "write_equation", "reveal_label", "highlight", "dim", "focus", "plot_graph", "animate_particles", "trace_wave", "show_measurement", "question", "thinking_pause", "hint", "answer_reveal", "clear_region", "mascot_reaction", ]); export function validateVisualBeatGraph(graph: VisualBeatGraph): EngineValidationIssue[] { const issues: EngineValidationIssue[] = []; let previousEnd = 0; const questions = new Map(); const answers = new Map(); graph.beats.forEach((beat, index) => { if (beat.order !== index + 1) { issues.push({ severity: "error", code: "BEAT_ORDER", message: "Beat order must be contiguous.", beatId: beat.beatId }); } if (beat.estimatedStartMs !== previousEnd) { issues.push({ severity: "error", code: "TIMELINE_GAP", message: "Beat timeline must be contiguous.", beatId: beat.beatId }); } if (beat.estimatedEndMs <= beat.estimatedStartMs) { issues.push({ severity: "error", code: "INVALID_DURATION", message: "Beat duration must be positive.", beatId: beat.beatId }); } if (!beat.sourceReferences.length) { issues.push({ severity: "error", code: "UNGROUNDED_BEAT", message: "Every beat requires a textbook source.", beatId: beat.beatId }); } for (const ref of beat.sourceReferences) { if (!ref.sourceId || !ref.chapterId || !ref.pageNumber || ref.excerptHash.length < 16) { issues.push({ severity: "error", code: "INVALID_SOURCE_REF", message: "Source reference is incomplete.", beatId: beat.beatId }); } } for (const action of beat.actions) { if (!IMPLEMENTED_ACTIONS.has(action.type)) { issues.push({ severity: "error", code: "UNSUPPORTED_ACTION", message: `Action ${action.type} is not implemented.`, beatId: beat.beatId }); } if (action.type === "question") questions.set(action.objectId, { beatId: beat.beatId, order: beat.order }); if (action.type === "answer_reveal") answers.set(action.objectId, { beatId: beat.beatId, order: beat.order }); } previousEnd = beat.estimatedEndMs; }); if (previousEnd !== graph.durationMs) { issues.push({ severity: "error", code: "DURATION_MISMATCH", message: "Graph duration must equal the final beat end." }); } for (const [objectId, question] of questions) { const answer = answers.get(objectId); if (!answer || answer.order <= question.order) { issues.push({ severity: "error", code: "ANSWER_REVEAL_ORDER", message: `Question ${objectId} must be answered in a later beat.`, beatId: question.beatId }); } } return issues; } export function validateLessonContract(contract: LessonContract, graph: VisualBeatGraph): EngineValidationIssue[] { const issues = validateVisualBeatGraph(graph); if (contract.lessonId !== graph.lessonId) { issues.push({ severity: "error", code: "LESSON_ID_MISMATCH", message: "Lesson and graph IDs differ." }); } if (!contract.objectives.length || !contract.recap.length) { issues.push({ severity: "error", code: "INCOMPLETE_TEACHING_CONTRACT", message: "Objectives and recap are required." }); } if (contract.teachingSequence.length !== graph.beats.length) { issues.push({ severity: "error", code: "BEAT_COUNT_MISMATCH", message: "Teaching sequence and visual graph differ." }); } const covered = new Set(contract.sourceCoverage.flatMap((entry) => entry.narrationBeatIds)); for (const beat of graph.beats) { if (!covered.has(beat.beatId)) { issues.push({ severity: "warning", code: "COVERAGE_GAP", message: "Beat is not listed in the coverage matrix.", beatId: beat.beatId }); } } return issues; } export function implementedVisualActions() { return [...IMPLEMENTED_ACTIONS].sort(); }