All files / server/services/tutor/curriculum curriculum-schema.ts

85.98% Statements 92/107
64.15% Branches 34/53
100% Functions 26/26
90.58% Lines 77/85

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277    33x 33x   33x 15462x 526801x 526801x   15462x     429x 15462x 15462x   33x 33x 33x 33x   33x         5534x       33x       33x     5576x   33x         33x         33x               33x                           33x                     3288x     3288x         33x                   33x                     33x           236x         33x         33x                       33x                           33x   33x                                         2257x       938x 749x 938x 148x       749x 749x 749x 1083x 1082x 937x 937x 935x 935x   936x       938x 2375x 749x 749x   935x 2369x 1007x   749x 749x 749x 749x 749x               748x 2369x 2369x     2369x                   748x 935x 1007x                         748x 288x 288x 288x                     748x 1683x         11x 10x    
import { z } from "zod";
 
const SAFE_ID = /^[a-z][a-z0-9-]{0,63}$/;
const SAFE_SHA256 = /^[a-f0-9]{64}$/i;
 
const hasControlCharacters = (value: string): boolean => {
  for (const character of value) {
    const code = character.codePointAt(0) ?? 0;
    Iif ((code <= 8) || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127) return true;
  }
  return false;
};
 
const boundedText = (max: number) => z.string().trim().min(1).max(max)
  .refine((value) => !hasControlCharacters(value), "Control characters are not allowed")
  .refine((value) => !/https?:\/\//i.test(value), "URLs are not allowed in curriculum text");
 
const idSchema = z.string().regex(SAFE_ID);
export const questionKindSchema = z.enum(["recall", "concept", "application", "prediction", "transfer"]);
const strategySchema = z.enum(["concrete-model", "smaller-subproblem", "perspective-change", "prerequisite"]);
const factKindSchema = z.enum(["type-used", "array-declared", "serial-call"]);
 
export const factRequirementSchema = z.object({
  fact: factKindSchema,
  values: z.array(boundedText(64)).max(12).optional(),
  elementTypes: z.array(boundedText(64)).max(12).optional(),
}).strict().refine(
  (value) => value.values !== undefined || value.elementTypes !== undefined || value.fact === "array-declared",
  "Fact requirements need a bounded value selector",
);
 
const activationSchema = z.object({
  any: z.array(factRequirementSchema).min(1).max(16),
}).strict();
 
const difficultyRangeSchema = z.tuple([
  z.number().int().min(1).max(100),
  z.number().int().min(1).max(100),
]).refine(([min, max]) => min <= max, "Difficulty range must be ordered");
 
const misconceptionSchema = z.object({
  id: idSchema,
  description: boundedText(500),
}).strict();
 
const indicatorSchema = z.object({
  id: idSchema,
  description: boundedText(500),
}).strict();
 
const masterySchema = z.object({
  minimumSuccessfulProbes: z.number().int().min(1).max(10),
  successRatingAtLeast: z.number().int().min(3).max(5),
  requiredIndicators: z.array(idSchema).min(1).max(12),
  minimumDistinctQuestionKinds: z.number().int().min(1).max(5),
  recentWeakAnswersAllowed: z.number().int().min(0).max(3),
}).strict();
 
const conceptSchema = z.object({
  id: idSchema,
  title: boundedText(160),
  objective: boundedText(1_000),
  prerequisites: z.array(idSchema).max(8),
  difficulty: z.object({
    entry: difficultyRangeSchema,
    transfer: difficultyRangeSchema,
  }).strict(),
  misconceptions: z.array(misconceptionSchema).max(16),
  indicators: z.array(indicatorSchema).min(1).max(12),
  mastery: masterySchema,
}).strict();
 
const questionSchema = z.object({
  id: idSchema,
  concept: idSchema,
  indicator: idSchema,
  kind: questionKindSchema,
  difficulty: difficultyRangeSchema,
  requires: z.array(factRequirementSchema).min(1).max(8),
  text: boundedText(1_000).optional(),
  template: boundedText(1_000).optional(),
  slots: z.array(idSchema).max(8).optional(),
}).strict().superRefine((value, context) => {
  Iif ((value.text === undefined) === (value.template === undefined)) {
    context.addIssue({ code: z.ZodIssueCode.custom, message: "Exactly one of text or template is required" });
  }
  Iif (value.template === undefined && value.slots !== undefined) {
    context.addIssue({ code: z.ZodIssueCode.custom, message: "Slots require a template" });
  }
});
 
const scaffoldSchema = z.object({
  id: idSchema,
  forConcept: idSchema,
  strategy: strategySchema,
  level: z.number().int().min(1).max(3),
  hint: boundedText(700),
  nextQuestion: idSchema,
  targetConcept: idSchema.optional(),
}).strict();
 
const progressionSchema = z.object({
  entryConcepts: z.array(idSchema).min(1).max(8),
  preferredOrder: z.array(idSchema).min(1).max(32),
  onRating: z.object({
    "1-2": z.literal("remediate"),
    "3": z.literal("clarify-same-indicator"),
    "4": z.literal("probe-missing-indicator"),
    "5": z.literal("evaluate-mastery-and-advance"),
  }).strict(),
}).strict();
 
const deepeningSchema = z.object({
  minimumSuccessfulProbes: z.number().int().min(1).max(10),
  successRatingAtLeast: z.number().int().min(3).max(5),
  requiredQuestionKinds: z.array(z.enum(["application", "prediction", "transfer"])).min(1).max(3),
  recentWeakAnswersAllowed: z.number().int().min(0).max(3),
}).strict().superRefine((value, context) => {
  Iif (new Set(value.requiredQuestionKinds).size !== value.requiredQuestionKinds.length) {
    context.addIssue({ code: z.ZodIssueCode.custom, path: ["requiredQuestionKinds"], message: "Deepening question kinds must be unique" });
  }
});
 
const topicExtensionSchema = z.object({
  topic: idSchema,
  objective: boundedText(500),
}).strict();
 
const curriculumTopicV1Schema = z.object({
  schemaVersion: z.literal(1),
  id: idSchema,
  title: boundedText(160),
  locale: z.string().regex(/^[a-z]{2}-[A-Z]{2}$/),
  activation: activationSchema,
  concepts: z.array(conceptSchema).min(1).max(16),
  questions: z.array(questionSchema).min(1).max(64),
  scaffolds: z.array(scaffoldSchema).max(32),
  progression: progressionSchema,
}).strict();
 
const curriculumTopicV2Schema = z.object({
  schemaVersion: z.literal(2),
  id: idSchema,
  title: boundedText(160),
  locale: z.string().regex(/^[a-z]{2}-[A-Z]{2}$/),
  activation: activationSchema,
  concepts: z.array(conceptSchema).min(1).max(16),
  questions: z.array(questionSchema).min(1).max(64),
  scaffolds: z.array(scaffoldSchema).max(32),
  progression: progressionSchema,
  deepening: deepeningSchema.optional(),
  extensions: z.array(topicExtensionSchema).max(8).optional(),
}).strict();
 
export const curriculumTopicSchema = z.union([curriculumTopicV1Schema, curriculumTopicV2Schema]);
 
export const curriculumManifestSchema = z.object({
  schemaVersion: z.literal(1),
  curriculumId: idSchema,
  release: boundedText(64),
  locale: z.string().regex(/^[a-z]{2}-[A-Z]{2}$/),
  topics: z.array(z.object({
    id: idSchema,
    path: z.string().regex(/^topics\/[a-z][a-z0-9-]{0,63}\.yaml$/),
    sha256: z.string().regex(SAFE_SHA256),
  }).strict()).max(64),
}).strict();
 
export type FactRequirement = z.infer<typeof factRequirementSchema>;
export type CurriculumTopic = z.infer<typeof curriculumTopicSchema>;
export type CurriculumManifest = z.infer<typeof curriculumManifestSchema>;
export type CurriculumConcept = CurriculumTopic["concepts"][number];
export type CurriculumQuestion = CurriculumTopic["questions"][number];
export type CurriculumScaffold = CurriculumTopic["scaffolds"][number];
export type TopicDeepening = z.infer<typeof deepeningSchema>;
 
function assertUnique(values: readonly string[], label: string): void {
  if (new Set(values).size !== values.length) throw new Error(`Duplicate ${label}`);
}
 
function assertAcyclic(concepts: readonly CurriculumConcept[]): void {
  const byId = new Map(concepts.map((concept) => [concept.id, concept]));
  for (const concept of concepts) {
    for (const prerequisite of concept.prerequisites) {
      Iif (!byId.has(prerequisite)) throw new Error(`Unknown prerequisite: ${prerequisite}`);
    }
  }
 
  const visiting = new Set<string>();
  const visited = new Set<string>();
  const visit = (id: string): void => {
    if (visiting.has(id)) throw new Error(`Cyclic concept dependency at: ${id}`);
    if (visited.has(id)) return;
    visiting.add(id);
    for (const prerequisite of byId.get(id)?.prerequisites ?? []) visit(prerequisite);
    visiting.delete(id);
    visited.add(id);
  };
  for (const concept of concepts) visit(concept.id);
}
 
export function validateCurriculumTopic(topic: CurriculumTopic): CurriculumTopic {
  assertUnique(topic.concepts.map((concept) => concept.id), "concept id");
  assertUnique(topic.questions.map((question) => question.id), "question id");
  assertUnique(topic.scaffolds.map((scaffold) => scaffold.id), "scaffold id");
  assertAcyclic(topic.concepts);
 
  const concepts = new Map(topic.concepts.map((concept) => [concept.id, concept]));
  const questions = new Map(topic.questions.map((question) => [question.id, question]));
  const conceptIndicators = new Map(topic.concepts.map((concept) => [concept.id, new Set(concept.indicators.map((indicator) => indicator.id))]));
 
  validateQuestions(topic.questions, concepts, conceptIndicators);
  validateMastery(topic.concepts, conceptIndicators);
  validateScaffolds(topic.scaffolds, concepts, new Set(questions.keys()));
  validateProgression(topic.progression, concepts);
  return topic;
}
 
function validateQuestions(
  questions: readonly CurriculumQuestion[],
  concepts: ReadonlyMap<string, CurriculumConcept>,
  conceptIndicators: ReadonlyMap<string, ReadonlySet<string>>,
): void {
  for (const question of questions) {
    Iif (!concepts.has(question.concept)) throw new Error(`Question references unknown concept: ${question.id}`);
    Iif (!conceptIndicators.get(question.concept)?.has(question.indicator)) {
      throw new Error(`Question references unknown indicator: ${question.id}`);
    }
    Iif (question.template !== undefined) {
      for (const slot of question.slots ?? []) {
        if (!/^([a-z][a-z0-9-]{0,63})$/.test(slot)) throw new Error(`Invalid question slot: ${slot}`);
      }
    }
  }
 
}
 
function validateMastery(concepts: readonly CurriculumConcept[], conceptIndicators: ReadonlyMap<string, ReadonlySet<string>>): void {
  for (const concept of concepts) {
    for (const indicator of concept.mastery.requiredIndicators) {
      Iif (!conceptIndicators.get(concept.id)?.has(indicator)) {
        throw new Error(`Mastery references unknown indicator: ${concept.id}/${indicator}`);
      }
    }
  }
 
}
 
function validateScaffolds(
  scaffolds: readonly CurriculumScaffold[],
  concepts: ReadonlyMap<string, CurriculumConcept>,
  questionIds: ReadonlySet<string>,
): void {
  for (const scaffold of scaffolds) {
    Iif (!concepts.has(scaffold.forConcept)) throw new Error(`Scaffold references unknown concept: ${scaffold.id}`);
    Iif (!questionIds.has(scaffold.nextQuestion)) throw new Error(`Scaffold references unknown question: ${scaffold.id}`);
    Iif (scaffold.targetConcept !== undefined && !concepts.has(scaffold.targetConcept)) {
      throw new Error(`Scaffold references unknown target concept: ${scaffold.id}`);
    }
  }
 
}
 
function validateProgression(
  progression: CurriculumTopic["progression"],
  concepts: ReadonlyMap<string, CurriculumConcept>,
): void {
  for (const conceptId of [...progression.entryConcepts, ...progression.preferredOrder]) {
    Iif (!concepts.has(conceptId)) throw new Error(`Progression references unknown concept: ${conceptId}`);
  }
}
 
export function validateCurriculumManifest(manifest: CurriculumManifest): CurriculumManifest {
  assertUnique(manifest.topics.map((topic) => topic.id), "topic id");
  return manifest;
}