Feature detail
Concept tree + question / generator primitives
Questions, concepts, and seeded generators stay minimal so consumer repos can shape content and rendering without fighting a heavyweight framework — with room for a concept-first rubric (disclosure, scaffold-then-fade) above the core types.
Worked example
Minimal content primitives
A consumer repo can keep question, concept, and generator contracts small while still driving prerequisite logic and deterministic variants.
Input
{
"questionType": "trace",
"conceptNode": {
"id": "loops",
"requires": [
"variables"
]
}
}Output
{
"generatedVariantSeed": 42,
"unlockedAfterPrereqs": [
"loops",
"variables"
]
}Real source excerpt
Concept tree primitives
export interface ConceptNode {
/** Stable unique identifier. */
id: string;
/** Display name. */
name: string;
/** One-line human description. */
description: string;
/** ConceptNode.id values that must be mastered before this unlocks. */
prerequisites: string[];
/** Populated by populateConceptQuestions from the question pool. */
questionIds: string[];
/** Optional chapter/unit grouping. */
chapter?: number;
/** Optional x% layout position (0-100) for skill-tree rendering. */
x?: number;
/** Optional y% layout position (0-100) for skill-tree rendering. */
y?: number;
}
export type ConceptTree = ConceptNode[];
/** Default mastery threshold (70%) used by cs1301-review-game. */
export const MASTERY_THRESHOLD = 0.7;
/**
* Fraction-correct across all attempts on a concept's questions.
* Returns 0 when there are no attempts yet.
*/
export function getConceptMastery(
_conceptId: string,
questionIds: string[],
masteryData: MasteryData
): number {
if (questionIds.length === 0) return 0;
let totalCorrect = 0;
let totalAttempts = 0;
for (const qId of questionIds) {
const data = masteryData[qId];
if (data) {
totalCorrect += data.correct;
totalAttempts += data.total;
}
}
if (totalAttempts === 0) return 0;
return totalCorrect / totalAttempts;
}
/**
* A concept unlocks once every prerequisite is at or above the threshold.
* Root concepts (no prerequisites) are always unlocked.
*/
export function isConceptUnlocked(
conceptId: string,
tree: ConceptTree,
masteryData: MasteryData,
threshold: number = MASTERY_THRESHOLD
): boolean {
const concept = tree.find(c => c.id === conceptId);
if (!concept) return false;
if (concept.prerequisites.length === 0) return true;
for (const prereqId of concept.prerequisites) {
const prereq = tree.find(c => c.id === prereqId);
if (!prereq) continue;
const mastery = getConceptMastery(prereqId, prereq.questionIds, masteryData);
if (mastery < threshold) return false;
}
return true;
}
/** Flatten all question ids from every currently-unlocked concept. */
export function getUnlockedQuestionIds(
tree: ConceptTree,
masteryData: MasteryData,
threshold: number = MASTERY_THRESHOLD
): string[] {
const unlocked: string[] = [];
for (const concept of tree) {
if (isConceptUnlocked(concept.id, tree, masteryData, threshold)) {
unlocked.push(...concept.questionIds);
}
}
return unlocked;
}
/** Ids of concepts at or above the threshold. */
export function getMasteredConcepts(
tree: ConceptTree,
masteryData: MasteryData,
threshold: number = MASTERY_THRESHOLD
): string[] {
return tree
.filter(c => getConceptMastery(c.id, c.questionIds, masteryData) >= threshold)
.map(c => c.id);
}
/** Concepts that are unlocked but not yet mastered — what to study next. */