All files / server/services/tutor/curriculum progression-state.ts

100% Statements 45/45
88.88% Branches 16/18
100% Functions 16/16
100% Lines 40/40

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      31x   31x                             31x               6738x                     352x             64x           187x 187x 187x 187x 187x 187x 187x 187x 53x   187x       1078x 982x         3x 3x 3x 3x 3x 3x 3x 3x 3x                 138x 138x 138x       54x       35x 35x 35x       136x       45x 53x 45x 45x 45x        
import type { CurriculumTopic, TopicDeepening } from "./curriculum-schema";
import type { Observation } from "./learning-planner";
 
export const DIDACTIC_PHASES = ["LEARN", "DEEPEN", "EXPAND"] as const;
export type DidacticPhase = typeof DIDACTIC_PHASES[number];
export const PROGRESSION_BLOCKED_REASONS = ["content-exhausted"] as const;
export type ProgressionBlockedReason = typeof PROGRESSION_BLOCKED_REASONS[number];
 
export interface TutorProgressionState {
  revision: string;
  activeTopicId?: string;
  phase?: DidacticPhase;
  masteredTopicIds: string[];
  masteryEvidence: Record<string, Observation[]>;
  postMasteryEvidence: Record<string, Observation[]>;
  retainedPhases: Record<string, "DEEPEN" | "EXPAND">;
  usedExpansionTargetTopicIds: Record<string, string[]>;
  progressionBlockedReason?: ProgressionBlockedReason;
}
 
export const DEFAULT_DEEPENING: TopicDeepening = {
  minimumSuccessfulProbes: 2,
  successRatingAtLeast: 4,
  requiredQuestionKinds: ["transfer"],
  recentWeakAnswersAllowed: 0,
};
 
export function createTutorProgressionState(revision: string): TutorProgressionState {
  return {
    revision,
    masteredTopicIds: [],
    masteryEvidence: {},
    postMasteryEvidence: {},
    retainedPhases: {},
    usedExpansionTargetTopicIds: {},
  };
}
 
export function cloneTutorProgressionState(state: TutorProgressionState): TutorProgressionState {
  return {
    ...state,
    masteredTopicIds: [...state.masteredTopicIds],
    masteryEvidence: cloneEvidence(state.masteryEvidence),
    postMasteryEvidence: cloneEvidence(state.postMasteryEvidence),
    retainedPhases: { ...state.retainedPhases },
    usedExpansionTargetTopicIds: Object.fromEntries(
      Object.entries(state.usedExpansionTargetTopicIds).map(([topicId, targetIds]) => [topicId, [...targetIds]]),
    ),
  };
}
 
export function commitTutorProgressionState(target: TutorProgressionState, source: TutorProgressionState): void {
  target.revision = source.revision;
  target.activeTopicId = source.activeTopicId;
  target.phase = source.phase;
  target.masteredTopicIds = [...source.masteredTopicIds];
  target.masteryEvidence = cloneEvidence(source.masteryEvidence);
  target.postMasteryEvidence = cloneEvidence(source.postMasteryEvidence);
  target.retainedPhases = { ...source.retainedPhases };
  target.usedExpansionTargetTopicIds = Object.fromEntries(
    Object.entries(source.usedExpansionTargetTopicIds).map(([topicId, targetIds]) => [topicId, [...targetIds]]),
  );
  target.progressionBlockedReason = source.progressionBlockedReason;
}
 
function cloneEvidence(evidence: Record<string, Observation[]>): Record<string, Observation[]> {
  return Object.fromEntries(
    Object.entries(evidence).map(([topicId, observations]) => [topicId, observations.map((observation) => ({ ...observation }))]),
  );
}
 
export function resetTutorProgressionState(state: TutorProgressionState, revision = state.revision): void {
  state.activeTopicId = undefined;
  state.phase = undefined;
  state.masteredTopicIds.splice(0, state.masteredTopicIds.length);
  state.masteryEvidence = {};
  state.postMasteryEvidence = {};
  state.retainedPhases = {};
  state.usedExpansionTargetTopicIds = {};
  state.progressionBlockedReason = undefined;
  state.revision = revision;
}
 
export function appendEvidence(
  state: TutorProgressionState,
  collection: "masteryEvidence" | "postMasteryEvidence",
  topicId: string,
  observation: Observation,
): void {
  const entries = state[collection][topicId] ?? [];
  entries.push(observation);
  state[collection][topicId] = entries;
}
 
export function markTopicMastered(state: TutorProgressionState, topicId: string): void {
  Eif (!state.masteredTopicIds.includes(topicId)) state.masteredTopicIds.push(topicId);
}
 
export function markExpansionTargetUsed(state: TutorProgressionState, sourceTopicId: string, targetTopicId: string): void {
  const targets = state.usedExpansionTargetTopicIds[sourceTopicId] ?? [];
  Eif (!targets.includes(targetTopicId)) targets.push(targetTopicId);
  state.usedExpansionTargetTopicIds[sourceTopicId] = targets;
}
 
export function deepeningCriteria(topic: CurriculumTopic): TopicDeepening {
  return topic.schemaVersion === 2 && topic.deepening ? topic.deepening : DEFAULT_DEEPENING;
}
 
export function hasMetDeepeningCriteria(topic: CurriculumTopic, observations: readonly Observation[]): boolean {
  const criteria = deepeningCriteria(topic);
  const successful = observations.filter(({ rating }) => rating >= criteria.successRatingAtLeast);
  const weakTrailing = [...observations].reverse().findIndex(({ rating }) => rating > 2);
  const recentWeakCount = weakTrailing < 0 ? observations.length : weakTrailing;
  return successful.length >= criteria.minimumSuccessfulProbes
    && criteria.requiredQuestionKinds.every((kind) => successful.some((observation) => observation.kind === kind))
    && recentWeakCount <= criteria.recentWeakAnswersAllowed;
}