All files / server/services/course-content tutor-quality-validator.ts

98.36% Statements 120/122
93.1% Branches 54/58
100% Functions 33/33
97.97% Lines 97/99

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 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371                                                                                                                                10x 10x 10x 10x 18x 18x     9x     10x   10x 10x 10x               10x 18x 18x 18x                 18x 18x 1x                     18x 10x 1x                     18x 8x                     18x 18x 10x 1x   10x 2x     10x 10x 9x 8x 8x                 32x 10x 42x 14x 4x   14x 14x 4x                     26x 26x 12x 36x 10x 10x   8x 8x 19x 1x                       10x 2x 1x                       10x 1x   26x 1x   10x 10x                     8x 8x 8x 2x 2x     6x             6x 6x 16x 11x     5x   16x 10x 10x               11x                         18x 18x 18x 39x       29x       39x 26x 26x 21x   26x                                         9x 9x 9x 9x 14x 14x 10x 3x               9x                     31x                                         20x                  
import type { TutorDialogTurn } from "@shared/tutor";
import type { CurriculumConcept, CurriculumQuestion, CurriculumTopic } from "../tutor/curriculum/curriculum-schema";
import {
  classifyTopic,
  collectObservations,
  DefaultLearningPlanner,
  questionApplies,
} from "../tutor/curriculum/learning-planner";
import { deepeningCriteria, hasMetDeepeningCriteria } from "../tutor/curriculum/progression-state";
import { DefaultSketchFactExtractor, type SketchFacts } from "../tutor/curriculum/sketch-facts";
import { DefaultTopicMatcher } from "../tutor/curriculum/topic-matcher";
import { BUILT_IN_TUTOR_STRATEGY, type EffectiveTutorStrategy } from "../tutor/strategy/effective-tutor-strategy";
 
export interface ResolvedTutorQualityCase {
  readonly id: string;
  readonly exampleId: string;
  readonly code: string;
  readonly expectedTopics: readonly string[];
  readonly forbiddenTopics: readonly string[];
  readonly learnStrategy?: EffectiveTutorStrategy;
  readonly deepenStrategy?: EffectiveTutorStrategy;
}
 
export type TutorContentQualityIssueCode =
  | "expected-topic-not-activated"
  | "forbidden-topic-activated"
  | "missing-positive-activation-case"
  | "missing-negative-activation-case"
  | "unknown-topic-reference"
  | "unreachable-concept"
  | "unreachable-indicator"
  | "unreachable-prerequisite"
  | "insufficient-mastery-probes"
  | "insufficient-mastery-question-kinds"
  | "missing-mastery-indicator-question"
  | "missing-deepening-question-kind"
  | "learn-content-exhausted"
  | "deepen-content-exhausted"
  | "unmasterable-example-activation"
  | "inline-focus-term-not-in-sketch"
  | "invalid-course-content-bundle"
  | "invalid-quality-cases"
  | "quality-case-example-not-found";
 
export interface TutorContentQualityIssue {
  readonly code: TutorContentQualityIssueCode;
  readonly message: string;
  readonly caseId?: string;
  readonly exampleId?: string;
  readonly topicId?: string;
  readonly conceptId?: string;
  readonly indicatorId?: string;
}
 
type CaseContext = {
  readonly qualityCase: ResolvedTutorQualityCase;
  readonly facts: SketchFacts;
  readonly activatedTopicIds: ReadonlySet<string>;
};
 
export function validateTutorContentQuality(
  topics: readonly CurriculumTopic[],
  qualityCases: readonly ResolvedTutorQualityCase[],
): TutorContentQualityIssue[] {
  const extractor = new DefaultSketchFactExtractor();
  const matcher = new DefaultTopicMatcher();
  const topicIds = new Set(topics.map(({ id }) => id));
  const contexts = qualityCases.map((qualityCase): CaseContext => {
    const facts = extractor.extract(qualityCase.code);
    return {
      qualityCase,
      facts,
      activatedTopicIds: new Set(matcher.match(topics, facts).map(({ topic }) => topic.id)),
    };
  });
  const issues: TutorContentQualityIssue[] = [];
 
  validateActivationReferences(contexts, topicIds, issues);
  for (const topic of topics) validateTopic(topic, contexts, issues);
  return issues;
}
 
