Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c8d7e38
fix(curriculum): include required prerequisites in personal learning …
enpasos Nov 10, 2026
eb49561
fix(curriculum): preserve legacy mastery reads with closed learning s…
enpasos Nov 10, 2026
ba359c4
test(graph): align native probes with required learning closure
enpasos Nov 10, 2026
492b8ec
test(learning): update seed presentation projection fixture
enpasos Nov 10, 2026
2ef5348
perf(graph): share applicability inputs within prerequisite verification
enpasos Nov 10, 2026
a319fa8
Merge verified-input reuse into Biology assessment-basis integration
enpasos Nov 10, 2026
fb35cf7
Merge required learning scope into Biology integration
enpasos Nov 10, 2026
9f102d6
Merge validated topology reuse into Biology integration
enpasos Nov 10, 2026
5b51a0a
feat(graph): verify assessment bases during prerequisite reduction
enpasos Nov 10, 2026
ef9db49
fix(graph): consume verified assessment basis bindings
enpasos Nov 10, 2026
4a45c1d
fix(learning): reject lost prerequisite acquisition targets
enpasos Nov 10, 2026
e6f1752
Merge commit '972c6e3383b7b273b23ca36b3d4b87860627501d' into fix/g17-…
enpasos Nov 10, 2026
ad72d18
fix(graph): integrate Biology atomic prerequisite proof
enpasos Nov 10, 2026
79b2f50
fix(validation): enforce APV-102 on actual learning targets
enpasos Nov 10, 2026
2173859
Merge remote-tracking branch 'origin/main' into fix/g17-biology-asses…
enpasos Nov 10, 2026
1d63d2b
test(graph): record Biology milestone and scope acceptance
enpasos Nov 10, 2026
3eb07e5
docs: use published-safe link for biology acceptance evidence
enpasos Nov 11, 2026
2e69dbe
fix(books): regenerate biology atlas input receipt
enpasos Nov 11, 2026
300a5b8
Merge remote-tracking branch 'origin/main' into fix/personal-curricul…
enpasos Nov 11, 2026
76afc6f
Merge remote-tracking branch 'origin/perf/applicability-input-snapsho…
enpasos Nov 11, 2026
fa2bac5
fix(curriculum): guard incomplete plans and align required-learning r…
enpasos Nov 11, 2026
bdae30d
fix(curriculum): preserve unresolved exports and test closed learning…
enpasos Nov 11, 2026
7d3fbc4
Merge branch 'fix/personal-curriculum-prerequisite-closure' into fix/…
enpasos Nov 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,9 @@ Interpretation rules:
reference for each atom. The 10 November decision supersedes the 7 November
blanket manual-role requirement at this boundary. Unknown references, cycles,
missing canonical atoms or unexposed/unaccounted derived targets remain hard
errors, regardless of mastery or focus. Compiler, validation and runtime
implementation of this corrected contract is still open.
errors, regardless of mastery or focus. The learner runtime and first-party
WebGUI implement this closed basis. Applicability validation, migration-proof
admission and complete native/curriculum/remote acceptance remain separate.
- `prerequisiteOnly` remains a subject-local support role. A required atom may
keep that role in one view when the same canonical atom is a target elsewhere
in the full Personal Curriculum; acquisition counts once. It must not remain
Expand Down
6 changes: 3 additions & 3 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,13 @@
"test:personal-curriculum-stage-scope": "tsx src/utils/personalCurriculumStageScope.test.ts",
"test:personal-curriculum-editor": "tsx src/utils/personalCurriculumOptionLabel.test.ts && tsx src/utils/courseProfileFilters.test.ts && npm run test:personal-curriculum-stage-scope && tsx scripts/testPersonalCurriculumEditorLabels.tsx && tsx src/utils/personalCurriculumEditorApi.test.ts",
"test:learner-learning-plans": "tsx src/utils/learnerLearningPlanApi.test.ts && tsx src/utils/masteryHistory.test.ts && tsx scripts/testLearnerPlanTodayCardUi.tsx && tsx scripts/testLearnerPlanCockpitUi.ts && tsx scripts/testLearnerViewMobilePlanChromeUi.ts && tsx scripts/testLearnerPlanGaugesPreviewUi.ts",
"test:learner-cockpit-startup": "tsx scripts/testLearnerCoreStartupUi.ts && tsx scripts/testLearnerViewStartupUi.ts",
"test:learner-cockpit-startup": "tsx scripts/testLearnerCoreStartupUi.ts && tsx scripts/testLearnerViewStartupUi.ts && tsx scripts/testPersonalCurriculumProjectionUi.ts",
"test:learner-map-projection": "tsx scripts/testLearnerMapProjection.ts",
"test:learner-map-native-ui": "tsx scripts/testNativeLearnerMapUi.ts",
"test:learner-map-prototype": "tsx scripts/testFullCockpitPrototype.ts && tsx scripts/testLearningUnitMapPrototype.ts",
"test:learner-map-build": "tsx scripts/testLearnerMapBuild.ts",
"test:learner-map": "npm run test:learner-map-projection && npm run test:learner-map-native-ui && npm run test:learner-map-prototype",
"test:composition-projection-roles": "tsx scripts/testCompositionProjectionRoles.ts",
"test:composition-projection-roles": "tsx scripts/testCompositionProjectionRoles.ts && tsx src/utils/personalCurriculumProjection.test.tsx",
"test:course-level-mapping-consistency": "tsx scripts/testCourseLevelMappingConsistency.ts",
"test:physics-source-method-routes": "tsx --test ../scripts/check_physics_source_method_routes.test.ts",
"test:goal-book-model": "tsx scripts/testGoalBookModel.ts && npm run test:math-distance-reflection-structure && npm run test:math-pythagoras-structure",
Expand Down Expand Up @@ -100,7 +100,7 @@
"validate:graph": "tsx scripts/validateGraph.ts",
"audit:transitive-minimality": "tsx scripts/auditTransitiveMinimality.ts --report-only",
"check:transitive-minimality": "tsx scripts/auditTransitiveMinimality.ts --check",
"test:transitive-minimality": "tsx scripts/testTransitiveMinimality.ts && tsx --test scripts/testTransitiveMinimalityCli.ts && tsx scripts/testPrerequisiteReductionProof.ts",
"test:transitive-minimality": "tsx --test scripts/testApplicabilityInputSnapshot.ts && tsx scripts/testTransitiveMinimality.ts && tsx --test scripts/testTransitiveMinimalityCli.ts && tsx scripts/testPrerequisiteReductionProof.ts",
"test:exam-markdown": "tsx scripts/testExamMarkdownValidation.ts",
"test:math-checkpoint-exams": "tsx scripts/testMathCheckpointExamRelease.ts",
"test:hard-learning-routes": "tsx scripts/testHardLearningRouteValidation.ts",
Expand Down
136 changes: 85 additions & 51 deletions app/scripts/applicabilityCompiler.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { ApplicabilityMap, LearningGoal, SkillLandscape } from '../src/landscapeTypes'
import { normalizeJurisdictionCode, type KnownJurisdiction } from '../src/utils/jurisdictionMetadata'
import { discoverActiveMemoryCardReviewConfigs } from './memoryCardReviewConfigDiscovery'
import { liveVerificationInputs, withVerificationInputSnapshot, type VerificationInputReader } from './lib/verificationInputSnapshot'

