dsh-math-modeling-agent 0.4.1 → 0.5.0
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.
- package/README.md +247 -187
- package/package.json +34 -34
- package/skills/math-modeling-agent/SKILL.md +70 -69
- package/skills/math-modeling-agent/references/claims-evidence.md +53 -41
- package/skills/math-modeling-agent/references/interaction-protocol.md +165 -163
- package/skills/math-modeling-agent/references/report-contract.md +130 -122
- package/skills/math-modeling-agent/references/run-directory.md +65 -62
- package/skills/math-modeling-agent/references/verification-recipes.md +43 -0
- package/skills/math-modeling-agent/schemas/attempt.schema.json +89 -70
- package/skills/math-modeling-agent/schemas/evidence.schema.json +110 -0
- package/skills/math-modeling-agent/schemas/failure.schema.json +30 -0
- package/skills/math-modeling-agent/schemas/ledger.schema.json +88 -68
- package/skills/math-modeling-agent/schemas/run.schema.json +126 -102
- package/skills/math-modeling-agent/schemas/verification.schema.json +60 -0
- package/skills/math-modeling-agent/scripts/correction-lineage.mjs +78 -0
- package/skills/math-modeling-agent/scripts/evidence-store.mjs +349 -0
- package/skills/math-modeling-agent/scripts/failure-insights.mjs +79 -0
- package/skills/math-modeling-agent/scripts/input-snapshot.mjs +98 -0
- package/skills/math-modeling-agent/scripts/ledger-mutation.mjs +82 -0
- package/skills/math-modeling-agent/scripts/migration-v3.mjs +31 -0
- package/skills/math-modeling-agent/scripts/output-integrity.mjs +82 -0
- package/skills/math-modeling-agent/scripts/paper-evidence.mjs +45 -0
- package/skills/math-modeling-agent/scripts/report-contract.mjs +112 -0
- package/skills/math-modeling-agent/scripts/run-state.mjs +829 -707
- package/skills/math-modeling-agent/scripts/verification-recipes.mjs +52 -0
- package/skills/math-modeling-agent/scripts/verification-runner.mjs +8 -0
- package/skills/math-modeling-audit/SKILL.md +41 -41
- package/skills/math-modeling-audit/references/mcm-icm-final-judge.md +335 -331
- package/skills/math-modeling-audit/scripts/mcm-score.mjs +293 -218
- package/skills/math-modeling-audit/scripts/paper-final-review.mjs +40 -0
- package/skills/math-modeling-audit/scripts/project-initial-review.mjs +41 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { dirname, join, basename } from 'node:path'
|
|
5
|
+
|
|
6
|
+
function hashText(text) { return createHash('sha256').update(text, 'utf8').digest('hex') }
|
|
7
|
+
function clone(value) { return JSON.parse(JSON.stringify(value)) }
|
|
8
|
+
function list(value) { return Array.isArray(value) ? value.filter(item => typeof item === 'string' && item.trim()) : [] }
|
|
9
|
+
|
|
10
|
+
async function readRun(root) { return JSON.parse(await readFile(join(root, 'run.json'), 'utf8')) }
|
|
11
|
+
async function readLedger(root) { return JSON.parse(await readFile(join(root, 'ledger.json'), 'utf8')) }
|
|
12
|
+
|
|
13
|
+
export async function applyFindingToLineage(lineageRoot, finding) {
|
|
14
|
+
const ledgerPath = join(lineageRoot, 'ledger.json')
|
|
15
|
+
const ledger = await readLedger(lineageRoot)
|
|
16
|
+
const reason = typeof finding.reason === 'string' && finding.reason.trim() ? finding.reason : 'evaluation finding requires correction'
|
|
17
|
+
const sourceIds = [finding.id].filter(Boolean)
|
|
18
|
+
const claimIds = list(finding.affectedClaims)
|
|
19
|
+
const obligationIds = list(finding.affectedObligations)
|
|
20
|
+
for (const claim of ledger.claims ?? []) {
|
|
21
|
+
if (claimIds.includes(claim.id)) {
|
|
22
|
+
claim.stale = true
|
|
23
|
+
claim.verdict = 'STALE'
|
|
24
|
+
claim.staleReason = reason
|
|
25
|
+
claim.staleSourceIds = sourceIds
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
for (const obligation of ledger.obligations ?? []) {
|
|
29
|
+
if (obligationIds.includes(obligation.id) || claimIds.includes(obligation.claimId)) {
|
|
30
|
+
obligation.status = 'STALE'
|
|
31
|
+
obligation.stale = true
|
|
32
|
+
obligation.staleReason = reason
|
|
33
|
+
obligation.staleSourceIds = sourceIds
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
await writeFile(ledgerPath, JSON.stringify(ledger, null, 2) + '\n', 'utf8')
|
|
37
|
+
return ledger
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function createCorrectionLineage(parentRunRoot, finding) {
|
|
41
|
+
if (!finding || typeof finding.id !== 'string' || !finding.id.trim()) throw new TypeError('finding.id is required')
|
|
42
|
+
const parentRun = await readRun(parentRunRoot)
|
|
43
|
+
const parentLedgerText = await readFile(join(parentRunRoot, 'ledger.json'), 'utf8')
|
|
44
|
+
const parentArtifactHash = hashText(JSON.stringify(parentRun) + '\n' + parentLedgerText)
|
|
45
|
+
const revision = (Number.isInteger(parentRun.revision) ? parentRun.revision : 0) + 1
|
|
46
|
+
const childRunRoot = await (async () => {
|
|
47
|
+
const { mkdtemp } = await import('node:fs/promises')
|
|
48
|
+
return mkdtemp(join(dirname(parentRunRoot), basename(parentRunRoot) + '-correction-'))
|
|
49
|
+
})()
|
|
50
|
+
await cp(parentRunRoot, childRunRoot, { recursive: true, filter: source => !source.endsWith('.run-state.lock') && !source.endsWith('.run-state.reclaim') })
|
|
51
|
+
const childRun = clone(parentRun)
|
|
52
|
+
childRun.schemaVersion = 3
|
|
53
|
+
childRun.lineageId = (parentRun.lineageId ?? parentRun.taskId) + '-correction-' + revision
|
|
54
|
+
childRun.parentRunId = parentRun.lineageId ?? parentRun.taskId
|
|
55
|
+
childRun.revision = revision
|
|
56
|
+
childRun.status = 'CORRECTION_REQUIRED'
|
|
57
|
+
childRun.updatedAt = new Date().toISOString()
|
|
58
|
+
await writeFile(join(childRunRoot, 'run.json'), JSON.stringify(childRun, null, 2) + '\n', 'utf8')
|
|
59
|
+
await applyFindingToLineage(childRunRoot, finding)
|
|
60
|
+
const lineage = { schemaVersion: 1, parentRunId: childRun.parentRunId, parentArtifactHash, findingIds: [finding.id], revision, status: 'CORRECTION_REQUIRED', childRunId: childRun.lineageId }
|
|
61
|
+
await writeFile(join(childRunRoot, 'lineage.json'), JSON.stringify(lineage, null, 2) + '\n', 'utf8')
|
|
62
|
+
return { childRunRoot, parentRunId: childRun.parentRunId, parentArtifactHash, revision, status: childRun.status, lineage }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function applyEvaluationFinding(parentRunRoot, finding) {
|
|
66
|
+
return createCorrectionLineage(parentRunRoot, finding)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function markSuperseded(parentRunRoot, childRunRoot) {
|
|
70
|
+
const childRun = await readRun(childRunRoot)
|
|
71
|
+
const lineagePath = join(childRunRoot, 'lineage.json')
|
|
72
|
+
let lineage
|
|
73
|
+
try { lineage = JSON.parse(await readFile(lineagePath, 'utf8')) } catch (error) { if (error.code !== 'ENOENT') throw error; lineage = { schemaVersion: 1, parentRunId: childRun.parentRunId ?? childRun.taskId, revision: childRun.revision ?? 1 } }
|
|
74
|
+
lineage.supersedes = parentRunRoot
|
|
75
|
+
lineage.status = 'SUPERSEDES_PARENT'
|
|
76
|
+
await writeFile(lineagePath, JSON.stringify(lineage, null, 2) + '\n', 'utf8')
|
|
77
|
+
return lineage
|
|
78
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
const EVIDENCE_REQUIRED_FIELDS = [
|
|
2
|
+
'id',
|
|
3
|
+
'kind',
|
|
4
|
+
'coveredClaimIds',
|
|
5
|
+
'coveredObligationIds',
|
|
6
|
+
'artifactPath',
|
|
7
|
+
'inputHashes',
|
|
8
|
+
'outputHashes',
|
|
9
|
+
'command',
|
|
10
|
+
'workdir',
|
|
11
|
+
'environment',
|
|
12
|
+
'exitCode',
|
|
13
|
+
'methodFamily',
|
|
14
|
+
'codeHash',
|
|
15
|
+
'level',
|
|
16
|
+
'limitations',
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
const EVIDENCE_STRING_FIELDS = [
|
|
20
|
+
'id',
|
|
21
|
+
'kind',
|
|
22
|
+
'artifactPath',
|
|
23
|
+
'command',
|
|
24
|
+
'workdir',
|
|
25
|
+
'methodFamily',
|
|
26
|
+
'codeHash',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const EVIDENCE_ARRAY_FIELDS = [
|
|
30
|
+
'coveredClaimIds',
|
|
31
|
+
'coveredObligationIds',
|
|
32
|
+
'limitations',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const EVIDENCE_NON_EMPTY_ARRAY_FIELDS = new Set([
|
|
36
|
+
'coveredClaimIds',
|
|
37
|
+
'coveredObligationIds',
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
const EVIDENCE_OBJECT_FIELDS = [
|
|
41
|
+
'inputHashes',
|
|
42
|
+
'outputHashes',
|
|
43
|
+
'environment',
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const EVIDENCE_NON_EMPTY_OBJECT_FIELDS = new Set(EVIDENCE_OBJECT_FIELDS)
|
|
47
|
+
|
|
48
|
+
const EVIDENCE_LEVELS = new Set([
|
|
49
|
+
'DERIVED',
|
|
50
|
+
'EXECUTED',
|
|
51
|
+
'VERIFIED',
|
|
52
|
+
'INDEPENDENTLY_VERIFIED',
|
|
53
|
+
'EXTERNALLY_VALIDATED',
|
|
54
|
+
'LEGACY_UNVERIFIED',
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
const VERIFICATION_REQUIRED_FIELDS = [
|
|
58
|
+
'id',
|
|
59
|
+
'recipeId',
|
|
60
|
+
'evidenceIds',
|
|
61
|
+
'verdict',
|
|
62
|
+
'independenceKey',
|
|
63
|
+
]
|
|
64
|
+
const VERIFICATION_ALLOWED_FIELDS = new Set([...VERIFICATION_REQUIRED_FIELDS, 'derivedFrom'])
|
|
65
|
+
const VERIFICATION_VERDICTS = new Set(['PASS', 'FAIL', 'INCONCLUSIVE', 'NOT_RUN'])
|
|
66
|
+
|
|
67
|
+
function isRecord(value) {
|
|
68
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasOwn(value, key) {
|
|
72
|
+
return Object.prototype.hasOwnProperty.call(value, key)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function result(errors) {
|
|
76
|
+
return { valid: errors.length === 0, errors }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function addRequiredErrors(value, fields, errors) {
|
|
80
|
+
for (const field of fields) {
|
|
81
|
+
if (!hasOwn(value, field)) {
|
|
82
|
+
errors.push(field + ' is required')
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function addUnknownFieldErrors(value, allowedFields, errors) {
|
|
88
|
+
for (const field of Object.keys(value)) {
|
|
89
|
+
if (!allowedFields.has(field)) {
|
|
90
|
+
errors.push('unknown top-level field: ' + field)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function addNonEmptyStringErrors(value, fields, errors) {
|
|
96
|
+
for (const field of fields) {
|
|
97
|
+
if (hasOwn(value, field) && (typeof value[field] !== 'string' || value[field].trim() === '')) {
|
|
98
|
+
errors.push(field + ' must be a nonempty string')
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function addStringArrayErrors(value, fields, errors) {
|
|
104
|
+
for (const field of fields) {
|
|
105
|
+
if (!hasOwn(value, field)) continue
|
|
106
|
+
const current = value[field]
|
|
107
|
+
if (!Array.isArray(current)) {
|
|
108
|
+
errors.push(field + ' must be an array of strings')
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (EVIDENCE_NON_EMPTY_ARRAY_FIELDS.has(field) && current.length === 0) {
|
|
112
|
+
errors.push(field + ' must not be empty')
|
|
113
|
+
}
|
|
114
|
+
if (current.some(item => typeof item !== 'string' || item.trim() === '')) {
|
|
115
|
+
errors.push(field + ' must contain only nonempty strings')
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function addObjectErrors(value, fields, errors) {
|
|
121
|
+
for (const field of fields) {
|
|
122
|
+
if (hasOwn(value, field) && !isRecord(value[field])) {
|
|
123
|
+
errors.push(field + ' must be an object')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function addHashMapErrors(value, fields, errors) {
|
|
129
|
+
for (const field of fields) {
|
|
130
|
+
if (!hasOwn(value, field) || !isRecord(value[field])) continue
|
|
131
|
+
if (EVIDENCE_NON_EMPTY_OBJECT_FIELDS.has(field) && Object.keys(value[field]).length === 0) {
|
|
132
|
+
errors.push(field + ' must not be empty')
|
|
133
|
+
}
|
|
134
|
+
for (const [key, hash] of Object.entries(value[field])) {
|
|
135
|
+
if (typeof hash !== 'string' || hash.trim() === '') {
|
|
136
|
+
errors.push(field + '.' + key + ' must be a nonempty string')
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeMarker(value) {
|
|
143
|
+
return value
|
|
144
|
+
.toLowerCase()
|
|
145
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
146
|
+
.trim()
|
|
147
|
+
.replace(/\s+/g, ' ')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isNonIndependentMarker(value) {
|
|
151
|
+
if (typeof value !== 'string' || value.trim() === '') return false
|
|
152
|
+
const marker = normalizeMarker(value)
|
|
153
|
+
return (
|
|
154
|
+
marker.includes('solver summary') ||
|
|
155
|
+
marker.includes('same summary') ||
|
|
156
|
+
marker.includes('deterministic result') ||
|
|
157
|
+
marker.includes('same result') ||
|
|
158
|
+
marker.includes('reprint')
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function stringsIn(value) {
|
|
163
|
+
if (typeof value === 'string') return [value]
|
|
164
|
+
if (Array.isArray(value)) return value.flatMap(stringsIn)
|
|
165
|
+
if (isRecord(value)) return Object.values(value).flatMap(stringsIn)
|
|
166
|
+
return []
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function addIndependenceErrors(value, field, errors) {
|
|
170
|
+
if (!hasOwn(value, field)) return
|
|
171
|
+
if (stringsIn(value[field]).some(isNonIndependentMarker)) {
|
|
172
|
+
errors.push(field + ' must identify an independent check, not a solver summary, deterministic result, or reprint-only check')
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function validateEvidence(value) {
|
|
177
|
+
const errors = []
|
|
178
|
+
if (!isRecord(value)) return result(['evidence must be an object'])
|
|
179
|
+
|
|
180
|
+
addRequiredErrors(value, EVIDENCE_REQUIRED_FIELDS, errors)
|
|
181
|
+
addUnknownFieldErrors(value, new Set(EVIDENCE_REQUIRED_FIELDS), errors)
|
|
182
|
+
addNonEmptyStringErrors(value, EVIDENCE_STRING_FIELDS, errors)
|
|
183
|
+
addStringArrayErrors(value, EVIDENCE_ARRAY_FIELDS, errors)
|
|
184
|
+
addObjectErrors(value, EVIDENCE_OBJECT_FIELDS, errors)
|
|
185
|
+
addHashMapErrors(value, ['inputHashes', 'outputHashes'], errors)
|
|
186
|
+
if (hasOwn(value, 'environment') && isRecord(value.environment) && Object.keys(value.environment).length === 0) {
|
|
187
|
+
errors.push('environment must not be empty')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (hasOwn(value, 'exitCode') && !Number.isInteger(value.exitCode)) {
|
|
191
|
+
errors.push('exitCode must be an integer')
|
|
192
|
+
}
|
|
193
|
+
if (hasOwn(value, 'level') && !EVIDENCE_LEVELS.has(value.level)) {
|
|
194
|
+
errors.push('level must be one of ' + [...EVIDENCE_LEVELS].join(', '))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return result(errors)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function validateVerification(value) {
|
|
201
|
+
const errors = []
|
|
202
|
+
if (!isRecord(value)) return result(['verification must be an object'])
|
|
203
|
+
|
|
204
|
+
addRequiredErrors(value, VERIFICATION_REQUIRED_FIELDS, errors)
|
|
205
|
+
addUnknownFieldErrors(value, VERIFICATION_ALLOWED_FIELDS, errors)
|
|
206
|
+
addNonEmptyStringErrors(value, ['id', 'recipeId', 'independenceKey'], errors)
|
|
207
|
+
addStringArrayErrors(value, ['evidenceIds'], errors)
|
|
208
|
+
|
|
209
|
+
if (hasOwn(value, 'evidenceIds') && Array.isArray(value.evidenceIds) && value.evidenceIds.length === 0) {
|
|
210
|
+
errors.push('evidenceIds must not be empty')
|
|
211
|
+
}
|
|
212
|
+
if (hasOwn(value, 'verdict') && !VERIFICATION_VERDICTS.has(value.verdict)) {
|
|
213
|
+
errors.push('verdict must be one of ' + [...VERIFICATION_VERDICTS].join(', '))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (hasOwn(value, 'derivedFrom')) {
|
|
217
|
+
const derivedFrom = value.derivedFrom
|
|
218
|
+
const validDerivedFrom =
|
|
219
|
+
(typeof derivedFrom === 'string' && derivedFrom.trim() !== '') ||
|
|
220
|
+
(Array.isArray(derivedFrom) && derivedFrom.length > 0 && derivedFrom.every(item => typeof item === 'string' && item.trim() !== ''))
|
|
221
|
+
if (!validDerivedFrom) {
|
|
222
|
+
errors.push('derivedFrom must be a nonempty string or string array')
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
addIndependenceErrors(value, 'independenceKey', errors)
|
|
227
|
+
addIndependenceErrors(value, 'derivedFrom', errors)
|
|
228
|
+
return result(errors)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function graphRecord(value, label) {
|
|
232
|
+
if (!isRecord(value)) throw new TypeError(label + ' must be an object')
|
|
233
|
+
if (typeof value.id !== 'string' || value.id.trim() === '') throw new TypeError(label + '.id must be a nonempty string')
|
|
234
|
+
return value
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function graphCollection(graph, name) {
|
|
238
|
+
if (!isRecord(graph) || !Array.isArray(graph[name])) throw new TypeError('graph.' + name + ' must be an array')
|
|
239
|
+
return graph[name]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function assertUnique(collection, id, label) {
|
|
243
|
+
if (collection.some(item => item?.id === id)) throw new TypeError('duplicate ' + label + ' id: ' + id)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function assertKnown(collection, id, label) {
|
|
247
|
+
if (!collection.some(item => item?.id === id)) throw new TypeError('unknown ' + label + ' id: ' + id)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function assertStringIds(value, field) {
|
|
251
|
+
if (value === undefined) return []
|
|
252
|
+
if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string' || item.trim() === '')) throw new TypeError(field + ' must be a nonempty string array')
|
|
253
|
+
return value
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function createGraph(taskId) {
|
|
257
|
+
if (typeof taskId !== 'string' || taskId.trim() === '') throw new TypeError('taskId must be a nonempty string')
|
|
258
|
+
return {
|
|
259
|
+
schemaVersion: 3,
|
|
260
|
+
taskId,
|
|
261
|
+
scope: { independentAuditPassed: false, interactions: [], decisionStack: [], robustnessExempt: false },
|
|
262
|
+
requirements: [],
|
|
263
|
+
assumptions: [],
|
|
264
|
+
claims: [],
|
|
265
|
+
obligations: [],
|
|
266
|
+
subproblems: [],
|
|
267
|
+
candidates: [],
|
|
268
|
+
issues: [],
|
|
269
|
+
evidence: [],
|
|
270
|
+
verifications: [],
|
|
271
|
+
failures: [],
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function addRequirement(graph, value) {
|
|
276
|
+
const item = graphRecord(value, 'requirement')
|
|
277
|
+
const requirements = graphCollection(graph, 'requirements')
|
|
278
|
+
assertUnique(requirements, item.id, 'requirement')
|
|
279
|
+
requirements.push(item)
|
|
280
|
+
return item.id
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function addClaim(graph, value) {
|
|
284
|
+
const item = graphRecord(value, 'claim')
|
|
285
|
+
const claims = graphCollection(graph, 'claims')
|
|
286
|
+
assertUnique(claims, item.id, 'claim')
|
|
287
|
+
const requirementIds = value.requirementIds ?? []
|
|
288
|
+
if (!Array.isArray(requirementIds) || requirementIds.some(id => typeof id !== 'string' || id.trim() === '')) throw new TypeError('claim.requirementIds must be a string array')
|
|
289
|
+
for (const id of requirementIds) assertKnown(graphCollection(graph, 'requirements'), id, 'requirement')
|
|
290
|
+
item.requirementIds = requirementIds
|
|
291
|
+
item.obligationIds = value.obligationIds ?? []
|
|
292
|
+
item.evidenceIds = value.evidenceIds ?? []
|
|
293
|
+
claims.push(item)
|
|
294
|
+
return item.id
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function addObligation(graph, value) {
|
|
298
|
+
const item = graphRecord(value, 'obligation')
|
|
299
|
+
const obligations = graphCollection(graph, 'obligations')
|
|
300
|
+
assertUnique(obligations, item.id, 'obligation')
|
|
301
|
+
assertKnown(graphCollection(graph, 'claims'), item.claimId, 'claim')
|
|
302
|
+
item.evidenceIds = value.evidenceIds ?? []
|
|
303
|
+
obligations.push(item)
|
|
304
|
+
return item.id
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function addEvidence(graph, value) {
|
|
308
|
+
const item = graphRecord(value, 'evidence')
|
|
309
|
+
const validation = validateEvidence(item)
|
|
310
|
+
if (!validation.valid) throw new TypeError(validation.errors.join('; '))
|
|
311
|
+
const evidence = graphCollection(graph, 'evidence')
|
|
312
|
+
assertUnique(evidence, item.id, 'evidence')
|
|
313
|
+
for (const id of item.coveredClaimIds) assertKnown(graphCollection(graph, 'claims'), id, 'claim')
|
|
314
|
+
for (const id of item.coveredObligationIds) assertKnown(graphCollection(graph, 'obligations'), id, 'obligation')
|
|
315
|
+
evidence.push(item)
|
|
316
|
+
for (const claim of graphCollection(graph, 'claims')) {
|
|
317
|
+
if (item.coveredClaimIds.includes(claim.id) && !claim.evidenceIds.includes(item.id)) claim.evidenceIds.push(item.id)
|
|
318
|
+
}
|
|
319
|
+
for (const obligation of graphCollection(graph, 'obligations')) {
|
|
320
|
+
if (item.coveredObligationIds.includes(obligation.id) && !obligation.evidenceIds.includes(item.id)) obligation.evidenceIds.push(item.id)
|
|
321
|
+
}
|
|
322
|
+
return item.id
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function markStale(graph, id, { staleReason, staleSourceIds } = {}) {
|
|
326
|
+
if (typeof staleReason !== 'string' || staleReason.trim() === '') throw new TypeError('staleReason is required')
|
|
327
|
+
const sourceIds = assertStringIds(staleSourceIds, 'staleSourceIds')
|
|
328
|
+
const claims = graphCollection(graph, 'claims')
|
|
329
|
+
const obligations = graphCollection(graph, 'obligations')
|
|
330
|
+
const claim = claims.find(item => item?.id === id)
|
|
331
|
+
const obligation = obligations.find(item => item?.id === id)
|
|
332
|
+
if (!claim && !obligation) throw new TypeError('unknown claim or obligation id: ' + id)
|
|
333
|
+
const mark = item => {
|
|
334
|
+
item.stale = true
|
|
335
|
+
item.staleReason = staleReason
|
|
336
|
+
item.staleSourceIds = [...sourceIds]
|
|
337
|
+
}
|
|
338
|
+
if (claim) {
|
|
339
|
+
mark(claim)
|
|
340
|
+
claim.verdict = 'STALE'
|
|
341
|
+
for (const item of obligations.filter(entry => entry?.claimId === id)) { mark(item); item.status = 'STALE' }
|
|
342
|
+
} else {
|
|
343
|
+
mark(obligation)
|
|
344
|
+
obligation.status = 'STALE'
|
|
345
|
+
const owner = claims.find(item => item?.id === obligation.claimId)
|
|
346
|
+
if (owner) { mark(owner); owner.verdict = 'STALE' }
|
|
347
|
+
}
|
|
348
|
+
return id
|
|
349
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const REQUIRED_FIELDS = [
|
|
6
|
+
'failureId', 'stage', 'subproblem', 'attempt', 'rootCause', 'candidateId', 'assumptionIds', 'symptom',
|
|
7
|
+
'minimalReproduction', 'command', 'inputHashes', 'codeHash', 'exitCode', 'affectedClaims', 'affectedObligations',
|
|
8
|
+
'whatWasRuledOut', 'scientificInsight', 'recovery', 'status', 'evidenceIds',
|
|
9
|
+
]
|
|
10
|
+
const ROOT_CAUSES = new Set(['DATA', 'TOOL', 'MODEL', 'ASSUMPTION', 'ANALYSIS', 'VALIDATION', 'EVIDENCE', 'RESEARCH_GAP'])
|
|
11
|
+
const SCIENTIFIC_FAILURES = new Set(['MODEL', 'ASSUMPTION', 'ANALYSIS', 'VALIDATION', 'EVIDENCE'])
|
|
12
|
+
|
|
13
|
+
function nonempty(value) { return typeof value === 'string' && value.trim() !== '' }
|
|
14
|
+
function stringArray(value, allowEmpty = true) { return Array.isArray(value) && (allowEmpty || value.length > 0) && value.every(item => nonempty(item)) }
|
|
15
|
+
|
|
16
|
+
export function validateFailure(value) {
|
|
17
|
+
const errors = []
|
|
18
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return { valid: false, errors: ['failure must be an object'] }
|
|
19
|
+
for (const field of REQUIRED_FIELDS) if (!Object.hasOwn(value, field)) errors.push(field + ' is required')
|
|
20
|
+
for (const field of ['failureId', 'stage', 'subproblem', 'candidateId', 'symptom', 'minimalReproduction', 'command', 'codeHash', 'scientificInsight', 'recovery']) if (Object.hasOwn(value, field) && !nonempty(value[field])) errors.push(field + ' must be nonempty')
|
|
21
|
+
if (Object.hasOwn(value, 'attempt') && (!Number.isInteger(value.attempt) || value.attempt < 1)) errors.push('attempt must be a positive integer')
|
|
22
|
+
if (Object.hasOwn(value, 'rootCause') && !ROOT_CAUSES.has(value.rootCause)) errors.push('rootCause is invalid')
|
|
23
|
+
for (const field of ['assumptionIds', 'affectedClaims', 'affectedObligations', 'evidenceIds']) if (Object.hasOwn(value, field) && !stringArray(value[field])) errors.push(field + ' must be an array of strings')
|
|
24
|
+
if (Object.hasOwn(value, 'whatWasRuledOut') && !stringArray(value.whatWasRuledOut, false) && SCIENTIFIC_FAILURES.has(value.rootCause)) errors.push('whatWasRuledOut must be a nonempty string array')
|
|
25
|
+
if (Object.hasOwn(value, 'inputHashes') && (!value.inputHashes || typeof value.inputHashes !== 'object' || Array.isArray(value.inputHashes) || Object.keys(value.inputHashes).length === 0)) errors.push('inputHashes must be a nonempty object')
|
|
26
|
+
if (Object.hasOwn(value, 'exitCode') && !Number.isInteger(value.exitCode)) errors.push('exitCode must be an integer')
|
|
27
|
+
if (Object.hasOwn(value, 'status') && !['OPEN', 'RECOVERED', 'ABANDONED', 'STALE'].includes(value.status)) errors.push('status is invalid')
|
|
28
|
+
if (SCIENTIFIC_FAILURES.has(value.rootCause)) {
|
|
29
|
+
if (!stringArray(value.whatWasRuledOut, false)) errors.push('scientific failure requires whatWasRuledOut')
|
|
30
|
+
if (!nonempty(value.scientificInsight)) errors.push('scientific failure requires scientificInsight')
|
|
31
|
+
}
|
|
32
|
+
return { valid: errors.length === 0, errors: [...new Set(errors)] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function appendFailure(runRoot, failure) {
|
|
36
|
+
const result = validateFailure(failure)
|
|
37
|
+
if (!result.valid) throw new TypeError(result.errors.join('; '))
|
|
38
|
+
const path = join(runRoot, 'failed', 'failures.jsonl')
|
|
39
|
+
await mkdir(join(runRoot, 'failed'), { recursive: true })
|
|
40
|
+
await appendFile(path, JSON.stringify(failure) + '\n', 'utf8')
|
|
41
|
+
return { path, failureId: failure.failureId }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function renderFailureInsight(failure) {
|
|
45
|
+
const ruledOut = (failure.whatWasRuledOut ?? []).map(value => '- ' + value).join('\n') || '- 未记录'
|
|
46
|
+
return [
|
|
47
|
+
'## ' + failure.failureId,
|
|
48
|
+
'',
|
|
49
|
+
'- 环节:' + failure.stage + ';子问题:' + failure.subproblem + ';attempt:' + failure.attempt,
|
|
50
|
+
'- 根因:' + failure.rootCause,
|
|
51
|
+
'- 症状:' + failure.symptom,
|
|
52
|
+
'',
|
|
53
|
+
'### 最小复现',
|
|
54
|
+
failure.minimalReproduction,
|
|
55
|
+
'',
|
|
56
|
+
'### 已排除',
|
|
57
|
+
ruledOut,
|
|
58
|
+
'',
|
|
59
|
+
'### 科学启发',
|
|
60
|
+
failure.scientificInsight,
|
|
61
|
+
'',
|
|
62
|
+
'### 修正与恢复',
|
|
63
|
+
failure.recovery,
|
|
64
|
+
'',
|
|
65
|
+
'下游影响:claims=' + (failure.affectedClaims ?? []).join(', ') + '; obligations=' + (failure.affectedObligations ?? []).join(', ') + '.',
|
|
66
|
+
'证据:' + (failure.evidenceIds ?? []).join(', ') + '.',
|
|
67
|
+
].join('\n')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function buildFailureInsights(runRoot) {
|
|
71
|
+
const path = join(runRoot, 'failed', 'failures.jsonl')
|
|
72
|
+
let text = ''
|
|
73
|
+
try { text = await readFile(path, 'utf8') } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
74
|
+
const failures = text.trim() ? text.trim().split('\n').map(JSON.parse) : []
|
|
75
|
+
const output = failures.length ? '# Failure Insights\n\n' + failures.map(renderFailureInsight).join('\n\n') + '\n' : '# Failure Insights\n\nNo structured failures recorded.\n'
|
|
76
|
+
const outputPath = join(runRoot, 'failure-insights.md')
|
|
77
|
+
await writeFile(outputPath, output, 'utf8')
|
|
78
|
+
return { count: failures.length, path: outputPath }
|
|
79
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { chmod, copyFile, lstat, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const MANIFEST_PATH = ['inputs', 'manifest.json']
|
|
7
|
+
|
|
8
|
+
function hashBytes(bytes) {
|
|
9
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isContained(root, target) {
|
|
13
|
+
const value = relative(root, target)
|
|
14
|
+
return value === '' || (!value.startsWith('..' + sep) && value !== '..' && !isAbsolute(value))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function assertRelativePath(value) {
|
|
18
|
+
if (typeof value !== 'string' || value.trim() === '' || isAbsolute(value)) throw new TypeError('input path must be a nonempty relative path')
|
|
19
|
+
const normalized = value.replaceAll('\\', '/')
|
|
20
|
+
if (normalized.split('/').some(part => part === '..')) throw new TypeError('input path must remain contained')
|
|
21
|
+
return normalized
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function aggregateHash(files) {
|
|
25
|
+
const material = files
|
|
26
|
+
.slice()
|
|
27
|
+
.sort((left, right) => left.relativePath.localeCompare(right.relativePath))
|
|
28
|
+
.map(file => file.relativePath + '\0' + file.snapshotHash)
|
|
29
|
+
.join('\n')
|
|
30
|
+
return hashBytes(Buffer.from(material, 'utf8'))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readManifest(runRoot) {
|
|
34
|
+
const path = resolve(runRoot, ...MANIFEST_PATH)
|
|
35
|
+
return { path, manifest: JSON.parse(await readFile(path, 'utf8')) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function snapshotInputs(sourceRoot, runRoot, relativeFiles) {
|
|
39
|
+
if (!Array.isArray(relativeFiles) || relativeFiles.length === 0) throw new TypeError('relativeFiles must be a nonempty array')
|
|
40
|
+
const sourceBase = resolve(sourceRoot)
|
|
41
|
+
const runBase = resolve(runRoot)
|
|
42
|
+
const files = []
|
|
43
|
+
for (const input of relativeFiles) {
|
|
44
|
+
const relativePath = assertRelativePath(input)
|
|
45
|
+
const sourcePath = resolve(sourceBase, relativePath)
|
|
46
|
+
const snapshotPath = resolve(runBase, 'inputs', 'raw', relativePath)
|
|
47
|
+
if (!isContained(sourceBase, sourcePath) || !isContained(resolve(runBase, 'inputs', 'raw'), snapshotPath)) throw new TypeError('input path must remain contained')
|
|
48
|
+
const info = await lstat(sourcePath)
|
|
49
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new TypeError('input must be a regular file: ' + relativePath)
|
|
50
|
+
const bytes = await readFile(sourcePath)
|
|
51
|
+
await mkdir(dirname(snapshotPath), { recursive: true })
|
|
52
|
+
await copyFile(sourcePath, snapshotPath)
|
|
53
|
+
await chmod(snapshotPath, 0o444)
|
|
54
|
+
const sourceHash = hashBytes(bytes)
|
|
55
|
+
const snapshotHash = hashBytes(await readFile(snapshotPath))
|
|
56
|
+
files.push({ relativePath, sourcePath, snapshotPath: relative(runBase, snapshotPath).replaceAll('\\', '/'), sourceHash, snapshotHash, bytes: bytes.length, readOnly: true })
|
|
57
|
+
}
|
|
58
|
+
const manifest = { schemaVersion: 1, sourceRoot: sourceBase, createdAt: new Date().toISOString(), files, inputSnapshotHash: aggregateHash(files) }
|
|
59
|
+
const manifestPath = resolve(runBase, ...MANIFEST_PATH)
|
|
60
|
+
await mkdir(dirname(manifestPath), { recursive: true })
|
|
61
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
62
|
+
return manifest
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function verifySnapshot(runRoot) {
|
|
66
|
+
const { manifest } = await readManifest(runRoot)
|
|
67
|
+
const runBase = resolve(runRoot)
|
|
68
|
+
let snapshotChanged = false
|
|
69
|
+
let sourceChanged = false
|
|
70
|
+
let sourceUnknown = false
|
|
71
|
+
const files = []
|
|
72
|
+
for (const file of manifest.files) {
|
|
73
|
+
const snapshotPath = resolve(runBase, file.snapshotPath)
|
|
74
|
+
let snapshotHash = null
|
|
75
|
+
try { snapshotHash = hashBytes(await readFile(snapshotPath)) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
76
|
+
const snapshotMismatch = snapshotHash !== file.snapshotHash
|
|
77
|
+
snapshotChanged ||= snapshotMismatch
|
|
78
|
+
let currentSourceHash = null
|
|
79
|
+
try { currentSourceHash = hashBytes(await readFile(file.sourcePath)) } catch (error) { if (error.code === 'ENOENT') sourceUnknown = true; else throw error }
|
|
80
|
+
const sourceMismatch = currentSourceHash !== null && currentSourceHash !== file.sourceHash
|
|
81
|
+
sourceChanged ||= sourceMismatch
|
|
82
|
+
files.push({ ...file, currentSnapshotHash: snapshotHash, currentSourceHash, snapshotMismatch, sourceMismatch })
|
|
83
|
+
}
|
|
84
|
+
const aggregate = aggregateHash(files.map(file => ({ relativePath: file.relativePath, snapshotHash: file.currentSnapshotHash ?? '' })))
|
|
85
|
+
const aggregateMismatch = aggregate !== manifest.inputSnapshotHash
|
|
86
|
+
return { valid: !snapshotChanged && !aggregateMismatch, snapshotChanged, sourceChanged, sourceUnknown, aggregateMismatch, files }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function assertSnapshotReadOnly(runRoot) {
|
|
90
|
+
const result = await verifySnapshot(runRoot)
|
|
91
|
+
const writable = []
|
|
92
|
+
for (const file of result.files) {
|
|
93
|
+
const path = resolve(runRoot, file.snapshotPath)
|
|
94
|
+
const info = await stat(path)
|
|
95
|
+
if (!file.readOnly || (info.mode & 0o200) !== 0) writable.push(file.relativePath)
|
|
96
|
+
}
|
|
97
|
+
return { valid: result.valid && writable.length === 0, writable, snapshot: result }
|
|
98
|
+
}
|