function validateActivationReferences(
  contexts: readonly CaseContext[],
  topicIds: ReadonlySet<string>,
  issues: TutorContentQualityIssue[],
): void {
  for (const { qualityCase, activatedTopicIds } of contexts) {
    validateUnknownTopicReferences(qualityCase, topicIds, issues);
    validateExpectedTopicActivation(qualityCase, activatedTopicIds, topicIds, issues);
    validateForbiddenTopicActivation(qualityCase, activatedTopicIds, topicIds, issues);
  }
}
 
function validateUnknownTopicReferences(
  qualityCase: ResolvedTutorQualityCase,
  topicIds: ReadonlySet<string>,
  issues: TutorContentQualityIssue[],
): void {
  for (const topicId of [...qualityCase.expectedTopics, ...qualityCase.forbiddenTopics]) {
    if (!topicIds.has(topicId)) {
      issues.push(issue("unknown-topic-reference", `Quality case references unknown Topic ${topicId}`, qualityCase.id, topicId));
    }
  }
}
 
function validateExpectedTopicActivation(
  qualityCase: ResolvedTutorQualityCase,
  activatedTopicIds: ReadonlySet<string>,
  topicIds: ReadonlySet<string>,
  issues: TutorContentQualityIssue[],
): void {
  for (const topicId of qualityCase.expectedTopics) {
    if (topicIds.has(topicId) && !activatedTopicIds.has(topicId)) {
      issues.push(issue("expected-topic-not-activated", `Expected Topic ${topicId} is not activated`, qualityCase.id, topicId));
    }
  }
}
 
function validateForbiddenTopicActivation(
  qualityCase: ResolvedTutorQualityCase,
  activatedTopicIds: ReadonlySet<string>,
  topicIds: ReadonlySet<string>,
  issues: TutorContentQualityIssue[],
): void {
  for (const topicId of qualityCase.forbiddenTopics) {
    Iif (topicIds.has(topicId) && activatedTopicIds.has(topicId)) {
      issues.push(issue("forbidden-topic-activated", `Forbidden Topic ${topicId} is activated`, qualityCase.id, topicId));
    }
  }
}
 
function validateTopic(
  topic: CurriculumTopic,
  contexts: readonly CaseContext[],
  issues: TutorContentQualityIssue[],
): void {
  const positive = contexts.filter(({ qualityCase }) => qualityCase.expectedTopics.includes(topic.id));
  const negative = contexts.filter(({ qualityCase }) => qualityCase.forbiddenTopics.includes(topic.id));
  if (positive.length === 0) {
    issues.push(issue("missing-positive-activation-case", `Topic ${topic.id} has no positive activation case`, undefined, topic.id));
  }
  if (negative.length === 0) {
    issues.push(issue("missing-negative-activation-case", `Topic ${topic.id} has no negative activation case`, undefined, topic.id));
  }
 
  validateGlobalReachability(topic, positive, issues);
  for (const context of positive) {
    if (!context.activatedTopicIds.has(topic.id)) continue;
    validateCaseStructure(topic, context, issues);
    validateExecutablePath(topic, context, issues);
  }
}
 
function validateGlobalReachability(
  topic: CurriculumTopic,
  contexts: readonly CaseContext[],
  issues: TutorContentQualityIssue[],
): void {
  const applicable = topic.questions.filter((question) => contexts.some(({ facts }) => questionApplies(question, facts)));
  for (const concept of topic.concepts) {
    const conceptQuestions = applicable.filter((question) => question.concept === concept.id);
    if (conceptQuestions.length === 0) {
      issues.push(issue("unreachable-concept", `Concept ${concept.id} has no applicable question in any positive case`, undefined, topic.id, concept.id));
    }
    for (const indicator of concept.indicators) {
      if (!conceptQuestions.some((question) => question.indicator === indicator.id)) {
        issues.push(issue("unreachable-indicator", `Indicator ${indicator.id} has no applicable question in any positive case`, undefined, topic.id, concept.id, indicator.id));
      }
    }
  }
}
 
