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() 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 } 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[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))