feat: complete production naming and reference lifecycle gates
This commit is contained in:
223
scripts/run-freecad-isomorphic-provenance.ts
Normal file
223
scripts/run-freecad-isomorphic-provenance.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
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))
|
||||
Reference in New Issue
Block a user