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 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 | 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 398x 398x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 367x 367x 367x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 43x 103x 92x 372x 372x 92x 103x 103x 3x 3x 2x 2x 2x 2x 2x 2x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 291x 291x 147x 147x 147x 147x 291x 142x 142x 291x 133x 133x 133x 22x 22x 22x 2x 2x 2x 2x 2x 2x 2x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 170x 170x 170x 170x 170x 170x 170x 170x 170x 170x 5x 5x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 13x 13x 13x 13x 13x 13x 12x 67x 67x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 2x 2x 2x 2x 2x 2x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 37x 37x 13x 13x 13x 13x 13x 70x 70x 70x 70x 13x 13x 12x 67x 67x 67x 12x 13x 13x 99x 99x 99x 40x 3x 3x 99x 13x 13x 13x 2x 2x 2x 2x 2x 2x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 2x 2x 2x 2x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 5x 5x 5x 5x 15x 15x 15x 15x 4x 15x 5x 5x | /**
* Linear-Readiness Derivation (L3)
* =================================
*
* "Linear" practice parts present mastered skills as horizontal number sentences
* ("45 + 27 = ?") instead of abacus/visualization work. A skill becomes
* *linear-ready* — is "aged out" onto pure number sentences — when the child has
* demonstrably mastered it AND their learning frontier has moved a stage past it.
*
* This is DERIVED-WITH-VETO, computed fresh at plan time and NEVER persisted:
* - readiness is a pure function of practice evidence + the frontier (this module)
* - the ONLY persisted state is a per-category teacher veto (`linear_readiness_veto`)
*
* It is intentionally decoupled from the manual `none → abacus → visual` ladder
* (`PracticeLevel`), which teachers still control by hand. Linear used to piggyback
* on the `visual` gate; L3 gives it its own gate driven by this derivation.
*
* ── The frontier ──────────────────────────────────────────────────────────────
* Curriculum stages, in forward teaching order (advanced is split by operation, so
* cascading-carry sits with addition and cascading-borrow with subtraction):
*
* 0 basic 1 fiveComplements 2 tenComplements 3 cascadingCarry
* 4 fiveComplementsSub 5 tenComplementsSub 6 cascadingBorrow
*
* The frontier is the contiguous fully-mastered *prefix* of stages. A stage counts
* as mastered only when EVERY non-cascading skill in it has REAL EVIDENCE
* (opportunities > 0) and advances the frontier under the entry policy (mastery +
* volume by default, see `linear-entry-policy.ts`) — a never-practiced skill does NOT count as
* "mastered by default" for the frontier, so a child mid-category cannot vault a
* whole stage. Cascading skills (which have no tutorial and are rarely drilled) are
* exempt: they never block the frontier, but they still need their own evidence to
* enter the linear pool (see membership below).
*
* ── Membership ────────────────────────────────────────────────────────────────
* A skill is linear-ready iff it meets the entry policy's membership test (mastery +
* volume + accuracy by default), has real evidence, sits at a stage the
* frontier has already crossed, its category isn't vetoed, AND it is still active
* on the manual ladder (a teacher's `none` removes it — "off means off").
*/
import {
getCategoryDisplayName,
getCategorySkillIds,
getFullSkillId,
getSkillCategory,
type SkillCategoryKey,
} from '@/constants/skillCategories'
import type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
import { isActive } from '@/db/schema/player-skill-mastery'
import type { SkillBktResult } from '@/lib/curriculum/bkt/types'
import {
assessLinearEntry,
DEFAULT_LINEAR_ENTRY_POLICY,
type LinearEntryAssessment,
type LinearEntryPolicy,
} from '@/lib/curriculum/linear-entry-policy'
import type { ProblemResultWithContext } from '@/lib/curriculum/session-planner'
import { assessSkillReadiness, type SkillReadinessResult } from '@/lib/curriculum/skill-readiness'
// =============================================================================
// Stage model
// =============================================================================
interface StageDef {
rank: number
/** Category the stage is drawn from (display name + veto key). */
category: SkillCategoryKey
skillIds: string[]
/**
* Cascading stages never block the frontier (no tutorial, rarely drilled), but
* their skills still need their own evidence to enter the linear pool.
*/
exempt?: boolean
}
/**
* Curriculum stages in forward order. `advanced` is deliberately split: cascading
* carry rides with addition (rank 3), cascading borrow with subtraction (rank 6).
*/
const STAGE_DEFS: StageDef[] = [
{ rank: 0, category: 'basic', skillIds: getCategorySkillIds('basic') },
{ rank: 1, category: 'fiveComplements', skillIds: getCategorySkillIds('fiveComplements') },
{ rank: 2, category: 'tenComplements', skillIds: getCategorySkillIds('tenComplements') },
{
rank: 3,
category: 'advanced',
skillIds: [getFullSkillId('advanced', 'cascadingCarry')],
exempt: true,
},
{ rank: 4, category: 'fiveComplementsSub', skillIds: getCategorySkillIds('fiveComplementsSub') },
{ rank: 5, category: 'tenComplementsSub', skillIds: getCategorySkillIds('tenComplementsSub') },
{
rank: 6,
category: 'advanced',
skillIds: [getFullSkillId('advanced', 'cascadingBorrow')],
exempt: true,
},
]
/** One past the last stage rank — the frontier value when everything is mastered. */
const FRONTIER_ALL_MASTERED = STAGE_DEFS[STAGE_DEFS.length - 1].rank + 1
/** Every skill id that participates in a curriculum stage. */
export const ALL_STAGED_SKILL_IDS: string[] = STAGE_DEFS.flatMap((s) => s.skillIds)
const STAGE_RANK_BY_SKILL: ReadonlyMap<string, number> = new Map(
STAGE_DEFS.flatMap((stage) => stage.skillIds.map((id) => [id, stage.rank] as const))
)
/** The curriculum stage rank of a skill, or null if it isn't a staged skill. */
export function stageRank(skillId: string): number | null {
return STAGE_RANK_BY_SKILL.get(skillId) ?? null
}
// =============================================================================
// Pure core — operates on per-skill evidence, easy to unit-test
// =============================================================================
/** The two facts about a skill that drive the derivation, distilled from history. */
export interface SkillEvidence {
/**
* Meets the entry policy's MEMBERSHIP requirement (mastery + volume + accuracy
* [+ speed]) — or has no history at all, see `opportunities`.
*/
isSolid: boolean
/**
* Meets the entry policy's FRONTIER requirement (mastery + volume by default):
* the curriculum has moved past this skill. Defaults to `isSolid` when absent,
* so evidence built without a policy still behaves as before.
*/
advancesFrontier?: boolean
/** Real practice opportunities in the assessment window (0 = never practiced). */
opportunities: number
}
/** Whether a skill counts toward its stage being passed by the frontier. */
function advancesFrontier(e: SkillEvidence): boolean {
return e.advancesFrontier ?? e.isSolid
}
/**
* The contiguous fully-mastered prefix of stages.
*
* Returns the rank of the FIRST non-exempt stage that is not fully mastered (every
* skill in it having real evidence AND being solid). Skills below the returned rank
* are past the frontier. Cascading (exempt) stages never stop the frontier.
*
* MUST-FIX (critique Finding 1): a stage is mastered only with real evidence
* (`opportunities > 0`), NOT the 0-opportunity "non-blocking" default that
* `assessSkillReadiness` uses for progression — otherwise a child who has only met
* a handful of a category's skills would vault the whole stage and graduate early.
*/
export function computeFrontierRank(evidenceBySkill: ReadonlyMap<string, SkillEvidence>): number {
for (const stage of STAGE_DEFS) {
if (stage.exempt) continue
const mastered = stage.skillIds.every((id) => {
const e = evidenceBySkill.get(id)
return e != null && e.opportunities > 0 && advancesFrontier(e)
})
if (!mastered) return stage.rank
}
return FRONTIER_ALL_MASTERED
}
/**
* Given per-skill evidence, the set of currently-active skill ids, and vetoed
* categories, return the ids that are linear-ready.
*/
export function deriveLinearReadyFromEvidence(params: {
/** Catalog skill id → evidence, derived from practice history. */
evidenceBySkill: ReadonlyMap<string, SkillEvidence>
/** Skill ids whose manual `practiceLevel` is active (not `none`). */
activeSkillIds: ReadonlySet<string>
/** Categories the teacher has vetoed (kept off number sentences). */
vetoedCategories: ReadonlySet<string>
}): Set<string> {
const { evidenceBySkill, activeSkillIds, vetoedCategories } = params
const frontierRank = computeFrontierRank(evidenceBySkill)
const linearReady = new Set<string>()
for (const skillId of activeSkillIds) {
const rank = stageRank(skillId)
if (rank === null || rank >= frontierRank) continue
const evidence = evidenceBySkill.get(skillId)
// Real evidence, not the vacuous 0-opportunity "solid" — a skill enters the
// hardest modality only when the child has actually demonstrated it.
if (!evidence || !evidence.isSolid || evidence.opportunities <= 0) continue
const category = getSkillCategory(skillId)
if (category === null || vetoedCategories.has(category)) continue
linearReady.add(skillId)
}
return linearReady
}
// =============================================================================
// Adapter — wires the planner's data (history + BKT + mastery) into the core
// =============================================================================
/** Per-skill readiness for every staged skill, plus the reduced evidence the frontier uses. */
export function buildStagedSkillEvidence(
problemHistory: ProblemResultWithContext[],
bktResults: Map<string, SkillBktResult> | undefined,
policy: LinearEntryPolicy = DEFAULT_LINEAR_ENTRY_POLICY
): {
evidenceBySkill: Map<string, SkillEvidence>
readinessBySkill: Map<string, SkillReadinessResult>
entryBySkill: Map<string, LinearEntryAssessment>
} {
const evidenceBySkill = new Map<string, SkillEvidence>()
const readinessBySkill = new Map<string, SkillReadinessResult>()
const entryBySkill = new Map<string, LinearEntryAssessment>()
for (const skillId of ALL_STAGED_SKILL_IDS) {
const readiness = assessSkillReadiness(skillId, problemHistory, bktResults?.get(skillId))
const entry = assessLinearEntry(skillId, problemHistory, readiness, policy)
readinessBySkill.set(skillId, readiness)
entryBySkill.set(skillId, entry)
evidenceBySkill.set(skillId, {
isSolid: entry.ready,
advancesFrontier: entry.advancesFrontier,
opportunities: entry.opportunities,
})
}
return { evidenceBySkill, readinessBySkill, entryBySkill }
}
/** The stage currently holding number sentences back, and how close it is to solid. */
export interface LinearReadinessFrontier {
rank: number
category: SkillCategoryKey
/** Category display name, e.g. "Basic Skills". */
name: string
skillIds: string[]
/** Skills in the stage that are practiced AND advance the frontier (mastered + well practiced). */
solidCount: number
total: number
}
export interface LinearReadinessSkillDetail {
skillId: string
stageRank: number
/** Generic four-dimension assessment (what the dashboard badge uses). */
readiness: SkillReadinessResult
/** The entry policy's verdict — the one that actually decides number sentences. */
entry?: LinearEntryAssessment
}
export interface LinearReadinessExplanation {
/** `null` once every non-exempt stage is solid (nothing left to unlock). */
frontier: LinearReadinessFrontier | null
/** Linear-ready skill ids after the teacher's category vetoes. */
readySkillIds: Set<string>
/** Linear-ready skill ids ignoring vetoes — lets callers tell "vetoed" from "not ready". */
readyBeforeVetoSkillIds: Set<string>
/** Readiness detail for each skill in the frontier stage (empty when `frontier` is null). */
frontierSkills: LinearReadinessSkillDetail[]
/**
* Active skills the frontier has already moved past that still fail the entry
* policy (accuracy / speed) — "almost there". Sorted by stage, then id.
*/
pendingSkills: LinearReadinessSkillDetail[]
}
function describeFrontier(
frontierRank: number,
evidenceBySkill: ReadonlyMap<string, SkillEvidence>
): LinearReadinessFrontier | null {
const stage = STAGE_DEFS.find((s) => s.rank === frontierRank)
if (!stage) return null
const solidCount = stage.skillIds.filter((id) => {
const e = evidenceBySkill.get(id)
return e != null && e.opportunities > 0 && advancesFrontier(e)
}).length
return {
rank: stage.rank,
category: stage.category,
name: getCategoryDisplayName(stage.category),
skillIds: [...stage.skillIds],
solidCount,
total: stage.skillIds.length,
}
}
/**
* Pure explanation over pre-computed evidence: the frontier, the ready set with and
* without vetoes, and the frontier stage's per-skill readiness (when supplied).
*/
export function explainLinearReadinessFromEvidence(params: {
evidenceBySkill: ReadonlyMap<string, SkillEvidence>
activeSkillIds: ReadonlySet<string>
vetoedCategories: ReadonlySet<string>
readinessBySkill?: ReadonlyMap<string, SkillReadinessResult>
entryBySkill?: ReadonlyMap<string, LinearEntryAssessment>
}): LinearReadinessExplanation {
const { evidenceBySkill, activeSkillIds, vetoedCategories, readinessBySkill, entryBySkill } =
params
const readyBeforeVetoSkillIds = deriveLinearReadyFromEvidence({
evidenceBySkill,
activeSkillIds,
vetoedCategories: new Set(),
})
const readySkillIds = new Set(
[...readyBeforeVetoSkillIds].filter((id) => {
const category = getSkillCategory(id)
return category !== null && !vetoedCategories.has(category)
})
)
const frontierRank = computeFrontierRank(evidenceBySkill)
const frontier = describeFrontier(frontierRank, evidenceBySkill)
const detail = (skillId: string, stageRank: number): LinearReadinessSkillDetail | null => {
const readiness = readinessBySkill?.get(skillId)
if (!readiness) return null
return { skillId, stageRank, readiness, entry: entryBySkill?.get(skillId) }
}
const frontierSkills: LinearReadinessSkillDetail[] = []
if (frontier) {
for (const skillId of frontier.skillIds) {
const d = detail(skillId, frontier.rank)
if (d) frontierSkills.push(d)
}
}
const pendingSkills: LinearReadinessSkillDetail[] = []
for (const skillId of activeSkillIds) {
const rank = stageRank(skillId)
const e = evidenceBySkill.get(skillId)
if (rank === null || rank >= frontierRank || !e || e.opportunities <= 0) continue
if (readyBeforeVetoSkillIds.has(skillId)) continue
const d = detail(skillId, rank)
if (d) pendingSkills.push(d)
}
pendingSkills.sort((a, b) => a.stageRank - b.stageRank || a.skillId.localeCompare(b.skillId))
return { frontier, readySkillIds, readyBeforeVetoSkillIds, frontierSkills, pendingSkills }
}
/**
* Single-source readiness contract: everything the planner, the modal and the
* dashboard need to agree on WHY number sentences are or aren't available.
*/
export function explainLinearReadiness(params: {
skillMastery: Pick<PlayerSkillMastery, 'skillId' | 'practiceLevel'>[]
problemHistory: ProblemResultWithContext[]
bktResults: Map<string, SkillBktResult> | undefined
vetoedCategories: ReadonlySet<string>
/** Entry policy (thresholds + which dimensions count); defaults to the code defaults. */
policy?: LinearEntryPolicy
}): LinearReadinessExplanation {
const { skillMastery, problemHistory, bktResults, vetoedCategories, policy } = params
const { evidenceBySkill, readinessBySkill, entryBySkill } = buildStagedSkillEvidence(
problemHistory,
bktResults,
policy
)
const activeSkillIds = new Set(
skillMastery.filter((s) => isActive(s.practiceLevel)).map((s) => s.skillId)
)
return explainLinearReadinessFromEvidence({
evidenceBySkill,
activeSkillIds,
vetoedCategories,
readinessBySkill,
entryBySkill,
})
}
/**
* Adapter used by the session planner: derive evidence from the student's
* mastery rows, practice history and BKT results, then reduce to the
* linear-ready ids. Requires nothing but the real catalog.
*/
export function deriveLinearReadySkills(params: {
skillMastery: Pick<PlayerSkillMastery, 'skillId' | 'practiceLevel'>[]
problemHistory: ProblemResultWithContext[]
bktResults: Map<string, SkillBktResult> | undefined
vetoedCategories: ReadonlySet<string>
}): Set<string> {
return explainLinearReadiness(params).readySkillIds
}
/** Group a set of linear-ready skill ids by category (for the graduation banner). */
export function groupLinearReadyByCategory(
skillIds: Iterable<string>
): Map<SkillCategoryKey, string[]> {
const byCategory = new Map<SkillCategoryKey, string[]>()
for (const skillId of skillIds) {
const category = getSkillCategory(skillId)
if (category === null) continue
const list = byCategory.get(category)
if (list) list.push(skillId)
else byCategory.set(category, [skillId])
}
return byCategory
}
|