140 lines
5.2 KiB
JavaScript
140 lines
5.2 KiB
JavaScript
export function cloneState(obj) {
|
|
return JSON.parse(JSON.stringify(obj));
|
|
}
|
|
|
|
export function getRecord(state, conceptId, dimension = "mastery") {
|
|
return state.records.find((r) => r.concept_id === conceptId && r.dimension === dimension) || null;
|
|
}
|
|
|
|
export function applyEvidence(state, event, decay = 0.05, reinforcement = 0.25) {
|
|
const next = cloneState(state);
|
|
let rec = getRecord(next, event.concept_id, event.dimension);
|
|
if (!rec) {
|
|
rec = {
|
|
concept_id: event.concept_id,
|
|
dimension: event.dimension,
|
|
score: 0,
|
|
confidence: 0,
|
|
evidence_count: 0,
|
|
last_updated: event.timestamp
|
|
};
|
|
next.records.push(rec);
|
|
}
|
|
|
|
const weight = Math.max(0.05, Math.min(1.0, event.confidence_hint ?? 0.5));
|
|
rec.score = ((rec.score * rec.evidence_count) + (event.score * weight)) / Math.max(1, rec.evidence_count + 1);
|
|
rec.confidence = Math.min(
|
|
1.0,
|
|
Math.max(0.0, rec.confidence * (1.0 - decay) + reinforcement * weight + 0.10 * Math.max(0.0, Math.min(1.0, event.score)))
|
|
);
|
|
rec.evidence_count += 1;
|
|
rec.last_updated = event.timestamp;
|
|
next.history.push(event);
|
|
return next;
|
|
}
|
|
|
|
export function prereqsSatisfied(state, concept, minScore = 0.65, minConfidence = 0.45) {
|
|
return (concept.prerequisites || []).every((pid) => {
|
|
const rec = getRecord(state, pid, concept.masteryDimension || "mastery");
|
|
return rec && rec.score >= minScore && rec.confidence >= minConfidence;
|
|
});
|
|
}
|
|
|
|
export function conceptStatus(state, concept, minScore = 0.65, minConfidence = 0.45) {
|
|
const rec = getRecord(state, concept.id, concept.masteryDimension || "mastery");
|
|
if (rec && rec.score >= minScore && rec.confidence >= minConfidence) return "mastered";
|
|
if (prereqsSatisfied(state, concept, minScore, minConfidence)) return rec ? "active" : "available";
|
|
return "locked";
|
|
}
|
|
|
|
export function buildMasteryMap(state, domain) {
|
|
return domain.concepts.map((c) => ({
|
|
id: c.id,
|
|
label: c.title,
|
|
status: conceptStatus(state, c)
|
|
}));
|
|
}
|
|
|
|
export function progressPercent(state, domain) {
|
|
const total = Math.max(1, domain.concepts.length);
|
|
const mastered = domain.concepts.filter((c) => conceptStatus(state, c) === "mastered").length;
|
|
return Math.round((mastered / total) * 100);
|
|
}
|
|
|
|
export function recommendNext(state, domain) {
|
|
const cards = [];
|
|
for (const concept of domain.concepts) {
|
|
const status = conceptStatus(state, concept);
|
|
const rec = getRecord(state, concept.id, concept.masteryDimension || "mastery");
|
|
if (status === "available" || status === "active") {
|
|
cards.push({
|
|
id: concept.id,
|
|
title: `Work on ${concept.title}`,
|
|
minutes: status === "available" ? 15 : 10,
|
|
reason: status === "available"
|
|
? "Prerequisites are satisfied, so this is the best next unlock."
|
|
: "You have started this concept, but mastery is not yet secure.",
|
|
why: [
|
|
"Prerequisite check passed",
|
|
rec ? `Current score: ${rec.score.toFixed(2)}` : "No evidence recorded yet",
|
|
rec ? `Current confidence: ${rec.confidence.toFixed(2)}` : "Confidence starts after your first exercise"
|
|
],
|
|
reward: concept.exerciseReward || `${concept.title} progress recorded`,
|
|
conceptId: concept.id,
|
|
scoreHint: status === "available" ? 0.82 : 0.76,
|
|
confidenceHint: status === "available" ? 0.72 : 0.55
|
|
});
|
|
}
|
|
}
|
|
for (const rec of state.records) {
|
|
if (rec.dimension === "mastery" && rec.confidence < 0.40) {
|
|
const concept = domain.concepts.find((c) => c.id === rec.concept_id);
|
|
if (concept) {
|
|
cards.push({
|
|
id: `${concept.id}-reinforce`,
|
|
title: `Reinforce ${concept.title}`,
|
|
minutes: 8,
|
|
reason: "Your score is promising, but confidence is still thin.",
|
|
why: [
|
|
`Confidence ${rec.confidence.toFixed(2)} is below reinforcement threshold`,
|
|
"A small fresh exercise can stabilize recall"
|
|
],
|
|
reward: "Confidence ring grows",
|
|
conceptId: concept.id,
|
|
scoreHint: Math.max(0.60, rec.score),
|
|
confidenceHint: 0.30
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return cards.slice(0, 4);
|
|
}
|
|
|
|
export function milestoneMessages(state, domain) {
|
|
const msgs = [];
|
|
for (const concept of domain.concepts) {
|
|
const status = conceptStatus(state, concept);
|
|
if (status === "mastered") msgs.push(`${concept.title} mastered`);
|
|
}
|
|
if (msgs.length === 0) msgs.push("Complete your first guided exercise to earn a visible mastery marker");
|
|
return msgs;
|
|
}
|
|
|
|
export function claimReadiness(state, domain, minScore = 0.75, minConfidence = 0.60) {
|
|
const mastered = domain.concepts.filter((c) => {
|
|
const rec = getRecord(state, c.id, c.masteryDimension || "mastery");
|
|
return rec && rec.score >= minScore && rec.confidence >= minConfidence;
|
|
}).length;
|
|
|
|
const records = domain.concepts.map((c) => getRecord(state, c.id, c.masteryDimension || "mastery")).filter(Boolean);
|
|
const avgScore = records.length ? records.reduce((a, r) => a + r.score, 0) / records.length : 0;
|
|
const avgConfidence = records.length ? records.reduce((a, r) => a + r.confidence, 0) / records.length : 0;
|
|
|
|
return {
|
|
ready: mastered >= Math.max(1, domain.concepts.length - 1) && avgScore >= minScore && avgConfidence >= minConfidence,
|
|
mastered,
|
|
avgScore,
|
|
avgConfidence
|
|
};
|
|
}
|