dsh-math-modeling-agent 0.2.8 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { lstat, readFile, readdir, realpath } from 'node:fs/promises'
3
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ const MAX_FILES = 10_000
7
+ const MAX_FILE_BYTES = 20 * 1024 * 1024
8
+ const MAX_TOTAL_BYTES = 100 * 1024 * 1024
9
+ const MAX_DEPTH = 32
10
+
11
+ function normalizeRelative(path) {
12
+ return path.split(sep).join('/')
13
+ }
14
+
15
+ function assertInside(root, path) {
16
+ const relativePath = relative(root, path)
17
+ if (isAbsolute(relativePath) || relativePath === '..' || relativePath.startsWith('..' + sep)) {
18
+ throw new Error('distribution entry escapes package root: ' + path)
19
+ }
20
+ }
21
+
22
+ async function collectFiles(root, entries, { allowMissing = false } = {}) {
23
+ const realRoot = await realpath(root)
24
+ const files = new Map()
25
+ let totalBytes = 0
26
+
27
+ const visit = async (path, depth) => {
28
+ if (depth > MAX_DEPTH) throw new Error('distribution tree exceeds maximum depth: ' + path)
29
+ let information
30
+ try {
31
+ information = await lstat(path)
32
+ } catch (error) {
33
+ if (allowMissing && error?.code === 'ENOENT') return
34
+ throw error
35
+ }
36
+ if (information.isSymbolicLink()) throw new Error('symbolic link is not allowed in distribution: ' + path)
37
+ const realPath = await realpath(path)
38
+ assertInside(realRoot, realPath)
39
+ if (information.isDirectory()) {
40
+ for (const entry of await readdir(path)) await visit(join(path, entry), depth + 1)
41
+ return
42
+ }
43
+ if (!information.isFile()) return
44
+ if (information.size > MAX_FILE_BYTES) throw new Error('distribution file exceeds maximum size: ' + path)
45
+ totalBytes += information.size
46
+ if (totalBytes > MAX_TOTAL_BYTES) throw new Error('distribution tree exceeds maximum size')
47
+ if (files.size >= MAX_FILES) throw new Error('distribution tree exceeds maximum file count')
48
+ const relativePath = normalizeRelative(relative(root, path))
49
+ const digest = createHash('sha256').update(await readFile(path)).digest('hex')
50
+ files.set(relativePath, digest)
51
+ }
52
+
53
+ for (const entry of entries) {
54
+ if (typeof entry !== 'string' || !entry.trim()) throw new Error('package files entries must be non-empty strings')
55
+ const path = resolve(root, entry)
56
+ assertInside(root, path)
57
+ await visit(path, 0)
58
+ }
59
+ return files
60
+ }
61
+
62
+ /** Compare the allowlisted package files of a source and distribution root. */
63
+ export async function compareDistribution(sourceRoot, candidateRoot) {
64
+ const source = resolve(sourceRoot)
65
+ const candidate = resolve(candidateRoot)
66
+ const manifest = JSON.parse(await readFile(join(source, 'package.json'), 'utf8'))
67
+ if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
68
+ throw new Error('source package.json must declare a non-empty files allowlist')
69
+ }
70
+ const entries = [...new Set(['package.json', ...manifest.files])]
71
+ const expected = await collectFiles(source, entries)
72
+ const actual = await collectFiles(candidate, entries, { allowMissing: true })
73
+ const missing = [...expected.keys()].filter(path => !actual.has(path)).sort()
74
+ const extra = [...actual.keys()].filter(path => !expected.has(path)).sort()
75
+ const mismatched = [...expected.keys()]
76
+ .filter(path => actual.has(path) && actual.get(path) !== expected.get(path))
77
+ .sort()
78
+ const metadataMismatches = []
79
+ try {
80
+ const candidateManifest = JSON.parse(await readFile(join(candidate, 'package.json'), 'utf8'))
81
+ for (const key of ['name', 'version']) {
82
+ if (candidateManifest[key] !== manifest[key]) {
83
+ metadataMismatches.push(`${key}: expected ${manifest[key]}, received ${candidateManifest[key]}`)
84
+ }
85
+ }
86
+ } catch (error) {
87
+ metadataMismatches.push('package.json: ' + (error instanceof Error ? error.message : String(error)))
88
+ }
89
+ return {
90
+ ok: missing.length === 0 && extra.length === 0 && mismatched.length === 0 && metadataMismatches.length === 0,
91
+ sourceRoot: source,
92
+ candidateRoot: candidate,
93
+ expectedFileCount: expected.size,
94
+ candidateFileCount: actual.size,
95
+ missing,
96
+ extra,
97
+ mismatched,
98
+ metadataMismatches,
99
+ }
100
+ }
101
+
102
+ function parseArguments(argv) {
103
+ if (argv.length !== 2 || argv.some(value => !value || value.startsWith('-'))) {
104
+ throw new Error('usage: node distribution-parity.mjs <source-root> <candidate-root>')
105
+ }
106
+ return { sourceRoot: argv[0], candidateRoot: argv[1] }
107
+ }
108
+
109
+ export async function main(argv = process.argv.slice(2)) {
110
+ try {
111
+ const { sourceRoot, candidateRoot } = parseArguments(argv)
112
+ const result = await compareDistribution(sourceRoot, candidateRoot)
113
+ console.log(JSON.stringify(result, null, 2))
114
+ return result.ok ? 0 : 1
115
+ } catch (error) {
116
+ console.error(error instanceof Error ? error.message : String(error))
117
+ return 2
118
+ }
119
+ }
120
+
121
+ if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
122
+ process.exitCode = await main()
123
+ }
@@ -18,7 +18,8 @@ const FINAL_STATES = ['SOLVED', 'PARTIAL', 'CONDITIONAL', 'INCONCLUSIVE', 'REFUT
18
18
  const STATUSES = [...NON_FINAL_STATES, ...FINAL_STATES]
19
19
  const STOP_TRANSITIONS = ['BLOCKED', 'CANCELLED']
20
20
  const TRANSITIONS = Object.freeze({
21
- TRIAGE: ['SCOPE_FROZEN', ...STOP_TRANSITIONS],
21
+ TRIAGE: ['RESEARCH', 'SCOPE_FROZEN', ...STOP_TRANSITIONS],
22
+ RESEARCH: ['SCOPE_FROZEN', 'CANDIDATES_READY', ...STOP_TRANSITIONS],
22
23
  SCOPE_FROZEN: ['INPUT_PROFILED', ...STOP_TRANSITIONS],
23
24
  INPUT_PROFILED: ['CLAIMS_REGISTERED', ...STOP_TRANSITIONS],
24
25
  CLAIMS_REGISTERED: ['RESEARCH', 'CANDIDATES_READY', ...STOP_TRANSITIONS],
@@ -27,7 +28,6 @@ const TRANSITIONS = Object.freeze({
27
28
  EXECUTE: ['VERIFY', ...STOP_TRANSITIONS],
28
29
  VERIFY: ['REVISE', 'RESEARCH', 'FORK', ...FINAL_STATES],
29
30
  REVISE: ['ATTEMPT', ...STOP_TRANSITIONS],
30
- RESEARCH: ['CANDIDATES_READY', ...STOP_TRANSITIONS],
31
31
  FORK: ['ATTEMPT', ...STOP_TRANSITIONS],
32
32
  })
33
33
  const TASK_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
@@ -440,13 +440,19 @@ function interactionContractViolations(run, ledger, to, reason, subproblem) {
440
440
  const violations = []
441
441
  const transitional = to !== undefined
442
442
  const earlyStop = ['BLOCKED', 'CANCELLED'].includes(run.status)
443
- if ((transitional && to === 'SCOPE_FROZEN') || (!transitional && run.status !== 'TRIAGE' && !earlyStop)) {
443
+ const statusOrder = ['TRIAGE', 'RESEARCH', 'SCOPE_FROZEN', 'INPUT_PROFILED', 'CLAIMS_REGISTERED', 'CANDIDATES_READY', 'ATTEMPT', 'EXECUTE', 'VERIFY', 'REVISE', 'FORK']
444
+ const reached = (target) => transitional
445
+ ? to === target
446
+ : (statusOrder.indexOf(target) <= statusOrder.indexOf(run.status) && !earlyStop)
447
+ if (reached('SCOPE_FROZEN')) {
444
448
  if (!hasDecisionRecord(scope, 'D0')) violations.push('D0 restatement interaction record missing (ledger.scope.interactions)')
445
449
  if (!hasDecisionRecord(scope, 'D1')) violations.push('D1 routing interaction record missing (ledger.scope.interactions)')
450
+ if (!hasDecisionRecord(scope, 'D-R', { allowAuto: false })) violations.push('D-R literature-research interaction record missing (literature survey precedes restatement; auto-authorization is not accepted)')
446
451
  }
452
+ if (reached('CLAIMS_REGISTERED') && !hasDecisionRecord(scope, 'D2-G')) violations.push('D2-G global-assumptions interaction record missing (global assumptions must be confirmed before claims registration)')
453
+ const candidatesGate = reached('CANDIDATES_READY')
454
+ if (candidatesGate && !hasDecisionRecord(scope, 'D-R', { allowAuto: false })) violations.push('D-R literature-research interaction record missing (backstop check)')
447
455
  const afterCandidates = ['CANDIDATES_READY', 'ATTEMPT', 'EXECUTE', 'VERIFY', 'REVISE', 'FORK', ...FINAL_STATES]
448
- const candidatesGate = transitional ? (to === 'CANDIDATES_READY') : (afterCandidates.includes(run.status) && !earlyStop)
449
- if (candidatesGate && !hasDecisionRecord(scope, 'D-R', { allowAuto: false })) violations.push('D-R literature-research interaction record missing (pre-modeling literature survey is mandatory; auto-authorization is not accepted)')
450
456
  const enteringAttemptFromCandidates = transitional ? (to === 'ATTEMPT' && run.status === 'CANDIDATES_READY') : (run.currentAttempt >= 1)
451
457
  if (enteringAttemptFromCandidates) {
452
458
  if (transitional && !subproblem) violations.push('ATTEMPT from CANDIDATES_READY requires --subproblem <id>')
@@ -468,8 +474,19 @@ function interactionContractViolations(run, ledger, to, reason, subproblem) {
468
474
  : (FINAL_STATES.includes(run.status) && !['BLOCKED', 'CANCELLED'].includes(run.status))
469
475
  if (terminalAndExempt) {
470
476
  if (scope.robustnessExempt !== true) {
471
- const passed = Array.isArray(ledger.obligations) && ledger.obligations.some(o => o && o.kind === 'robustness' && o.status === 'PASS')
472
- if (!passed) violations.push('terminal state requires a PASSED robustness-kind obligation (or scope.robustnessExempt: true)')
477
+ const robos = Array.isArray(ledger.obligations) ? ledger.obligations.filter(o => o && o.kind === 'robustness' && o.required !== false) : []
478
+ const allPassed = robos.length > 0 && robos.every(o => o.status === 'PASS')
479
+ if (!allPassed) violations.push(`terminal state requires ALL ${robos.length} required robustness obligations to be PASS (or scope.robustnessExempt: true)`)
480
+ }
481
+ const subs = Array.isArray(ledger.subproblems) ? ledger.subproblems : []
482
+ if (subs.length > 0) {
483
+ const done = (sp) => sp.status === 'DONE' || sp.status === 'CLOSED'
484
+ const unfinished = subs.filter(sp => !done(sp))
485
+ if (unfinished.length > 0) violations.push(`terminal state requires every subproblem DONE (open: ${unfinished.map(sp => sp.id).join(', ')})`)
486
+ const depGaps = subs.filter(sp => done(sp) && Array.isArray(sp.dependencies) && sp.dependencies.some(dep => {
487
+ const d = subs.find(x => x.id === dep); return d === undefined || !done(d)
488
+ }))
489
+ if (depGaps.length > 0) violations.push(`subproblem dependencies incomplete: ${depGaps.map(sp => sp.id).join(', ')}`)
473
490
  }
474
491
  if (scope.cleanupPassed !== true) violations.push('terminal state requires scope.cleanupPassed: true (run the cleanup checklist in run-directory.md)')
475
492
  }
@@ -574,6 +591,16 @@ async function validateRunUnlocked(root, expectedTaskId) {
574
591
  warnings.push(`attempt ${run.currentAttempt} report.md unreadable (runlog digest required)`)
575
592
  }
576
593
  }
594
+ for (const assumption of Array.isArray(ledger.assumptions) ? ledger.assumptions : []) {
595
+ const revised = assumption && (assumption.status === 'revised' || (Array.isArray(assumption.revisionHistory) && assumption.revisionHistory.length > 0))
596
+ if (!revised) continue
597
+ for (const claim of Array.isArray(ledger.claims) ? ledger.claims : []) {
598
+ const depends = claim && Array.isArray(claim.assumptions) && claim.assumptions.includes(assumption.id)
599
+ if (depends && claim.status === 'VERIFIED' && (!Array.isArray(claim.evidenceIds) || claim.evidenceIds.length === 0)) {
600
+ warnings.push(`claim ${claim.id} depends on revised assumption ${assumption.id} but has no post-revision evidence`)
601
+ }
602
+ }
603
+ }
577
604
  }
578
605
  return { valid: diagnostics.length === 0, diagnostics, warnings, run: clone(run) }
579
606
  }