Files
Web_FreeCAD_Bitbybit/scripts/run-freecad-isomorphic-provenance.ts
wangdequan f64b78865c
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled
feat: complete production naming and reference lifecycle gates
2026-08-14 10:10:30 -04:00

224 lines
16 KiB
TypeScript

import { createHash } from 'node:crypto'
import { readFile, stat, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { inspectFcstdArchive, rewriteFcstdMetadataArchive, serializeFcstdMetadataArchive } from '../src/facade/fcstd'
import { DirectNativeOcctHistoryProvider, NATIVE_OCCT_HISTORY_PROTOCOL_VERSION } from '../src/facade/nativeHistoryProtocol'
import type { NativeStageNamingEvidence } from '../src/facade/nativeNamingEvidence'
import type { NativeOcctHistoryOperation, NativeOcctHistoryResponse } from '../src/facade/nativeHistoryProvider'
import type { DocumentSnapshot, TopoRefValue } from '../src/facade/types'
const root = resolve(new URL('..', import.meta.url).pathname)
const dist = resolve(root, 'native/occt-history/dist')
const modulePath = resolve(dist, 'bitbybit-occt-history.js')
const reportPath = resolve(root, 'config/freecad-isomorphic-provenance-verification.json')
const artifactNames = ['bitbybit-occt-history.js', 'bitbybit-occt-history.wasm', 'bitbybit-occt-history.data']
const fail = (message: string): never => { throw new Error(`FreeCAD isomorphic provenance verification failed: ${message}`) }
const createModule = (await import(pathToFileURL(modulePath).href)).default
const module = await createModule({ locateFile: (path: string) => resolve(dist, path) })
const provider = new DirectNativeOcctHistoryProvider(module)
const shapeStep = (method: string, ...args: number[]) => {
const shape = module[method](...args)
try { return module.shapeToStep(shape) as string }
finally { shape.delete?.() }
}
const namingCounts = (evidence: NativeStageNamingEvidence) => ({
status: evidence.status,
stable: evidence.mappedNames?.filter(({ relation }) => relation !== 'ambiguous').length ?? 0,
ambiguous: evidence.mappedNames?.filter(({ relation }) => relation === 'ambiguous').length ?? 0,
candidates: evidence.mappedNames?.filter(({ relation }) => relation === 'ambiguous').reduce((count, mapped) => count + (mapped.candidates?.length ?? 0), 0) ?? 0,
stringHasherEntries: evidence.stringHasher ? ('entries' in evidence.stringHasher ? evidence.stringHasher.entries.length : 0) : 0,
elementMaps: evidence.elementMap2 ? ('maps' in evidence.elementMap2 ? evidence.elementMap2.maps.length : 0) : 0,
})
const referencedUpstreamCandidateObjects = (records: NativeOcctHistoryResponse['records'], evidence: NativeStageNamingEvidence) => {
const objects = new Set<string>()
for (const record of records) {
if (record.relation === 'deleted' || !['object', 'tool'].includes(record.sourceId ?? record.source)) continue
const mapped = evidence.mappedNames?.find((entry) => entry.kind === record.kind && entry.resultIndex === record.sourceIndex)
for (const candidate of mapped?.candidates ?? []) objects.add(candidate.objectId)
}
return objects
}
const commonObjectStep = shapeStep('makeBox', 10, 10, 10)
const commonToolStep = shapeStep('makeBox', 10, 10, 10)
const common = await provider.capture({
protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION,
requestId: 'isomorphic-common', documentId: 'isomorphic-doc', documentVersion: 1,
operationId: 'common', resultObjectId: 'common-result', resultObjectTag: 401, operation: 'common',
objectStep: commonObjectStep, toolStep: commonToolStep,
inputs: [
{ inputId: 'object', objectId: 'symmetric-object', role: 'object', stageId: 'source-object', objectTag: 301, step: commonObjectStep },
{ inputId: 'tool', objectId: 'symmetric-tool', role: 'tool', stageId: 'source-tool', objectTag: 302, step: commonToolStep },
],
stages: [{ stageId: 'common-stage', operation: 'common', inputIds: ['object', 'tool'], ordinal: 0 }],
}, new AbortController().signal)
const commonEvidence = common.history.namingEvidence ?? fail('symmetric Common omitted naming evidence')
const commonCounts = namingCounts(commonEvidence)
if (commonCounts.status !== 'ambiguous' || commonCounts.stable !== 0 || commonCounts.ambiguous < 1 || commonCounts.candidates < commonCounts.ambiguous * 2) fail('symmetric Common did not retain all native candidates')
const persistedCommon = JSON.parse(JSON.stringify(commonEvidence)) as NativeStageNamingEvidence
if (JSON.stringify(persistedCommon.mappedNames) !== JSON.stringify(commonEvidence.mappedNames)) fail('candidate sets changed across JSON persistence')
const cutObjectStep = shapeStep('makeBox', 10, 10, 10)
const cutToolStep = shapeStep('makeBox', 5, 5, 5)
const cut = await provider.capture({
protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION,
requestId: 'mixed-cut', documentId: 'isomorphic-doc', documentVersion: 2,
operationId: 'cut', resultObjectId: 'cut-result', resultObjectTag: 501, operation: 'cut',
objectStep: cutObjectStep, toolStep: cutToolStep,
inputs: [
{ inputId: 'object', objectId: 'cut-object', role: 'object', stageId: 'cut-object-source', objectTag: 311, step: cutObjectStep },
{ inputId: 'tool', objectId: 'cut-tool', role: 'tool', stageId: 'cut-tool-source', objectTag: 312, step: cutToolStep },
],
stages: [{ stageId: 'cut-stage', operation: 'cut', inputIds: ['object', 'tool'], ordinal: 0 }],
}, new AbortController().signal)
const cutEvidence = cut.history.namingEvidence ?? fail('mixed Cut omitted naming evidence')
const cutCounts = namingCounts(cutEvidence)
if (cutCounts.status !== 'ambiguous' || cutCounts.stable < 1 || cutCounts.ambiguous < 1 || cutCounts.elementMaps < 1) fail('mixed Cut did not combine private names with explicit ambiguity')
const cutResultStep = cut.history.resultStep ?? fail('mixed Cut omitted result STEP')
const downstream = await provider.capture({
protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION,
requestId: 'downstream-rotate', documentId: 'isomorphic-doc', documentVersion: 3,
operationId: 'rotate', resultObjectId: 'rotate-result', resultObjectTag: 601, operation: 'rotate',
objectStep: cutResultStep, axisOrigin: [0, 0, 0], direction: [0, 0, 1], angle: 15,
inputs: [{ inputId: 'object', objectId: 'cut-result', role: 'object', stageId: 'cut-stage', objectTag: 501, step: cutResultStep, namingEvidence: JSON.parse(JSON.stringify(cutEvidence)) as NativeStageNamingEvidence }],
stages: [{ stageId: 'rotate-stage', operation: 'rotate', inputIds: ['object'], ordinal: 0 }],
}, new AbortController().signal)
const downstreamEvidence = downstream.history.namingEvidence ?? fail('downstream Rotate omitted naming evidence')
const downstreamCounts = namingCounts(downstreamEvidence)
const downstreamCandidateObjects = new Set(downstreamEvidence.mappedNames?.flatMap(({ candidates }) => candidates?.map(({ objectId }) => objectId) ?? []) ?? [])
const downstreamReferencedCandidateObjects = referencedUpstreamCandidateObjects(downstream.history.records, cutEvidence)
if (downstreamCounts.status !== 'ambiguous' || downstreamCounts.ambiguous < 1 || !downstreamCandidateObjects.has('cut-object') || !downstreamCandidateObjects.has('cut-tool')) fail('downstream feature collapsed upstream ambiguity')
if ([...downstreamReferencedCandidateObjects].some((objectId) => !downstreamCandidateObjects.has(objectId))) fail('downstream feature lost a referenced upstream candidate')
type ChainSource = { objectId: string; objectTag: number; stageId: string; step: string; evidence: NativeStageNamingEvidence }
type ChainDefinition = { operation: NativeOcctHistoryOperation; parameters: Record<string, unknown> }
const chainStages = [{
operation: 'cut',
historyRecords: cut.history.records.length,
...cutCounts,
candidateObjects: [...new Set(cutEvidence.mappedNames?.flatMap(({ candidates }) => candidates?.map(({ objectId }) => objectId) ?? []) ?? [])].sort(),
persistedInputEvidence: false,
}, {
operation: 'rotate',
historyRecords: downstream.history.records.length,
...downstreamCounts,
candidateObjects: [...downstreamCandidateObjects].sort(),
referencedInputCandidateObjects: [...downstreamReferencedCandidateObjects].sort(),
referencedCandidateObjectsRetained: true,
persistedInputEvidence: true,
}]
const chainResponses = [cut, downstream]
let chainSource: ChainSource = {
objectId: 'rotate-result', objectTag: 601, stageId: 'rotate-stage',
step: downstream.history.resultStep ?? fail('downstream Rotate omitted result STEP'), evidence: downstreamEvidence,
}
let previousHasherEntries = downstreamCounts.stringHasherEntries
const chainDefinitions: ChainDefinition[] = [
{ operation: 'fillet', parameters: { radius: 0.2 } },
{ operation: 'mirrored', parameters: { axisOrigin: [0, 0, 0], direction: [1, 0, 0] } },
{ operation: 'linear-pattern', parameters: { direction: [3, 0, 0] } },
]
for (const [index, definition] of chainDefinitions.entries()) {
const documentVersion = index + 4
const stageId = `${definition.operation}-stage`
const resultObjectId = `${definition.operation}-result`
const resultObjectTag = 701 + index
const persistedInputEvidence = JSON.parse(JSON.stringify(chainSource.evidence)) as NativeStageNamingEvidence
const response = await provider.capture({
protocolVersion: NATIVE_OCCT_HISTORY_PROTOCOL_VERSION,
requestId: `long-chain-${definition.operation}`, documentId: 'isomorphic-doc', documentVersion,
operationId: definition.operation, resultObjectId, resultObjectTag, operation: definition.operation,
objectStep: chainSource.step,
inputs: [{ inputId: 'object', objectId: chainSource.objectId, role: 'object', stageId: chainSource.stageId, objectTag: chainSource.objectTag, step: chainSource.step, namingEvidence: persistedInputEvidence }],
stages: [{ stageId, operation: definition.operation, inputIds: ['object'], ordinal: 0 }],
...definition.parameters,
} as Parameters<typeof provider.capture>[0], new AbortController().signal)
const evidence = response.history.namingEvidence ?? fail(`${definition.operation} omitted naming evidence`)
const counts = namingCounts(evidence)
const candidateObjects = new Set(evidence.mappedNames?.flatMap(({ candidates }) => candidates?.map(({ objectId }) => objectId) ?? []) ?? [])
const referencedInputCandidateObjects = referencedUpstreamCandidateObjects(response.history.records, persistedInputEvidence)
if (counts.status !== 'ambiguous' || counts.stable < 1 || counts.ambiguous < 1 || counts.elementMaps < 1) fail(`${definition.operation} did not preserve mixed stable/ambiguous naming evidence`)
if ([...referencedInputCandidateObjects].some((objectId) => !candidateObjects.has(objectId))) fail(`${definition.operation} lost a referenced upstream candidate`)
if (counts.stringHasherEntries < previousHasherEntries) fail(`${definition.operation} regressed the restored StringHasher table`)
previousHasherEntries = counts.stringHasherEntries
chainStages.push({ operation: definition.operation, historyRecords: response.history.records.length, ...counts, candidateObjects: [...candidateObjects].sort(), referencedInputCandidateObjects: [...referencedInputCandidateObjects].sort(), referencedCandidateObjectsRetained: true, persistedInputEvidence: true })
chainResponses.push(response)
chainSource = { objectId: resultObjectId, objectTag: resultObjectTag, stageId, step: response.history.resultStep ?? fail(`${definition.operation} omitted result STEP`), evidence }
}
const persistedFinalEvidence = JSON.parse(JSON.stringify(chainSource.evidence)) as NativeStageNamingEvidence
if (JSON.stringify(persistedFinalEvidence.mappedNames) !== JSON.stringify(chainSource.evidence.mappedNames)) fail('long-chain naming evidence changed across final JSON persistence')
const ambiguousRef: TopoRefValue = { schemaVersion: 1, objectId: 'Source', kind: 'face', persistentId: 'Face1', topologyVersion: 2, generation: 1, status: 'ambiguous', candidates: ['Face1', 'Face2'] }
const fcstdDocument: DocumentSnapshot = {
id: 'fcstd-ambiguous', label: 'FCStd ambiguity boundary', version: 1, dirty: false, readOnly: false, units: 'mm',
tree: [{ id: 'Source', label: 'Source', type: 'feature' }, { id: 'Consumer', label: 'Consumer', type: 'feature' }],
objects: [
{ id: 'Source', typeId: 'Part::Feature', properties: [] },
{ id: 'Consumer', typeId: 'Part::Feature', properties: [{ name: 'Support', label: 'Support', group: 'Links', scope: 'data', type: 'App::PropertyLinkSub', value: { schemaVersion: 1, objectId: 'Source', subElements: [ambiguousRef] } }] },
],
dependencies: [{ sourceId: 'Consumer', targetId: 'Source', relation: 'topo-ref', propertyName: 'Support', reference: 'Face1' }],
recompute: { generation: 0, status: 'idle', objectStates: { Source: 'clean', Consumer: 'clean' }, dirtyObjects: [], order: [], errors: [] },
}
let fcstdAmbiguityRejected = false
try { serializeFcstdMetadataArchive(fcstdDocument) }
catch (error) { fcstdAmbiguityRejected = /stable topology status/.test(error instanceof Error ? error.message : String(error)) }
if (!fcstdAmbiguityRejected) fail('FCStd writer did not reject an ambiguous LinkSub')
const stableRef: TopoRefValue = { schemaVersion: 1, objectId: 'Source', kind: 'face', persistentId: 'Face1', topologyVersion: 5, generation: 5, status: 'stable' }
const stableFcstdDocument: DocumentSnapshot = {
...fcstdDocument,
id: 'fcstd-stable', label: 'FCStd stable LinkSub boundary', version: 5,
objects: fcstdDocument.objects.map((object) => object.id !== 'Consumer' ? object : {
...object,
properties: object.properties.map((property) => property.name !== 'Support' ? property : { ...property, value: { schemaVersion: 1, objectId: 'Source', subElements: [stableRef] } }),
}),
}
const stableArchive = serializeFcstdMetadataArchive(stableFcstdDocument)
const stableInspection = inspectFcstdArchive(stableArchive)
const nativeLinkSub = stableInspection.objects.find(({ name }) => name === 'Consumer')?.properties.find(({ name }) => name === 'Support')
if (JSON.stringify(nativeLinkSub?.subElements) !== '["Face1"]') fail('FCStd writer did not emit the stable native LinkSub name')
const rewrittenStableArchive = rewriteFcstdMetadataArchive(stableArchive, stableInspection.proxyDocument)
const rewrittenStableLinkSub = inspectFcstdArchive(rewrittenStableArchive).objects.find(({ name }) => name === 'Consumer')?.properties.find(({ name }) => name === 'Support')
if (JSON.stringify(rewrittenStableLinkSub?.subElements) !== '["Face1"]') fail('FCStd inspect/rewrite changed the stable native LinkSub name')
for (const response of [common, ...chainResponses]) (response.history as typeof response.history & { result?: { delete?(): void } }).result?.delete?.()
const artifacts = await Promise.all(artifactNames.map(async (name) => {
const path = resolve(dist, name)
const [content, bytes] = await Promise.all([readFile(path), stat(path).then(({ size }) => size)])
return { name, bytes, sha256: createHash('sha256').update(content).digest('hex') }
}))
const harnessPath = resolve(root, 'scripts/run-freecad-isomorphic-provenance.ts')
const harnessContent = await readFile(harnessPath)
const report = {
schemaVersion: 1, status: 'pass', runtime: 'node', implementation: 'freecad-linked', productionWorkerLinked: true,
cases: {
symmetricCommon: { ...commonCounts, persistedCandidateSets: true },
mixedCut: cutCounts,
downstreamRotate: { ...downstreamCounts, candidateObjects: [...downstreamCandidateObjects].sort() },
longChain: {
operations: chainStages.map(({ operation }) => operation),
stages: chainStages,
persistedFinalEvidence: true,
referencedCandidateObjectsRetained: true,
stringHasherMonotonic: true,
},
},
fcstdAmbiguityRejected,
fcstdStableLinkRoundtrip: { subElements: rewrittenStableLinkSub?.subElements, inspectRewritePreserved: true, nativeDesktopResaveCovered: false },
artifacts,
harness: { path: 'scripts/run-freecad-isomorphic-provenance.ts', bytes: harnessContent.length, sha256: createHash('sha256').update(harnessContent).digest('hex') },
exactBlocker: 'exhaustive cross-feature naming and native FreeCAD FCStd save-reopen-resave corpus',
systemExact: false,
generatedAt: new Date().toISOString(),
}
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify(report, null, 2))