Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
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 --test scripts/testApplicabilityInputSnapshot.ts && 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 && tsx --test scripts/testCurriculumGraphIntegrity.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
23 changes: 19 additions & 4 deletions app/scripts/auditTransitiveMinimality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ export function assertTransitiveMinimalityImplementationCurrent(): void {
throw new Error('Transitive minimality implementation changed since module load')
}

export type AuditGoal = { id: string; title?: string; contains?: string[]; requires?: string[] }
export type AuditGoal = { id: string; title?: string; contains?: string[]; requires?: string[]; type?: unknown }
export type AuditLandscape = { landscapeId: string; goals: AuditGoal[] }
type Node = { key: string; landscapeId: string; goal: AuditGoal; contains: string[]; requires: string[] }
export type MinimalityOrigin = { source: string; prerequisite: string }
export type MinimalityFinding = MinimalityOrigin & { witness: string[]; supportingOrigins: MinimalityOrigin[] }
export type AtomicNormalFormFinding = MinimalityOrigin & { sourceAtomic: boolean; prerequisiteAtomic: boolean }
export type ExplicitTypeMismatchFinding = { goal: string; declaredType: unknown; structuralType: 'atomic' | 'cluster' }
// Unicode code-point order is independent of the process/host locale.
const compareKeys = (left: string, right: string): number => {
let a = 0, b = 0
Expand Down Expand Up @@ -231,14 +233,26 @@ export function auditTransitiveMinimality(catalog: AuditLandscape[], rootLandsca
const finding = findRedundantOrigin(prepared, origin)
if (finding) findings.push(finding)
}
// Use the already selected canonical nodes and authored origins. A foreign
// inheritance-only ancestor retains its full canonical contains structure.
const atomicNormalFormFindings: AtomicNormalFormFinding[] = origins.flatMap((origin) => {
const sourceAtomic = selected.get(origin.source)!.contains.length === 0
const prerequisiteAtomic = selected.get(origin.prerequisite)!.contains.length === 0
return sourceAtomic && prerequisiteAtomic ? [] : [{ ...origin, sourceAtomic, prerequisiteAtomic }]
})
const explicitTypeMismatchFindings: ExplicitTypeMismatchFinding[] = ordered.flatMap((node) => {
const structuralType = node.contains.length === 0 ? 'atomic' as const : 'cluster' as const
return !Object.hasOwn(node.goal, 'type') || node.goal.type === structuralType ? []
: [{ goal: node.key, declaredType: node.goal.type, structuralType }]
})
return {
nodeCount: selected.size, authoredRequiresCount: origins.length,
containsCount: [...contains.values()].reduce((n, children) => n + children.length, 0),
effectiveRequiresCount: [...fullEffective.values()].reduce((n, edges) => n + edges.length, 0),
inheritanceOnlyAncestors: sorted([...selected.keys()].filter((key) => !childrenExpanded.has(key))),
crossLandscapeReferenceCount: ordered.reduce((n, node) => n + [...contains.get(node.key)!, ...node.requires]
.filter((key) => selected.get(key)!.landscapeId !== node.landscapeId).length, 0),
landscapes: sorted(ordered.map((n) => n.landscapeId)), findings,
landscapes: sorted(ordered.map((n) => n.landscapeId)), findings, atomicNormalFormFindings, explicitTypeMismatchFindings,
findingsByLandscape: Object.fromEntries(sorted(ordered.map((n) => n.landscapeId)).map((id) =>
[id, findings.filter((f) => selected.get(f.source)!.landscapeId === id).length])),
}
Expand Down Expand Up @@ -280,7 +294,8 @@ function rootReport(catalog: AuditLandscape[], rootLandscapeId: string) {
const goalKeys = sorted(findings.flatMap((f) => [...f.witness, ...f.supportingOrigins.map((o) => o.source)]))
const indices = new Map(goalKeys.map((key, index) => [key, index]))
return {
rootLandscapeId, status: findings.length ? 'fail' as const : 'pass' as const, ...summary,
rootLandscapeId, status: findings.length || summary.atomicNormalFormFindings.length || summary.explicitTypeMismatchFindings.length
? 'fail' as const : 'pass' as const, ...summary,
findingEncoding: ['sourceIndex', 'prerequisiteIndex', 'witnessGoalIndices', 'supportingOriginSourceIndices'],
goalKeys, findings: findings.map((f) => [indices.get(f.source), indices.get(f.prerequisite),
f.witness.map((key) => indices.get(key)), f.supportingOrigins.map((o) => indices.get(o.source))]),
Expand Down Expand Up @@ -328,7 +343,7 @@ function main() {
rootResults,
}
for (const result of rootResults) console.log(`${report.mode}: ${result.rootLandscapeId}: ${result.status}; ${result.status === 'invalid-input'
? result.error : `${result.nodeCount} goals; ${result.authoredRequiresCount} authored edges; ${result.findings.length} minimality findings`}`)
? result.error : `${result.nodeCount} goals; ${result.authoredRequiresCount} authored edges; ${result.findings.length} minimality findings; ${result.atomicNormalFormFindings.length} non-atomic requires; ${result.explicitTypeMismatchFindings.length} explicit type mismatches`}`)
exitCode = status === 'invalid-input' ? 2 : status === 'fail' && !options.reportOnly ? 1 : 0
} catch (error) {
report = { schemaVersion: 2, mode: options.reportOnly ? 'report-only' : 'validation',
Expand Down
77 changes: 77 additions & 0 deletions app/scripts/curriculumGraphIntegrity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: Apache-2.0
// Current graph validation only. Historical review-binding values are not inputs.
import { auditTransitiveMinimality, type AuditLandscape } from './auditTransitiveMinimality'
import { fingerprintPrerequisiteReductionDocument, type PrerequisiteReductionCatalogSource } from './prerequisiteReductionProof'

export type CurrentCatalogEntry = { path: string; landscape: AuditLandscape }
export type GraphIntegrityRule = {
id: 'CQR-001'
status: 'pass' | 'fail'
summary: string
metrics: Record<string, number>
details?: string[]
}
function ensure(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(`Current curriculum catalog: ${message}`)
}

/** Shared producer seam: absence or incomplete coverage aborts before status rows. */
export function requireCompleteCurrentCatalog(
loaded: CurrentCatalogEntry[],
expected: readonly PrerequisiteReductionCatalogSource[] | undefined,
): AuditLandscape[] {
ensure(expected && expected.length > 0, 'verified catalog inventory is required')
const declared = new Map<string, PrerequisiteReductionCatalogSource>(), owners = new Set<string>()
for (const source of expected) {
ensure(source.path.length > 0 && source.landscapeId.length > 0, 'invalid verified path/owner')
ensure(!declared.has(source.path) && !owners.has(source.landscapeId), 'duplicate verified path/owner')
declared.set(source.path, source); owners.add(source.landscapeId)
}
ensure(Array.isArray(loaded) && loaded.length === declared.size, 'missing or additional canonical source')
const paths = new Set<string>(), roots = new Set<string>()
for (const { path, landscape } of loaded) {
ensure(!paths.has(path), `duplicate canonical path ${path}`)
paths.add(path)
const source = declared.get(path)
ensure(source && landscape && typeof landscape.landscapeId === 'string' && source.landscapeId === landscape.landscapeId,
`undeclared canonical path/owner ${path}:${landscape?.landscapeId}`)
ensure(!roots.has(landscape.landscapeId), `duplicate canonical root ${landscape.landscapeId}`)
roots.add(landscape.landscapeId)
ensure(Array.isArray(landscape.goals) && landscape.goals.length > 0, `empty or invalid canonical root ${landscape.landscapeId}`)
// Hash the unchanged full raw producer object, never a goals-only projection.
ensure(fingerprintPrerequisiteReductionDocument(landscape) === source.currentDocumentDigest,
`current document digest differs: ${path}`)
}
ensure(paths.size === declared.size && roots.size === owners.size, 'incomplete canonical path/owner coverage')
return loaded.map(({ landscape }) => landscape)
}

/** Thin CQR adapter over the genuine auditor and its existing selected closure. */
export function evaluateGraphIntegrity(catalog: AuditLandscape[], rootLandscapeId: string): GraphIntegrityRule {
try {
const audit = auditTransitiveMinimality(catalog, [rootLandscapeId])
const issues = audit.findings.length + audit.atomicNormalFormFindings.length + audit.explicitTypeMismatchFindings.length
const details = [
...audit.findings.map(({ source, prerequisite, witness, supportingOrigins }) =>
`Redundant ${source} -> ${prerequisite}; witness ${witness.join(' -> ')}; authored origins ${supportingOrigins.map((origin) => `${origin.source} -> ${origin.prerequisite}`).join('; ')}`),
...audit.atomicNormalFormFindings.map(({ source, prerequisite, sourceAtomic, prerequisiteAtomic }) =>
`Non-atomic requires ${source} -> ${prerequisite}; source=${sourceAtomic ? 'atomic' : 'cluster'}; prerequisite=${prerequisiteAtomic ? 'atomic' : 'cluster'}`),
...audit.explicitTypeMismatchFindings.map(({ goal, declaredType, structuralType }) =>
`Explicit type mismatch ${goal}; declared=${JSON.stringify(declaredType)}; structural=${structuralType}`),
]
return {
id: 'CQR-001', status: issues ? 'fail' : 'pass',
summary: issues ? `${issues} current graph integrity issue(s).` : 'Current graph references, DAGs, atomic requires normal form and transitive minimality pass.',
metrics: {
goals: audit.nodeCount, authoredRequires: audit.authoredRequiresCount, effectiveRequires: audit.effectiveRequiresCount,
crossLandscapeReferences: audit.crossLandscapeReferenceCount, minimalityFindings: audit.findings.length,
atomicNormalFormFindings: audit.atomicNormalFormFindings.length, explicitTypeMismatches: audit.explicitTypeMismatchFindings.length,
witnessEdges: audit.findings.reduce((count, finding) => count + finding.witness.length - 1, 0), invalidInputs: 0,
},
...(details.length ? { details: details.slice(0, 20) } : {}),
}
} catch (error) {
return { id: 'CQR-001', status: 'fail', summary: `Invalid current graph for ${rootLandscapeId}.`,
metrics: { invalidInputs: 1 }, details: [error instanceof Error ? error.message : String(error)] }
}
}
126 changes: 19 additions & 107 deletions app/scripts/generateCurriculumQualityStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {
discoverMemoryCardReviewConfigs,
} from './memoryCardReviewConfigDiscovery'
import { generateDeepUnderstandingRollout, type DeepUnderstandingRolloutReport } from './reportDeepUnderstandingRollout'
import { loadVerifiedPrerequisiteReductionContext } from './prerequisiteReductionProof'
import { loadVerifiedPrerequisiteReductionContext, verifiedPrerequisiteReductionCatalogSources } from './prerequisiteReductionProof'
import { evaluateGraphIntegrity, requireCompleteCurrentCatalog } from './curriculumGraphIntegrity'
import { createReviewedRequiresClosureCoverageChecker } from './sourceCoverageEvidence'
import { shouldRequireCanonicalMathSek1AssessmentEndpoint } from './lib/canonicalMathSek1ReviewedExamRoutes'
import { createRepositorySourceAvailability } from './repositorySourceAvailability'
Expand Down Expand Up @@ -1024,10 +1025,10 @@ const ruleCatalog: QualityRuleDefinition[] = [
},
{
id: 'CQR-001',
label: 'Basic graph integrity',
label: 'Current graph integrity and atomic prerequisite normal form',
category: 'graph',
maturityTarget: 'M0',
description: 'Goal IDs, local references, self-reference guards, and direct DAG checks are clean.',
description: 'Every declared current canonical root passes the shared qualified-reference and effective-DAG auditor, structural atomic requires normal form, explicit type consistency and deterministic transitive minimality, including relevant foreign inheritance.',
},
{
id: 'CQR-002',
Expand Down Expand Up @@ -1784,37 +1785,6 @@ function createVisibleAtomicPathChecker(
)
}

function findCycle(edgeMap: Map<string, string[]>): string[] | null {
const visiting = new Set<string>()
const visited = new Set<string>()
const stack: string[] = []

const visit = (goalId: string): string[] | null => {
if (visiting.has(goalId)) {
const cycleStart = stack.indexOf(goalId)
return stack.slice(Math.max(0, cycleStart)).concat(goalId)
}
if (visited.has(goalId)) return null

visiting.add(goalId)
stack.push(goalId)
for (const nextId of edgeMap.get(goalId) ?? []) {
const cycle = visit(nextId)
if (cycle) return cycle
}
stack.pop()
visiting.delete(goalId)
visited.add(goalId)
return null
}

for (const goalId of edgeMap.keys()) {
const cycle = visit(goalId)
if (cycle) return cycle
}
return null
}

function formatGoal(goal: LearningGoal | undefined, goalId: string): string {
return goal ? `${goal.title} [${goalId}]` : goalId
}
Expand All @@ -1835,69 +1805,6 @@ function makeRule(
}
}

function evaluateGraphIntegrity(landscape: SkillLandscape, globalGoalIds: Set<string>): RuleResult {
const errors: string[] = []
const goalById = new Map<string, LearningGoal>()

landscape.goals.forEach((goal) => {
if (goalById.has(goal.id)) {
errors.push(`Duplicate goal id ${goal.id}`)
} else {
goalById.set(goal.id, goal)
}
})

const requireEdges = new Map<string, string[]>()
const containsEdges = new Map<string, string[]>()

landscape.goals.forEach((goal) => {
const localRequires: string[] = []
const localContains: string[] = []

goal.requires?.forEach((rawRef) => {
const ref = parseReference(rawRef, landscape.landscapeId)
if (ref.goalId === goal.id && ref.landscapeId === landscape.landscapeId) {
errors.push(`${formatGoal(goal, goal.id)} requires itself`)
}
if (ref.landscapeId === landscape.landscapeId && !goalById.has(ref.goalId) && !globalGoalIds.has(ref.goalId)) {
errors.push(`${formatGoal(goal, goal.id)} requires missing local goal ${ref.goalId}`)
}
if (ref.landscapeId === landscape.landscapeId && goalById.has(ref.goalId)) localRequires.push(ref.goalId)
})

goal.contains?.forEach((rawRef) => {
const ref = parseReference(rawRef, landscape.landscapeId)
if (ref.goalId === goal.id && ref.landscapeId === landscape.landscapeId) {
errors.push(`${formatGoal(goal, goal.id)} contains itself`)
}
if (ref.landscapeId === landscape.landscapeId && !goalById.has(ref.goalId) && !globalGoalIds.has(ref.goalId)) {
errors.push(`${formatGoal(goal, goal.id)} contains missing local goal ${ref.goalId}`)
}
if (ref.landscapeId === landscape.landscapeId && goalById.has(ref.goalId)) localContains.push(ref.goalId)
})

requireEdges.set(goal.id, localRequires)
containsEdges.set(goal.id, localContains)
})

const requiresCycle = findCycle(requireEdges)
if (requiresCycle) errors.push(`Direct requires cycle: ${requiresCycle.join(' -> ')}`)

const containsCycle = findCycle(containsEdges)
if (containsCycle) errors.push(`Contains cycle: ${containsCycle.join(' -> ')}`)

return makeRule(
'CQR-001',
errors.length === 0 ? 'pass' : 'fail',
errors.length === 0 ? 'Basic graph integrity checks pass.' : `${errors.length} graph integrity issue(s).`,
{
goals: landscape.goals.length,
localReferenceIssues: errors.length,
},
errors,
)
}

function evaluateTypeConsistency(landscape: SkillLandscape): RuleResult {
const mismatches = landscape.goals.filter((goal) => {
if (!goal.type) return false
Expand Down Expand Up @@ -6038,6 +5945,12 @@ function deriveScopeMaturity(rules: RuleResult[]): MaturityLevel {
return 'M4'
}

export function applyGraphMaturityCeiling(curriculumRules: RuleResult[], scopes: ScopeStatus[]): ScopeStatus[] {
const graphReady = curriculumRules.find((rule) => rule.id === 'CQR-001')?.status === 'pass'
&& curriculumRules.find((rule) => rule.id === 'CQR-002')?.status === 'pass'
return graphReady ? scopes : scopes.map((scope) => ({ ...scope, maturity: 'M0' }))
}

export function deriveCurriculumMaturity(curriculumRules: RuleResult[], scopes: ScopeStatus[]): MaturityLevel {
const graphReady = curriculumRules.find((rule) => rule.id === 'CQR-001')?.status === 'pass'
&& curriculumRules.find((rule) => rule.id === 'CQR-002')?.status === 'pass'
Expand Down Expand Up @@ -6168,23 +6081,22 @@ function renderMarkdown(status: StatusDocument): string {

async function main() {
const shouldCheck = process.argv.includes('--check')
const prerequisiteReductionContext = await loadVerifiedPrerequisiteReductionContext()
const canonicalFiles = collectFiles(canonicalRoot, (fileName) => /\.json$/i.test(fileName) && !/_deck/i.test(fileName))
const loadedLandscapes = canonicalFiles.map((file) => ({ file, landscape: loadJson<SkillLandscape>(file) }))
const currentCatalog = requireCompleteCurrentCatalog(loadedLandscapes.map(({ file, landscape }) => ({
path: toRepoPath(file), landscape,
})), verifiedPrerequisiteReductionCatalogSources(prerequisiteReductionContext))
const applicabilityCompilation = buildApplicabilityCompilation()
const semanticConfigsByLandscapeId = readSemanticConfigs()
const memoryCardReviewConfigsByLandscapeId = readMemoryCardReviewConfigs()
const goalVisualizationQaLedgers = readGoalVisualizationQaLedgers()
const prerequisiteReductionContext = await loadVerifiedPrerequisiteReductionContext()
const deepUnderstandingReport = await generateDeepUnderstandingRollout(undefined, prerequisiteReductionContext)
const compositionViewCountsByLandscapeId = readCompositionViewCountsByLandscapeId()
const applicabilityWarningMetricsByLandscapeId = readApplicabilityWarningMetricsByLandscapeId(applicabilityCompilation)
const jurisdictionCoverageByLandscapeId = readJurisdictionCoverageByLandscapeId(applicabilityCompilation)
const mappingPipelineByLandscapeId = readMappingPipelineByLandscapeId()

const canonicalFiles = collectFiles(canonicalRoot, (fileName) => /\.json$/i.test(fileName) && !/_deck/i.test(fileName))
const loadedLandscapes = canonicalFiles.map((file) => ({
file,
landscape: loadJson<SkillLandscape>(file),
}))
const globalGoalIds = new Set(loadedLandscapes.flatMap(({ landscape }) => landscape.goals.map((goal) => goal.id)))

const curricula = loadedLandscapes
.map(({ file, landscape }) => {
Expand All @@ -6193,7 +6105,7 @@ async function main() {
const mappingPipeline = mappingPipelineByLandscapeId.get(landscape.landscapeId)
const memoryCardReviewConfigs = memoryCardReviewConfigsByLandscapeId.get(landscape.landscapeId) ?? []
const curriculumRules: RuleResult[] = [
evaluateGraphIntegrity(landscape, globalGoalIds),
evaluateGraphIntegrity(currentCatalog, landscape.landscapeId),
evaluateTypeConsistency(landscape),
evaluateSourceSnapshotIngestion(jurisdictionCoverage, mappingPipeline),
evaluateJurisdictionCoverage(jurisdictionCoverage),
Expand All @@ -6208,11 +6120,11 @@ async function main() {
evaluateApplicabilityWarnings(applicabilityWarningMetricsByLandscapeId.get(landscape.landscapeId)),
)
const scopedProfiles = routeProfiles.filter((profile) => profile.landscapeId === landscape.landscapeId)
const scopes = scopedProfiles.map((profile) => evaluateRouteProfile(
const scopes = applyGraphMaturityCeiling(curriculumRules, scopedProfiles.map((profile) => evaluateRouteProfile(
landscape,
profile,
applicabilityCompilation,
))
)))
if (scopes.length === 0) {
curriculumRules.push(makeRule(
'CQR-101',
Expand Down
Loading
Loading