function validateCaseStructure(
  topic: CurriculumTopic,
  context: CaseContext,
  issues: TutorContentQualityIssue[],
): void {
  const applicable = topic.questions.filter((question) => questionApplies(question, context.facts));
  const domain = new Set(applicable.map(({ concept }) => concept));
  for (const concept of topic.concepts.filter(({ id }) => domain.has(id))) {
    const questions = applicable.filter((question) => question.concept === concept.id);
    validatePrerequisites(topic, concept, domain, context, issues);
    validateMasteryCapacity(topic, concept, questions, context, issues);
  }
  const criteria = deepeningCriteria(topic);
  for (const kind of criteria.requiredQuestionKinds) {
    if (!applicable.some((question) => question.kind === kind)) {
      issues.push(issue("missing-deepening-question-kind", `DEEPEN requires unavailable question kind ${kind}`, context.qualityCase.id, topic.id));
    }
  }
}
 
function validatePrerequisites(
  topic: CurriculumTopic,
  concept: CurriculumConcept,
  domain: ReadonlySet<string>,
  context: CaseContext,
  issues: TutorContentQualityIssue[],
): void {
  for (const prerequisite of concept.prerequisites) {
    if (!domain.has(prerequisite)) {
      issues.push(issue("unreachable-prerequisite", `Concept ${concept.id} requires unprobeable Concept ${prerequisite}`, context.qualityCase.id, topic.id, concept.id));
    }
  }
}
 
function validateMasteryCapacity(
  topic: CurriculumTopic,
  concept: CurriculumConcept,
  questions: readonly CurriculumQuestion[],
  context: CaseContext,
  issues: TutorContentQualityIssue[],
): void {
  if (questions.length < concept.mastery.minimumSuccessfulProbes) {
    issues.push(issue("insufficient-mastery-probes", `Concept ${concept.id} has too few distinct applicable mastery probes`, context.qualityCase.id, topic.id, concept.id));
  }
  if (new Set(questions.map(({ kind }) => kind)).size < concept.mastery.minimumDistinctQuestionKinds) {
    issues.push(issue("insufficient-mastery-question-kinds", `Concept ${concept.id} has too few applicable question kinds`, context.qualityCase.id, topic.id, concept.id));
  }
  for (const indicator of concept.mastery.requiredIndicators) {
    Iif (!questions.some((question) => question.indicator === indicator)) {
      issues.push(issue("missing-mastery-indicator-question", `Mastery Indicator ${indicator} has no applicable question`, context.qualityCase.id, topic.id, concept.id, indicator));
    }
  }
}
 
function validateExecutablePath(
  topic: CurriculumTopic,
  context: CaseContext,
  issues: TutorContentQualityIssue[],
): void {
  const planner = new DefaultLearningPlanner();
  const { history, masteryReached } = strictLearnPath(topic, context.facts, context.qualityCase.learnStrategy ?? BUILT_IN_TUTOR_STRATEGY);
  if (!masteryReached) {
    issues.push(issue("learn-content-exhausted", "Default strict progression cannot reach Topic mastery", context.qualityCase.id, topic.id));
    return;
  }
 
  const postMastery = [] as Array<{
    questionId: string;
    conceptId: string;
    indicatorId: string;
    kind: CurriculumQuestion["kind"];
    rating: 5;
  }>;
  const criteria = deepeningCriteria(topic);
  for (let step = 0; step <= topic.questions.length; step += 1) {
    if (hasMetDeepeningCriteria(topic, postMastery)) return;
    const plan = planner.start(topic, "0".repeat(40), context.facts, history, 30, context.qualityCase.deepenStrategy ?? BUILT_IN_TUTOR_STRATEGY, {
      phase: "DEEPEN",
      preferredQuestionKinds: criteria.requiredQuestionKinds,
      existingQuestionKinds: postMastery.map(({ kind }) => kind),
    });
    if (!plan) break;
    history.push(successfulTurn(plan.brief));
    postMastery.push({
      questionId: plan.brief.questionId,
      conceptId: plan.brief.conceptId,
      indicatorId: plan.brief.indicatorId,
      kind: plan.brief.questionKind,
      rating: 5,
    });
  }
  issues.push(issue("deepen-content-exhausted", "Default strict progression cannot satisfy DEEPEN", context.qualityCase.id, topic.id));
}
 