const scriptDir = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(scriptDir, '../..')
Expand Down Expand Up @@ -219,8 +220,9 @@ export function hasOnlyPartialMappingSourceEvidence(
&& sourceEvidence.every((entry) => entry.kind === 'mapping' && entry.mappingStrength === 'partial')
}

const supportedOverviewBytes = readFileSync(canonicalOverviewFile)
function loadSupportedJurisdictions(): SupportedJurisdiction[] {
const json = JSON.parse(readFileSync(canonicalOverviewFile, 'utf8')) as unknown
const json = JSON.parse(supportedOverviewBytes.toString('utf8')) as unknown
if (!isSkillLandscapeJson(json)) {
throw new Error(`Cannot load canonical overview landscape from ${canonicalOverviewFile}.`)
}
Expand All @@ -243,8 +245,8 @@ function loadSupportedJurisdictions(): SupportedJurisdiction[] {
const SUPPORTED_JURISDICTIONS = loadSupportedJurisdictions()
const SUPPORTED_JURISDICTION_SET = new Set<SupportedJurisdiction>(SUPPORTED_JURISDICTIONS)

export function getAllJsonFiles(dir: string, files: string[] = []): string[] {
const entries = readdirSync(dir, { withFileTypes: true })
export function getAllJsonFiles(dir: string, files: string[] = [], inputs = liveVerificationInputs): string[] {
const entries = [...inputs.readDirectory(dir)]
.sort((a, b) => a.name.localeCompare(b.name))

for (const entry of entries) {
Expand All @@ -253,7 +255,7 @@ export function getAllJsonFiles(dir: string, files: string[] = []): string[] {
// Review inputs and historical snapshots are not live curriculum sources.
// Their explicit QA bindings are checked by the dedicated review tools.
if (entry.name === 'quality') continue
getAllJsonFiles(file, files)
getAllJsonFiles(file, files, inputs)
continue
}
if (entry.name.endsWith('.json')) {
Expand Down Expand Up @@ -354,13 +356,13 @@ function jurisdictionFromPath(file: string): KnownJurisdiction | null {
return null
}

function loadLandscapePathIndex() {
const files = getAllJsonFiles(curriculaDir)
function loadLandscapePathIndex(inputs: VerificationInputReader = liveVerificationInputs) {
const files = getAllJsonFiles(curriculaDir, [], inputs)
const pathByLandscapeId = new Map<string, string>()

for (const file of files) {
try {
const json = JSON.parse(readFileSync(file, 'utf8')) as unknown
const json = JSON.parse(inputs.readText(file)) as unknown
if (!isSkillLandscapeJson(json)) continue

const existing = pathByLandscapeId.get(json.landscapeId)
Expand All @@ -382,11 +384,11 @@ function loadLandscapePathIndex() {
return pathByLandscapeId
}

function loadSourceLandscapeRegistry(): LoadedSourceLandscapeRegistry {
function loadSourceLandscapeRegistry(inputs: VerificationInputReader = liveVerificationInputs): LoadedSourceLandscapeRegistry {
const entriesByLandscapeId = new Map<string, LoadedSourceLandscapeRegistryEntry>()

try {
const raw = JSON.parse(readFileSync(sourceLandscapeRegistryFile, 'utf8')) as SourceLandscapeRegistryFile
const raw = JSON.parse(inputs.readText(sourceLandscapeRegistryFile)) as SourceLandscapeRegistryFile
if (raw.version !== 1) {
throw new Error(`Unsupported source landscape registry version in ${repoRelative(sourceLandscapeRegistryFile)}.`)
}
Expand Down Expand Up @@ -436,11 +438,11 @@ function formatRegistryEvidenceSource(
return registryRef
}

function loadCanonicalGoalProvenanceRegistry(): LoadedCanonicalGoalProvenanceRegistry {
function loadCanonicalGoalProvenanceRegistry(inputs: VerificationInputReader = liveVerificationInputs): LoadedCanonicalGoalProvenanceRegistry {
const entriesByGoalId = new Map<string, Record<string, unknown>>()

try {
const raw = JSON.parse(readFileSync(canonicalGoalProvenanceRegistryFile, 'utf8')) as CanonicalGoalProvenanceRegistryFile
const raw = JSON.parse(inputs.readText(canonicalGoalProvenanceRegistryFile)) as CanonicalGoalProvenanceRegistryFile
if (raw.version !== 1) {
throw new Error(`Unsupported canonical goal provenance registry version in ${repoRelative(canonicalGoalProvenanceRegistryFile)}.`)
}
Expand All @@ -466,11 +468,11 @@ function loadCanonicalGoalProvenanceRegistry(): LoadedCanonicalGoalProvenanceReg
}
}

function loadCanonicalGoalApplicabilityOverrideRegistry(): LoadedCanonicalGoalApplicabilityOverrideRegistry {
function loadCanonicalGoalApplicabilityOverrideRegistry(inputs: VerificationInputReader = liveVerificationInputs): LoadedCanonicalGoalApplicabilityOverrideRegistry {
const entriesByGoalId = new Map<string, Record<string, unknown>>()

try {
const raw = JSON.parse(readFileSync(canonicalGoalApplicabilityOverrideRegistryFile, 'utf8')) as CanonicalGoalApplicabilityOverrideRegistryFile
const raw = JSON.parse(inputs.readText(canonicalGoalApplicabilityOverrideRegistryFile)) as CanonicalGoalApplicabilityOverrideRegistryFile
if (raw.version !== 1) {
throw new Error(`Unsupported canonical goal applicability override registry version in ${repoRelative(canonicalGoalApplicabilityOverrideRegistryFile)}.`)
}
Expand All @@ -496,16 +498,16 @@ function loadCanonicalGoalApplicabilityOverrideRegistry(): LoadedCanonicalGoalAp
}
}

function loadMemoryReviewOriginReferences(): Map<string, MemoryReviewOriginReference[]> {
function loadMemoryReviewOriginReferences(inputs: VerificationInputReader = liveVerificationInputs): Map<string, MemoryReviewOriginReference[]> {
const referencesByMemoryGoalKey = new Map<string, MemoryReviewOriginReference[]>()
// The central registry owns current Memory evidence, including successors
// outside the original authoring directory. Discovery validates that the
// retained scope and visibility requirements are preserved.
const configFiles = discoverActiveMemoryCardReviewConfigs()
const configFiles = discoverActiveMemoryCardReviewConfigs(undefined, undefined, inputs)
.map(({ configPath }) => resolve(repoRoot, configPath))

for (const configFile of configFiles) {
const config = JSON.parse(readFileSync(configFile, 'utf8')) as {
const config = JSON.parse(inputs.readText(configFile)) as {
reviewId?: unknown
landscapeId?: unknown
reviewPath?: unknown
Expand All @@ -519,7 +521,7 @@ function loadMemoryReviewOriginReferences(): Map<string, MemoryReviewOriginRefer
}

const reviewPath = resolve(repoRoot, config.reviewPath)
const lines = readFileSync(reviewPath, 'utf8')
const lines = inputs.readText(reviewPath)
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
Expand Down Expand Up @@ -556,10 +558,10 @@ export interface ApplicabilityCanonicalInput {
landscape: SkillLandscape
}

function loadCanonicalLandscapes(suppliedCatalog?: readonly ApplicabilityCanonicalInput[]): LoadedCanonicalLandscape[] {
const inputs = suppliedCatalog ?? getAllJsonFiles(canonicalDir)
function loadCanonicalLandscapes(suppliedCatalog?: readonly ApplicabilityCanonicalInput[], inputReader = liveVerificationInputs): LoadedCanonicalLandscape[] {
const inputs = suppliedCatalog ?? getAllJsonFiles(canonicalDir, [], inputReader)
.sort((a, b) => a.localeCompare(b))
.map((file) => ({ file, landscape: JSON.parse(readFileSync(file, 'utf8')) as SkillLandscape }))
.map((file) => ({ file, landscape: JSON.parse(inputReader.readText(file)) as SkillLandscape }))
return inputs.map(({ file, landscape }) => {
const goalById = new Map(landscape.goals.map((goal) => [goal.id, goal]))
const rootGoal = landscape.goals.find((goal) => goal.tags?.includes('root')) ?? landscape.goals[0]
Expand All @@ -573,12 +575,12 @@ function loadCanonicalLandscapes(suppliedCatalog?: readonly ApplicabilityCanonic
})
}

function loadMappingFiles(): LoadedMappingFile[] {
return getAllJsonFiles(join(curriculaDir, 'DE'))
function loadMappingFiles(inputs: VerificationInputReader = liveVerificationInputs): LoadedMappingFile[] {
return getAllJsonFiles(join(curriculaDir, 'DE'), [], inputs)
.filter((file) => file.replace(/\\/g, '/').includes('/mapping/'))
.sort((a, b) => a.localeCompare(b))
.map((file) => {
const raw = JSON.parse(readFileSync(file, 'utf8')) as GoalMappingFile
const raw = JSON.parse(inputs.readText(file)) as GoalMappingFile
return {
file,
sourceLandscapeId: raw.sourceLandscapeId,
Expand Down Expand Up @@ -812,10 +814,62 @@ function currentApplicabilityForGoal(goal: LearningGoal): ApplicabilityMap {
return normalizeCompiledApplicability(normalized)
}

declare const applicabilitySnapshot: unique symbol
export type ApplicabilityInputSnapshot = Readonly<{ [applicabilitySnapshot]: true }>
const loadedCompilerInputs = [
'app/scripts/applicabilityCompiler.ts', 'app/scripts/memoryCardReviewConfigDiscovery.ts',
'app/scripts/lib/verificationInputSnapshot.ts', 'app/src/utils/jurisdictionMetadata.ts',
'app/scripts/prerequisiteReductionProof.ts', 'app/scripts/auditTransitiveMinimality.ts', 'app/package-lock.json',
].map(relativePath => {
const path = resolve(repoRoot, relativePath)
try { return { path, bytes: readFileSync(path) } }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return { path, bytes: null }
}
})
function loadApplicabilityInputs(inputs: VerificationInputReader, bindGeneration = false) {
if (bindGeneration) for (const { path, bytes } of loadedCompilerInputs) {
if (bytes === null || !Buffer.from(inputs.readText(path), 'utf8').equals(bytes))
throw new Error(`Applicability compiler generation changed; start a fresh process: ${path}`)
}
if (bindGeneration && !Buffer.from(inputs.readText(canonicalOverviewFile), 'utf8').equals(supportedOverviewBytes))
throw new Error('Applicability overview changed; start a fresh process')
return {
mappingFiles: loadMappingFiles(inputs), landscapePathById: loadLandscapePathIndex(inputs),
sourceLandscapeRegistry: loadSourceLandscapeRegistry(inputs),
canonicalGoalProvenanceRegistry: loadCanonicalGoalProvenanceRegistry(inputs),
canonicalGoalApplicabilityOverrideRegistry: loadCanonicalGoalApplicabilityOverrideRegistry(inputs),
memoryReviewOriginReferencesByMemoryGoalKey: loadMemoryReviewOriginReferences(inputs),
}
}
const applicabilitySnapshots = new WeakMap<ApplicabilityInputSnapshot, {
inputs: VerificationInputReader; assertCurrent: () => void; loaded?: ReturnType<typeof loadApplicabilityInputs>
}>()
export function assertApplicabilityInputSnapshotCurrent(snapshot: ApplicabilityInputSnapshot): void {
const entry = applicabilitySnapshots.get(snapshot)
if (!entry) throw new Error('Unregistered or closed applicability input snapshot')
entry.assertCurrent()
}
/** Lazy input capture: malformed proof fixtures still fail before ambient evidence loading. */
export async function withApplicabilityInputSnapshot<T>(
work: (snapshot: ApplicabilityInputSnapshot) => T | Promise<T>,
): Promise<T> {
return withVerificationInputSnapshot(async (inputs, assertCurrent) => {
const snapshot = Object.freeze({}) as ApplicabilityInputSnapshot
applicabilitySnapshots.set(snapshot, { inputs, assertCurrent })
try { return await work(snapshot) }
finally { applicabilitySnapshots.delete(snapshot) }
})
}

export function buildApplicabilityCompilation(
suppliedCatalog?: readonly ApplicabilityCanonicalInput[],
suppliedCatalog?: readonly ApplicabilityCanonicalInput[], snapshot?: ApplicabilityInputSnapshot,
): ApplicabilityCompilationResult {
const canonicalLandscapes = loadCanonicalLandscapes(suppliedCatalog)
const captured = snapshot === undefined ? undefined : applicabilitySnapshots.get(snapshot)
if (snapshot !== undefined && !captured) throw new Error('Unregistered or closed applicability input snapshot')
const inputs = captured?.inputs ?? liveVerificationInputs
const canonicalLandscapes = loadCanonicalLandscapes(suppliedCatalog, inputs)
const canonicalLandscapeById = new Map(
canonicalLandscapes.map((entry) => [entry.landscape.landscapeId, entry]),
)
Expand Down Expand Up @@ -878,12 +932,9 @@ export function buildApplicabilityCompilation(
validateBasisReference({ landscapeId, goalId })
}

const mappingFiles = loadMappingFiles()
const landscapePathById = loadLandscapePathIndex()
const sourceLandscapeRegistry = loadSourceLandscapeRegistry()
const canonicalGoalProvenanceRegistry = loadCanonicalGoalProvenanceRegistry()
const canonicalGoalApplicabilityOverrideRegistry = loadCanonicalGoalApplicabilityOverrideRegistry()
const memoryReviewOriginReferencesByMemoryGoalKey = loadMemoryReviewOriginReferences()
const { mappingFiles, landscapePathById, sourceLandscapeRegistry, canonicalGoalProvenanceRegistry,
canonicalGoalApplicabilityOverrideRegistry, memoryReviewOriginReferencesByMemoryGoalKey } = captured
? captured.loaded ??= loadApplicabilityInputs(inputs, true) : loadApplicabilityInputs(inputs)

for (const mappingFile of mappingFiles) {
if (!mappingFile.targetLandscapeId) continue
Expand Down Expand Up @@ -1511,6 +1562,8 @@ export function buildApplicabilityCompilation(
}
}

// APV-102 checks actual emitted prerequisite learning in the mandatory
// native scope/emission tests. Source-country membership cannot prove L completeness.
for (const value of SUPPORTED_JURISDICTIONS) {
const visibleGoalIds = new Set(
canonical.landscape.goals
Expand Down Expand Up @@ -1539,25 +1592,6 @@ export function buildApplicabilityCompilation(
}
}

if (isAtomicGoal(goal) && !isMemoryGoal(goal)) {
for (const rawReq of goal.requires ?? []) {
const req = resolveCanonicalReference(rawReq)
const targetApplicability = compiledByGoalId.get(goalKey(req.landscapeId, req.goalId))
if (!targetApplicability) continue
if (!(targetApplicability[SUPPORTED_DIMENSION] ?? []).includes(value)) {
findings.push({
code: 'APV-102',
severity: 'error',
landscapeId: canonical.landscape.landscapeId,
goalId: goal.id,
title: goal.title,
dimension: SUPPORTED_DIMENSION,
value,
message: `Visible goal requires invisible prerequisite ${req.goalId} in projection ${SUPPORTED_DIMENSION}=${value}.`,
})
}
}
}
}

if (canonical.rootGoalId) {
Expand Down
7 changes: 7 additions & 0 deletions app/scripts/auditTransitiveMinimality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const auditorModulePath = fileURLToPath(import.meta.url)
const auditorModuleBytes = readFileSync(auditorModulePath)
export function assertTransitiveMinimalityImplementationCurrent(): void {
if (!readFileSync(auditorModulePath).equals(auditorModuleBytes))
throw new Error('Transitive minimality implementation changed since module load')
}

export type AuditGoal = { id: string; title?: string; contains?: string[]; requires?: string[] }
export type AuditLandscape = { landscapeId: string; goals: AuditGoal[] }
type Node = { key: string; landscapeId: string; goal: AuditGoal; contains: string[]; requires: string[] }
Expand Down

Large diffs are not rendered by default.

Loading
Loading