Files
rootnode-academy/platform/api/src/curriculum-rules.js
T
ced4568 01a862e007 feat(learning): add scenario attempt telemetry and curriculum completion rules
Evolves scenario_attempts (Phase 2B: one row per validation click) into a real attempt
lifecycle — one row per continuous working session, with live counters (validation
attempts/passes/fails, hint reveals, duration) updated in place, not re-inserted. Backward
compatible: every new column is nullable/defaulted, existing rows backfilled under the new
semantics rather than dropped. hint_count (Phase 2B, always NULL — 'not measurable') finally
gets a real DEFAULT 0 and increments, since this is the phase that builds real hint tracking.

curriculum-rules.js computes rule-driven stage lock/complete/percent/next-scenario state from
scenarios/rootnode-path.json's new per-stage completion_rule (all_required/threshold/
prerequisites) against a student's progress. The capstone stage is no longer a hardcoded
locked placeholder — it carries an unlock_rule and a pointer at the assessment definition that
actually defines its scenario draw (see the assessment-engine commit).
2026-08-18 14:35:11 -05:00

84 lines
4.0 KiB
JavaScript

// Curriculum progression rules — reads scenarios/rootnode-path.json's per-stage
// completion_rule (and the capstone stage's unlock_rule) against a student's progress map to
// answer: current stage, completed/locked stages, next required scenario, percent complete,
// and what's blocking advancement. Rule-driven, not adaptive — see
// docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §4 for why that's a deliberate scope line.
//
// Capstone completeness itself isn't computed here (a capstone stage has no scenario_ids to
// check against progress) — the caller supplies it after checking for a 'capstone_completed'
// achievement, since that's Postgres-backed state this module has no access to.
function isStageComplete(stage, progress) {
const ids = stage.scenario_ids || [];
if (!ids.length) return false;
const rule = stage.completion_rule || { type: 'all_required' };
const completedIds = ids.filter((id) => progress[id]?.status === 'completed');
switch (rule.type) {
case 'threshold':
return completedIds.length / ids.length >= (rule.percent || 100) / 100;
case 'prerequisites': {
const required = rule.scenario_ids && rule.scenario_ids.length ? rule.scenario_ids : ids;
return required.every((id) => progress[id]?.status === 'completed');
}
case 'all_required':
default:
return completedIds.length === ids.length;
}
}
function nextIncompleteScenario(stage, progress) {
return (stage.scenario_ids || []).find((id) => progress[id]?.status !== 'completed') || null;
}
// capstoneStatus: 'locked' | 'unlocked' | 'completed' — supplied by the caller (server route),
// since it depends on Postgres state (achievements) this module can't see.
function computeCurriculumState(path, progress, capstoneStatus) {
const contentStages = path.stages.filter((s) => s.type !== 'capstone');
const capstoneStage = path.stages.find((s) => s.type === 'capstone');
let priorComplete = true; // the first content stage is always unlocked
const stageCompletion = {}; // stage id -> bool, used by the capstone's stages_complete rule
const stages = contentStages.map((stage) => {
const complete = isStageComplete(stage, progress);
stageCompletion[stage.id] = complete;
const locked = !priorComplete;
if (!complete) priorComplete = false; // next stage stays locked until this one is done
const ids = stage.scenario_ids || [];
const completedCount = ids.filter((id) => progress[id]?.status === 'completed').length;
return {
id: stage.id, name: stage.name, icon: stage.icon, locked, complete,
total: ids.length, completed: completedCount,
pct: ids.length ? Math.round((completedCount / ids.length) * 100) : 0,
next_scenario: locked || complete ? null : nextIncompleteScenario(stage, progress),
};
});
const currentStage = stages.find((s) => !s.locked && !s.complete) || null;
const overallTotal = contentStages.reduce((n, s) => n + (s.scenario_ids || []).length, 0);
const overallCompleted = contentStages.reduce(
(n, s) => n + (s.scenario_ids || []).filter((id) => progress[id]?.status === 'completed').length, 0
);
let capstone = null;
if (capstoneStage) {
const rule = capstoneStage.unlock_rule || { type: 'stages_complete', stage_ids: contentStages.map((s) => s.id) };
const unlockedByRule = rule.type === 'stages_complete' && rule.stage_ids.every((id) => stageCompletion[id]);
const blocking = rule.type === 'stages_complete' ? rule.stage_ids.filter((id) => !stageCompletion[id]) : [];
capstone = {
id: capstoneStage.id, name: capstoneStage.name, icon: capstoneStage.icon,
assessment_definition_id: capstoneStage.capstone_assessment_id,
status: capstoneStatus || (unlockedByRule ? 'unlocked' : 'locked'),
blocking_stage_ids: blocking,
};
}
return {
stages, current_stage: currentStage?.id || null,
percent_complete: overallTotal ? Math.round((overallCompleted / overallTotal) * 100) : 0,
capstone,
};
}
module.exports = { computeCurriculumState, isStageComplete };