feat: complete production naming and reference lifecycle gates
This commit is contained in:
347
scripts/freecad-naming-production-matrix.mjs
Normal file
347
scripts/freecad-naming-production-matrix.mjs
Normal file
@@ -0,0 +1,347 @@
|
||||
export const FREECAD_NAMING_CALLBACKS = [
|
||||
'freecadNamingAbiVersion',
|
||||
'freecadNamingCapabilitiesJson',
|
||||
'freecadNamingEvidenceJson',
|
||||
]
|
||||
|
||||
export const FREECAD_NAMING_OPERATIONS = [
|
||||
'fuse', 'cut', 'common', 'rotate', 'pad', 'pocket', 'loft', 'pipe',
|
||||
'revolution', 'groove', 'fillet', 'chamfer', 'hole', 'draft', 'thickness',
|
||||
'linear-pattern', 'polar-pattern', 'mirrored', 'multi-transform',
|
||||
]
|
||||
|
||||
const shapeStep = (module, method, ...args) => {
|
||||
const shape = module[method](...args)
|
||||
try {
|
||||
return module.shapeToStep(shape)
|
||||
} finally {
|
||||
shape.delete?.()
|
||||
}
|
||||
}
|
||||
|
||||
const box = (module, ...args) => shapeStep(module, 'makeBox', ...args)
|
||||
const placedBox = (module, ...args) => shapeStep(module, 'makeBoxPlaced', ...args)
|
||||
const rectangle = (module, ...args) => shapeStep(module, 'makeRectangleFace', ...args)
|
||||
const placedRectangle = (module, ...args) => shapeStep(module, 'makeRectangleFacePlaced', ...args)
|
||||
const line = (module, ...args) => shapeStep(module, 'makeLineWire', ...args)
|
||||
|
||||
const input = (inputId, role, objectTag, step) => ({
|
||||
inputId,
|
||||
objectId: `production-${inputId}`,
|
||||
role,
|
||||
objectTag,
|
||||
step,
|
||||
})
|
||||
|
||||
const fixtures = {
|
||||
fuse(module) {
|
||||
const objectStep = box(module, 10, 10, 10)
|
||||
const toolStep = placedBox(module, 5, 5, 5, 8, 0, 0)
|
||||
return { response: module.booleanHistoryFromStep(objectStep, toolStep, 'fuse'), inputs: [input('object', 'object', 101, objectStep), input('tool', 'tool', 102, toolStep)] }
|
||||
},
|
||||
cut(module) {
|
||||
const objectStep = box(module, 10, 10, 10)
|
||||
const toolStep = box(module, 5, 5, 5)
|
||||
return { response: module.booleanHistoryFromStep(objectStep, toolStep, 'cut'), inputs: [input('object', 'object', 111, objectStep), input('tool', 'tool', 112, toolStep)] }
|
||||
},
|
||||
common(module) {
|
||||
const objectStep = box(module, 10, 10, 10)
|
||||
const toolStep = placedBox(module, 5, 5, 5, 8, 2, 2)
|
||||
return { response: module.booleanHistoryFromStep(objectStep, toolStep, 'common'), inputs: [input('object', 'object', 121, objectStep), input('tool', 'tool', 122, toolStep)] }
|
||||
},
|
||||
rotate(module) {
|
||||
const objectStep = placedBox(module, 2, 1, 1, 1, 0, 0)
|
||||
return { response: module.rotateHistoryFromStep(objectStep, 0, 0, 0, 0, 0, 1, 30), inputs: [input('object', 'object', 131, objectStep)] }
|
||||
},
|
||||
pad(module) {
|
||||
const objectStep = rectangle(module, 2, 3)
|
||||
return { response: module.prismHistoryFromStep(objectStep, 0, 0, 5), inputs: [input('object', 'object', 141, objectStep)] }
|
||||
},
|
||||
pocket(module) {
|
||||
const objectStep = box(module, 10, 10, 10)
|
||||
const toolStep = rectangle(module, 2, 3)
|
||||
return { response: module.pocketHistoryFromStep(objectStep, toolStep, 0, 0, 5), inputs: [input('object', 'object', 151, objectStep), input('tool', 'tool', 152, toolStep)] }
|
||||
},
|
||||
loft(module) {
|
||||
const objectStep = placedRectangle(module, 2, 2, 0, 0, 0)
|
||||
const toolStep = placedRectangle(module, 2, 2, 0, 0, 5)
|
||||
return { response: module.loftHistoryFromStep(objectStep, toolStep, false), inputs: [input('object', 'object', 161, objectStep), input('tool', 'tool', 162, toolStep)] }
|
||||
},
|
||||
pipe(module) {
|
||||
const objectStep = rectangle(module, 2, 2)
|
||||
const toolStep = line(module, 0, 0, 0, 0, 0, 5)
|
||||
return { response: module.pipeHistoryFromStep(objectStep, toolStep), inputs: [input('object', 'object', 171, objectStep), input('tool', 'tool', 172, toolStep)] }
|
||||
},
|
||||
revolution(module) {
|
||||
const objectStep = rectangle(module, 2, 3)
|
||||
return { response: module.revolutionHistoryFromStep(objectStep, -1, 0, 0, 0, 1, 0, 360), inputs: [input('object', 'object', 181, objectStep)] }
|
||||
},
|
||||
groove(module) {
|
||||
const objectStep = placedBox(module, 10, 10, 8, -5, 0, -4)
|
||||
const toolStep = placedRectangle(module, 1, 3, 0.5, 2, 0)
|
||||
return { response: module.grooveHistoryFromStep(objectStep, toolStep, 0, 0, 0, 0, 1, 0, 360), inputs: [input('object', 'object', 191, objectStep), input('tool', 'tool', 192, toolStep)] }
|
||||
},
|
||||
fillet(module) {
|
||||
const objectStep = box(module, 6, 6, 6)
|
||||
return { response: module.filletHistoryFromStep(objectStep, 0.4), inputs: [input('object', 'object', 201, objectStep)] }
|
||||
},
|
||||
chamfer(module) {
|
||||
const objectStep = box(module, 6, 6, 6)
|
||||
return { response: module.chamferHistoryFromStep(objectStep, 0.4), inputs: [input('object', 'object', 211, objectStep)] }
|
||||
},
|
||||
hole(module) {
|
||||
const objectStep = box(module, 10, 10, 10)
|
||||
return { response: module.holeHistoryFromStep(objectStep, 1, 10, 0, 0, -5, 0, 0, 1), inputs: [input('object', 'object', 221, objectStep)] }
|
||||
},
|
||||
draft(module) {
|
||||
const objectStep = box(module, 10, 10, 10)
|
||||
return { response: module.draftHistoryFromStep(objectStep, 0, 5, 0, 0, 1, 0, 0, 0, 0, 0, 1, false), inputs: [input('object', 'object', 231, objectStep)] }
|
||||
},
|
||||
thickness(module) {
|
||||
const objectStep = box(module, 6, 6, 6)
|
||||
return { response: module.thicknessHistoryFromStep(objectStep, 1, -0.4, false), inputs: [input('object', 'object', 241, objectStep)] }
|
||||
},
|
||||
'linear-pattern'(module) {
|
||||
const objectStep = box(module, 2, 2, 2)
|
||||
return { response: module.linearPatternHistoryFromStep(objectStep, 2, 0, 0), inputs: [input('object', 'object', 251, objectStep)] }
|
||||
},
|
||||
'polar-pattern'(module) {
|
||||
const objectStep = placedBox(module, 2, 1, 1, -1, -0.5, -0.5)
|
||||
return { response: module.polarPatternHistoryFromStep(objectStep, 0, 0, 0, 0, 0, 1, 90), inputs: [input('object', 'object', 261, objectStep)] }
|
||||
},
|
||||
mirrored(module) {
|
||||
const objectStep = placedBox(module, 2, 1, 1, -0.5, -0.5, -0.5)
|
||||
return { response: module.mirroredHistoryFromStep(objectStep, 0, 0, 0, 1, 0, 0), inputs: [input('object', 'object', 271, objectStep)] }
|
||||
},
|
||||
'multi-transform'(module) {
|
||||
const objectStep = placedBox(module, 2, 1, 1, -0.5, -0.5, -0.5)
|
||||
return {
|
||||
response: module.multiTransformHistoryFromStep(objectStep, [
|
||||
{ type: 'linear', direction: [1, 0, 0] },
|
||||
{ type: 'mirrored', axisOrigin: [0, 0, 0], direction: [1, 0, 0] },
|
||||
]),
|
||||
inputs: [input('object', 'object', 281, objectStep)],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const serializableHistory = (response) => ({
|
||||
provider: response.provider,
|
||||
occtVersion: response.occtVersion,
|
||||
records: response.records,
|
||||
hasModified: response.hasModified,
|
||||
hasGenerated: response.hasGenerated,
|
||||
hasDeleted: response.hasDeleted,
|
||||
resultStep: response.resultStep,
|
||||
resultBrep: response.resultBrep,
|
||||
})
|
||||
|
||||
const externallySourcedRecords = (records, inputs) => {
|
||||
const sources = new Set(inputs.flatMap(({ inputId, role }) => [inputId, role]).filter(Boolean))
|
||||
if (inputs.length > 0) sources.add('object')
|
||||
if (inputs.length > 1) sources.add('tool')
|
||||
return records.filter((record) => sources.has(record.sourceId ?? record.source))
|
||||
}
|
||||
|
||||
const resolveInput = (record, inputs) => {
|
||||
const source = record.sourceId ?? record.source
|
||||
return inputs.find((entry) => entry.inputId === source)
|
||||
?? inputs.find((entry) => entry.role === source)
|
||||
?? (source === 'object' ? inputs[0] : undefined)
|
||||
?? (source === 'tool' ? inputs[1] : undefined)
|
||||
}
|
||||
|
||||
const persistentSourceName = (record, sourceInput) => {
|
||||
const prior = sourceInput.namingEvidence?.mappedNames?.find((mapped) => mapped.kind === record.kind && mapped.resultIndex === record.sourceIndex)
|
||||
const title = `${record.kind[0].toUpperCase()}${record.kind.slice(1)}`
|
||||
return prior?.resultPersistentId ?? `${title}${record.sourceIndex + 1}`
|
||||
}
|
||||
|
||||
export const classifyFreeCadNamingProvenance = (records, inputs) => {
|
||||
const externalRecords = externallySourcedRecords(records, inputs)
|
||||
const grouped = new Map()
|
||||
for (const record of externalRecords) {
|
||||
if (record.relation === 'deleted') continue
|
||||
const sourceInput = resolveInput(record, inputs)
|
||||
if (!sourceInput) throw new Error(`Native source ${record.sourceId ?? record.source} has no transport input.`)
|
||||
const resultKind = record.resultKind || record.kind
|
||||
const indexes = Array.isArray(record.resultIndexes) ? record.resultIndexes : [record.resultIndex]
|
||||
for (const resultIndex of indexes) {
|
||||
if (!Number.isSafeInteger(resultIndex) || resultIndex < 0) throw new Error(`Native result ${resultKind}:${String(resultIndex)} is invalid.`)
|
||||
const key = `${resultKind}:${resultIndex}`
|
||||
const entries = grouped.get(key) ?? []
|
||||
entries.push({
|
||||
record: { ...record, resultKind, resultIndex, resultIndexes: undefined },
|
||||
resultKind,
|
||||
resultIndex,
|
||||
candidate: {
|
||||
inputId: sourceInput.inputId,
|
||||
objectId: sourceInput.objectId || sourceInput.inputId,
|
||||
persistentId: persistentSourceName(record, sourceInput),
|
||||
...(sourceInput.stageId ? { stageId: sourceInput.stageId } : {}),
|
||||
relation: record.relation,
|
||||
sourceKind: record.kind,
|
||||
sourceIndex: record.sourceIndex,
|
||||
},
|
||||
})
|
||||
grouped.set(key, entries)
|
||||
}
|
||||
}
|
||||
const selected = externalRecords.filter((record) => record.relation === 'deleted').map((record) => ({ ...record }))
|
||||
const ambiguities = []
|
||||
let duplicateRecordCount = 0
|
||||
let relationConflictCount = 0
|
||||
for (const entries of grouped.values()) {
|
||||
const unique = new Map()
|
||||
for (const entry of entries) {
|
||||
const candidate = entry.candidate
|
||||
const key = `${candidate.objectId}\u0000${candidate.persistentId}\u0000${candidate.stageId || ''}\u0000${candidate.relation}`
|
||||
if (!unique.has(key)) unique.set(key, entry)
|
||||
}
|
||||
duplicateRecordCount += entries.length - unique.size
|
||||
const distinctSources = new Set([...unique.values()].map(({ candidate }) => `${candidate.objectId}\u0000${candidate.persistentId}\u0000${candidate.stageId || ''}`))
|
||||
if (distinctSources.size > 1) {
|
||||
ambiguities.push({ resultKind: entries[0].resultKind, resultIndex: entries[0].resultIndex, candidates: [...unique.values()].map(({ candidate }) => candidate) })
|
||||
continue
|
||||
}
|
||||
const relations = new Set([...unique.values()].map(({ candidate }) => candidate.relation))
|
||||
if (relations.size > 1) relationConflictCount += 1
|
||||
const stable = [...unique.values()].find(({ candidate }) => candidate.relation === 'modified') ?? [...unique.values()][0]
|
||||
selected.push(stable.record)
|
||||
}
|
||||
return {
|
||||
records: selected,
|
||||
ambiguities,
|
||||
externalRecordCount: externalRecords.length,
|
||||
internalRecordCount: records.length - externalRecords.length,
|
||||
duplicateRecordCount,
|
||||
relationConflictCount,
|
||||
}
|
||||
}
|
||||
|
||||
const validateEvidence = (operation, evidence, recordCount) => {
|
||||
if (evidence.status !== 'native-evidence') throw new Error(`${operation}: callback did not return native evidence.`)
|
||||
if (evidence.mappedNames?.length !== recordCount || recordCount < 1) throw new Error(`${operation}: callback did not map every selected native history result.`)
|
||||
// A first-generation mapping from plain Face1/Edge1 names legitimately has
|
||||
// no interned strings. Chained mappings below must restore and grow the table.
|
||||
if (!Array.isArray(evidence.stringHasher?.entries)) throw new Error(`${operation}: StringHasher evidence is absent.`)
|
||||
if (!Array.isArray(evidence.elementMap2?.maps) || evidence.elementMap2.maps.length < 1) throw new Error(`${operation}: ElementMap2 evidence is empty.`)
|
||||
const hasherIds = new Set(evidence.stringHasher.entries.map((entry) => entry.id))
|
||||
const tokens = evidence.elementMap2.maps.flatMap((map) => map.sections.flatMap((section) => section.names.flatMap((name) => name.tokens)))
|
||||
for (const mapped of evidence.mappedNames) {
|
||||
const reference = mapped.reference
|
||||
if (typeof reference?.name !== 'string' || !reference.name) throw new Error(`${operation}: MappedNameRef is malformed.`)
|
||||
if (reference.name.startsWith('#')) {
|
||||
if (!Number.isSafeInteger(reference.prefixStringId) || !reference.stringIds?.includes(reference.prefixStringId) || !reference.stringIds.every((id) => hasherIds.has(id))) throw new Error(`${operation}: MappedNameRef is not closed over StringHasher.`)
|
||||
if (!tokens.some((token) => token.marker === '$' && token.name === reference.name)) throw new Error(`${operation}: MappedNameRef is absent from ElementMap2.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const runFreeCadNamingProductionMatrix = async (module) => {
|
||||
if (!FREECAD_NAMING_CALLBACKS.every((name) => typeof module[name] === 'function')) throw new Error('Production Worker omits a required FreeCAD naming callback.')
|
||||
if (module.freecadNamingAbiVersion() !== 1) throw new Error('Production Worker FreeCAD naming ABI version is not 1.')
|
||||
const descriptor = JSON.parse(module.freecadNamingCapabilitiesJson())
|
||||
if (descriptor.freecadVersion !== '1.1.1' || descriptor.sourceCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d') throw new Error('Production Worker naming descriptor is not source locked.')
|
||||
if (descriptor.operations?.length !== FREECAD_NAMING_OPERATIONS.length || FREECAD_NAMING_OPERATIONS.some((operation) => !descriptor.operations.includes(operation))) throw new Error('Production Worker naming descriptor does not cover all native builders.')
|
||||
|
||||
const cases = []
|
||||
let chainedSource
|
||||
for (let index = 0; index < FREECAD_NAMING_OPERATIONS.length; index += 1) {
|
||||
const operation = FREECAD_NAMING_OPERATIONS[index]
|
||||
const { response, inputs } = fixtures[operation](module)
|
||||
try {
|
||||
if (response.provider !== 'occt-native' || !/^8\./.test(response.occtVersion) || !response.resultStep?.startsWith('ISO-10303-21;')) throw new Error(`${operation}: native builder response is invalid.`)
|
||||
const provenance = classifyFreeCadNamingProvenance(response.records, inputs)
|
||||
const records = provenance.records
|
||||
const stableResultCount = records.filter((record) => record.relation !== 'deleted').length
|
||||
if (stableResultCount < 1 && provenance.ambiguities.length < 1) throw new Error(`${operation}: native builder returned no usable result history.`)
|
||||
const request = {
|
||||
schemaVersion: 1,
|
||||
requestId: `production-${operation}-request`,
|
||||
documentId: 'production-naming-matrix',
|
||||
documentVersion: index + 1,
|
||||
operationId: `production-${operation}`,
|
||||
operation,
|
||||
stageId: `production:${operation}:stage`,
|
||||
resultObjectId: `production:${operation}:result`,
|
||||
resultObjectTag: 1000 + index,
|
||||
inputs,
|
||||
stages: [{ stageId: `production:${operation}:stage`, operation, inputIds: inputs.map(({ inputId }) => inputId), ordinal: 0 }],
|
||||
resultStep: response.resultStep,
|
||||
resultBrep: response.resultBrep,
|
||||
...(provenance.ambiguities.length > 0 ? { historyAmbiguities: provenance.ambiguities } : {}),
|
||||
history: { ...serializableHistory(response), records },
|
||||
}
|
||||
const evidence = stableResultCount > 0 ? JSON.parse(module.freecadNamingEvidenceJson(JSON.stringify(request))) : undefined
|
||||
if (evidence) validateEvidence(operation, evidence, stableResultCount)
|
||||
cases.push({ operation, status: 'pass', namingStatus: evidence ? (provenance.ambiguities.length > 0 ? 'mixed-native-and-ambiguous' : 'native-evidence') : 'ambiguous', callbackExecuted: Boolean(evidence), historyRecords: response.records.length, externallySourcedRecords: provenance.externalRecordCount, internalRecordsExcluded: provenance.internalRecordCount, duplicateRecordsCollapsed: provenance.duplicateRecordCount, modifiedRelationPrecedence: provenance.relationConflictCount, selectedRecords: stableResultCount, ambiguousResults: provenance.ambiguities.length, provenanceResults: stableResultCount + provenance.ambiguities.length, mappedNames: evidence?.mappedNames.length ?? 0, stringHasherEntries: evidence?.stringHasher.entries.length ?? 0, elementMaps: evidence?.elementMap2.maps.length ?? 0 })
|
||||
if (operation === 'cut' && evidence) chainedSource = { request, evidence, record: records.find((record) => record.relation !== 'deleted') }
|
||||
} finally {
|
||||
response.result?.delete?.()
|
||||
}
|
||||
}
|
||||
|
||||
const symmetricObjectStep = box(module, 10, 10, 10)
|
||||
const symmetricToolStep = box(module, 10, 10, 10)
|
||||
const symmetricResponse = module.booleanHistoryFromStep(symmetricObjectStep, symmetricToolStep, 'common')
|
||||
let isomorphicCorpus
|
||||
try {
|
||||
const symmetricInputs = [input('symmetric-object', 'object', 301, symmetricObjectStep), input('symmetric-tool', 'tool', 302, symmetricToolStep)]
|
||||
const provenance = classifyFreeCadNamingProvenance(symmetricResponse.records, symmetricInputs)
|
||||
if (provenance.ambiguities.length < 1 || provenance.ambiguities.some(({ candidates }) => candidates.length < 2)) throw new Error('Symmetric Common did not retain its non-unique native source candidates.')
|
||||
const candidateSets = provenance.ambiguities.map(({ resultKind, resultIndex, candidates }) => ({ result: `${resultKind}:${resultIndex}`, candidates: candidates.map(({ objectId, persistentId, stageId }) => `${objectId}:${persistentId}:${stageId || ''}`).sort() }))
|
||||
isomorphicCorpus = {
|
||||
operation: 'common',
|
||||
fixture: 'coincident-equal-boxes',
|
||||
status: 'pass',
|
||||
historyRecords: symmetricResponse.records.length,
|
||||
uniqueResults: provenance.records.filter((record) => record.relation !== 'deleted').length,
|
||||
ambiguousResults: provenance.ambiguities.length,
|
||||
candidateSets,
|
||||
arrayOrderFallbackUsed: false,
|
||||
}
|
||||
} finally {
|
||||
symmetricResponse.result?.delete?.()
|
||||
}
|
||||
|
||||
if (!chainedSource) throw new Error('Production naming matrix lacks its chained cut source.')
|
||||
const nextIndex = chainedSource.record.resultIndex + 1
|
||||
const chainedRequest = {
|
||||
...chainedSource.request,
|
||||
requestId: 'production-cut-chained-request',
|
||||
documentVersion: 20,
|
||||
operationId: 'production-cut-chained',
|
||||
stageId: 'production:cut:chained-stage',
|
||||
resultObjectId: 'production:cut:chained-result',
|
||||
resultObjectTag: 1100,
|
||||
inputs: [{ inputId: 'object', objectId: chainedSource.request.resultObjectId, role: 'object', stageId: chainedSource.request.stageId, objectTag: chainedSource.request.resultObjectTag, step: chainedSource.request.resultStep, namingEvidence: chainedSource.evidence }],
|
||||
stages: [{ stageId: 'production:cut:chained-stage', operation: 'cut', inputIds: ['object'], ordinal: 0 }],
|
||||
history: { ...chainedSource.request.history, records: [{ relation: 'modified', source: 'object', kind: chainedSource.record.resultKind || chainedSource.record.kind, sourceIndex: chainedSource.record.resultIndex, resultIndex: nextIndex }] },
|
||||
}
|
||||
const chainedEvidence = JSON.parse(module.freecadNamingEvidenceJson(JSON.stringify(chainedRequest)))
|
||||
validateEvidence('cut-chained', chainedEvidence, 1)
|
||||
if (chainedEvidence.stringHasher.entries.length <= chainedSource.evidence.stringHasher.entries.length) throw new Error('Production Worker did not restore and extend the prior StringHasher table.')
|
||||
|
||||
const invalid = JSON.parse(module.freecadNamingEvidenceJson(JSON.stringify({ ...chainedSource.request, requestId: 'production-invalid-history', history: { ...chainedSource.request.history, records: [] } })))
|
||||
if (invalid.status !== 'error' || !invalid.error?.includes('requires inputs and native history records')) throw new Error('Production Worker did not fail closed for missing native history.')
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
status: 'pass',
|
||||
implementation: 'freecad-linked',
|
||||
productionWorkerLinked: true,
|
||||
occtVersion: module.occtVersion(),
|
||||
freecadVersion: descriptor.freecadVersion,
|
||||
sourceCommit: descriptor.sourceCommit,
|
||||
callbacks: [...FREECAD_NAMING_CALLBACKS],
|
||||
operations: [...FREECAD_NAMING_OPERATIONS],
|
||||
cases,
|
||||
callbackExecutedCases: cases.filter(({ callbackExecuted }) => callbackExecuted).length,
|
||||
ambiguityOnlyCases: cases.filter(({ namingStatus }) => namingStatus === 'ambiguous').length,
|
||||
isomorphicCorpus,
|
||||
chainedStage: { mappedNames: chainedEvidence.mappedNames.length, stringHasherEntries: chainedEvidence.stringHasher.entries.length },
|
||||
invalidHistoryRejected: true,
|
||||
systemExact: false,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user