/**
 * The deterministic LEARN path of a learner who answers every planned question successfully,
 * using the production planner and Topic classification. It needs no provider: if even this
 * path cannot reach Topic mastery, no learner can.
 */
function strictLearnPath(
  topic: CurriculumTopic,
  facts: SketchFacts,
  strategy: EffectiveTutorStrategy,
): { readonly history: TutorDialogTurn[]; readonly masteryReached: boolean } {
  const planner = new DefaultLearningPlanner();
  const history: TutorDialogTurn[] = [];
  for (let step = 0; step <= topic.questions.length; step += 1) {
    const classification = classifyTopic(
      topic,
      facts,
      collectObservations(topic, history),
      new Set(history.flatMap(({ questionId }) => questionId ? [questionId] : [])),
      30,
      strategy,
    );
    if (classification.status === "mastered") return { history, masteryReached: true };
    const plan = planner.start(topic, "0".repeat(40), facts, history, 30, strategy);
    if (!plan) break;
    history.push(successfulTurn(plan.brief));
  }
  return { history, masteryReached: false };
}
 
export interface CatalogExample {
  readonly id: string;
  readonly code: string;
  readonly learnStrategy?: EffectiveTutorStrategy;
}
 
/**
 * Catalog-wide authoring invariant, independent of quality cases: every Topic that the
 * production matcher activates on an Example must be masterable on that Example's sketch by a
 * learner who answers every planned question successfully. This finds Topics that are
 * unresolved from the first turn (LearningQuestions SSOT 2.3), for example through a
 * prerequisite Concept that the sketch cannot probe. Later exhaustion after weak answers and
 * post-mastery (DEEPEN) capacity stay out of scope here.
 */
export function validateExampleTopicActivations(
  topics: readonly CurriculumTopic[],
  examples: readonly CatalogExample[],
): TutorContentQualityIssue[] {
  const extractor = new DefaultSketchFactExtractor();
  const matcher = new DefaultTopicMatcher();
  const issues: TutorContentQualityIssue[] = [];
  for (const example of examples) {
    const facts = extractor.extract(example.code);
    for (const { topic } of matcher.match(topics, facts)) {
      if (strictLearnPath(topic, facts, example.learnStrategy ?? BUILT_IN_TUTOR_STRATEGY).masteryReached) continue;
      issues.push({
        code: "unmasterable-example-activation",
        message: `Example ${example.id} activates Topic ${topic.id}, but a learner who answers every planned question successfully cannot reach its mastery`,
        exampleId: example.id,
        topicId: topic.id,
      });
    }
  }
  return issues;
}
 
function successfulTurn(brief: {
  readonly question: string;
  readonly questionId: string;
  readonly topicId: string;
  readonly conceptId: string;
  readonly indicatorId: string;
  readonly questionKind: CurriculumQuestion["kind"];
}): TutorDialogTurn {
  return {
    question: brief.question,
    questionId: brief.questionId,
    topicId: brief.topicId,
    conceptId: brief.conceptId,
    indicatorId: brief.indicatorId,
    questionKind: brief.questionKind,
    answer: "Deterministic successful quality probe",
    answerRating: 5,
    responseStyle: "normal",
  };
}
 
function issue(
  code: TutorContentQualityIssueCode,
  message: string,
  caseId?: string,
  topicId?: string,
  conceptId?: string,
  indicatorId?: string,
): TutorContentQualityIssue {
  return {
    code,
    message,
    ...(caseId ? { caseId } : {}),
    ...(topicId ? { topicId } : {}),
    ...(conceptId ? { conceptId } : {}),
    ...(indicatorId ? { indicatorId } : {}),
  };
}