File size: 4,166 Bytes
65d68f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import type { LessonContract, VisualAction, VisualBeatGraph } from "./contracts";

export type EngineValidationIssue = {
  severity: "error" | "warning";
  code: string;
  message: string;
  beatId?: string;
};

const IMPLEMENTED_ACTIONS = new Set<VisualAction["type"]>([
  "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<string, { beatId: string; order: number }>();
  const answers = new Map<string, { beatId: string; order: number }>();

  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();
}