feat: advance TSN drift and ordered pair evidence
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const root = resolve(new URL('..', import.meta.url).pathname)
|
||||
const report = JSON.parse(await readFile(resolve(root, 'config/freecad-ordered-operation-pair-classification.json'), 'utf8'))
|
||||
const fail = (message) => { throw new Error(`FreeCAD ordered operation pair classification: ${message}`) }
|
||||
const sha256 = (value) => createHash('sha256').update(value).digest('hex')
|
||||
if (report.schemaVersion !== 1 || report.baseline?.freecadVersion !== '1.1.1' || report.baseline.commit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || !/^8\./.test(report.baseline.occtVersion)) fail('baseline is invalid.')
|
||||
for (const harness of [report.nativeProbe?.executor, report.nativeProbe?.matrix, report.nativeProbe?.resave]) {
|
||||
const path = resolve(root, harness?.path ?? '')
|
||||
const [content, bytes] = await Promise.all([readFile(path), stat(path).then(({ size }) => size)])
|
||||
if (bytes !== harness.bytes || sha256(content) !== harness.sha256) fail(`${harness.path} provenance is stale.`)
|
||||
}
|
||||
for (const artifact of report.nativeProbe?.artifacts ?? []) {
|
||||
const path = resolve(root, 'native/occt-history/dist', artifact.name)
|
||||
const [content, bytes] = await Promise.all([readFile(path), stat(path).then(({ size }) => size)])
|
||||
if (bytes !== artifact.bytes || sha256(content) !== artifact.sha256) fail(`${artifact.name} provenance is stale.`)
|
||||
}
|
||||
const expectedPairs = [
|
||||
{ pair: 'fuse->fuse', secondBuilder: 'BRepAlgoAPI_Fuse', secondInputCount: 2, mutationParameter: 'toolOffsetX', mutationTrajectory: [12, 11, 12] },
|
||||
{ pair: 'fuse->cut', secondBuilder: 'BRepAlgoAPI_Cut', secondInputCount: 2, mutationParameter: 'toolSize', mutationTrajectory: [4, 3, 4] },
|
||||
{ pair: 'fuse->common', secondBuilder: 'BRepAlgoAPI_Common', secondInputCount: 2, mutationParameter: 'toolOffsetX', mutationTrajectory: [10, 9, 10] },
|
||||
{ pair: 'fuse->rotate', secondBuilder: 'BRepBuilderAPI_Transform', secondInputCount: 1, mutationParameter: 'angle', mutationTrajectory: [15, 22.5, 15] },
|
||||
]
|
||||
if (report.nativeProbe?.artifacts?.length !== 3 || report.classifications?.length !== expectedPairs.length) fail('native artifacts or classification prefix is incomplete.')
|
||||
for (const [index, entry] of report.classifications.entries()) {
|
||||
const expected = expectedPairs[index]
|
||||
const secondOperation = expected.pair.split('->')[1]
|
||||
if (entry.taskId !== `TSN-PAIR-${expected.pair.replace('->', '-')}` || entry.pair !== expected.pair || entry.classification !== 'accepted' || entry.nativeDecision !== 'accepted' || entry.first?.operation !== 'fuse' || entry.first.builder !== 'BRepAlgoAPI_Fuse' || entry.first.inputCount !== 2 || entry.second?.operation !== secondOperation || entry.second.builder !== expected.secondBuilder || entry.second.inputCount !== expected.secondInputCount || entry.first.historyProvider !== 'occt-native' || entry.second.historyProvider !== 'occt-native') fail(`${expected.pair} decision contract is invalid.`)
|
||||
if (entry.first.summary?.isValid !== true || entry.second.summary?.isValid !== true || entry.first.historyRecords < 1 || entry.second.historyRecords < 1 || entry.mutation?.changed !== true || entry.mutation?.restoredExactly !== true) fail(`${expected.pair} builder or mutation evidence is incomplete.`)
|
||||
if (entry.mutation.scope !== 'second-operation-only' || entry.mutation.parameter !== expected.mutationParameter || JSON.stringify(entry.mutation.trajectory) !== JSON.stringify(expected.mutationTrajectory) || entry.mutation.beforeValue !== expected.mutationTrajectory[0] || entry.mutation.editedValue !== expected.mutationTrajectory[1] || entry.mutation.restoredValue !== expected.mutationTrajectory[2]) fail(`${expected.pair} mutation trajectory is invalid.`)
|
||||
if (entry.mutation.editedEvidence?.operation !== secondOperation || entry.mutation.restoredEvidence?.operation !== secondOperation || entry.mutation.editedEvidence?.builder !== expected.secondBuilder || entry.mutation.restoredEvidence?.builder !== expected.secondBuilder || entry.mutation.editedEvidence?.inputCount !== expected.secondInputCount || entry.mutation.restoredEvidence?.inputCount !== expected.secondInputCount || entry.mutation.editedEvidence?.historyProvider !== 'occt-native' || entry.mutation.restoredEvidence?.historyProvider !== 'occt-native') fail(`${expected.pair} mutation history provider is invalid.`)
|
||||
if (entry.second.namingSemanticSha256 !== entry.mutation.restoredEvidence?.namingSemanticSha256) fail(`${expected.pair} semantic naming evidence did not restore to nominal.`)
|
||||
if (entry.naming?.upstreamEvidenceRestored !== true || entry.naming?.downstreamEvidence !== true || entry.naming?.jsonRoundtripStable !== true) fail(`${expected.pair} naming evidence is incomplete.`)
|
||||
if (entry.persistence?.status !== 'pass' || entry.persistence.pair !== entry.pair || entry.persistence.freecadVersion !== '1.1.1' || !Object.values(entry.persistence.checks ?? {}).every(Boolean)) fail(`${expected.pair} FCStd persistence evidence is incomplete.`)
|
||||
const phases = entry.persistence.phases
|
||||
if (JSON.stringify(phases?.initial) !== JSON.stringify(phases?.reopened) || JSON.stringify(phases?.initial) !== JSON.stringify(phases?.resaved) || phases.initial.namingEvidenceSha256 !== entry.second.namingEvidenceSha256) fail(`${expected.pair} FCStd phases changed Shape or naming evidence.`)
|
||||
}
|
||||
const expectedSummary = { registeredOperations: 19, orderedPairs: 361, classifiedPairs: 4, accepted: 4, rejected: 0, unknown: 357 }
|
||||
if (JSON.stringify(report.summary) !== JSON.stringify(expectedSummary)) fail('summary is inconsistent.')
|
||||
console.log(JSON.stringify({ status: 'freecad-ordered-operation-pair-classification-pass', completedTasks: report.classifications.map(({ taskId }) => taskId), pairs: report.classifications.map(({ pair, classification }) => ({ pair, classification })), nativeBuilderRuns: report.classifications.length * 4, fcstdPhases: report.classifications.length * 3, remainingPairs: report.summary.unknown }, null, 2))
|
||||
43
scripts/check-freecad-production-drift-classification.mjs
Normal file
43
scripts/check-freecad-production-drift-classification.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { buildProductionDriftSnapshot, productionDriftDecisions, same, validateProductionDriftSnapshot } from './freecad-production-drift-classification.mjs'
|
||||
|
||||
const root = resolve(new URL('..', import.meta.url).pathname)
|
||||
const load = (path) => readFile(resolve(root, path), 'utf8').then(JSON.parse)
|
||||
const fail = (message) => { throw new Error(`FreeCAD production drift classification: ${message}`) }
|
||||
const [report, oracle] = await Promise.all([
|
||||
load('config/freecad-production-drift-classification.json'),
|
||||
load('config/freecad-tsn-stage-correlation-oracle.json'),
|
||||
])
|
||||
if (report.schemaVersion !== 1 || report.baseline?.freecadVersion !== '1.1.1' || report.baseline.commit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || report.baseline.oracleId !== oracle.baselineId) fail('baseline is invalid.')
|
||||
const harnessPath = resolve(root, report.nativeProbe?.path ?? '')
|
||||
const [harnessContent, harnessBytes] = await Promise.all([readFile(harnessPath), stat(harnessPath).then(({ size }) => size)])
|
||||
if (report.nativeProbe.path !== 'scripts/freecad-tsn-stage-correlation-oracle.py' || report.nativeProbe.bytes !== harnessBytes || report.nativeProbe.sha256 !== createHash('sha256').update(harnessContent).digest('hex') || report.nativeProbe.independentRuns < 2) fail('native replay provenance is missing or stale.')
|
||||
if (!Array.isArray(report.classifications) || report.classifications.length !== productionDriftDecisions.length) fail('classification report does not contain the complete serial decision prefix.')
|
||||
|
||||
let classifiedStages = 0
|
||||
for (const [index, entry] of report.classifications.entries()) {
|
||||
const decision = productionDriftDecisions[index]
|
||||
if (!same({ taskId: entry.taskId, operation: entry.operation, classification: entry.classification, reasonCode: entry.reasonCode, rationale: entry.rationale }, decision) || entry.replayStable !== true || entry.implementationDefect !== (decision.classification === 'implementation_defect')) fail(`${decision.taskId} classification contract is invalid.`)
|
||||
const expected = buildProductionDriftSnapshot(oracle, entry.operation)
|
||||
validateProductionDriftSnapshot(expected)
|
||||
validateProductionDriftSnapshot(entry.reference)
|
||||
validateProductionDriftSnapshot(entry.replay)
|
||||
if (!same(entry.reference, expected) || !same(entry.replay, expected)) fail(`${entry.operation} classification evidence is stale or the independent replay differs.`)
|
||||
classifiedStages += expected.driftOrdinals.length
|
||||
}
|
||||
const driftCases = oracle.driftReplays.length
|
||||
const countClassification = (classification) => productionDriftDecisions.filter((entry) => entry.classification === classification).length
|
||||
const expectedSummary = { driftCases, classifiedCases: productionDriftDecisions.length, classifiedStages, stableSemantics: countClassification('stable_semantics'), allowedEvolution: countClassification('allowed_evolution'), implementationDefects: countClassification('implementation_defect'), unknown: driftCases - productionDriftDecisions.length }
|
||||
if (!same(report.summary, expectedSummary)) fail('classification summary is inconsistent.')
|
||||
|
||||
console.log(JSON.stringify({
|
||||
status: 'freecad-production-drift-classification-pass',
|
||||
completedTasks: report.classifications.map(({ taskId }) => taskId),
|
||||
cases: report.classifications.map(({ operation, classification }) => ({ operation, classification })),
|
||||
independentRuns: report.nativeProbe.independentRuns,
|
||||
classifiedCases: report.summary.classifiedCases,
|
||||
classifiedStages: report.summary.classifiedStages,
|
||||
remainingCases: report.summary.unknown,
|
||||
}, null, 2))
|
||||
@@ -6,7 +6,7 @@ import { validateCompositeMutationEvidence } from './freecad-composite-mutation-
|
||||
const root = resolve(new URL('..', import.meta.url).pathname)
|
||||
const load = (path) => readFile(resolve(root, path), 'utf8').then(JSON.parse)
|
||||
const fail = (message) => { throw new Error(`FreeCAD TSN stage correlation: ${message}`) }
|
||||
const [desktop, production, productionRegistry, composite, resave, mutations, recoveredNaming] = await Promise.all([
|
||||
const [desktop, production, productionRegistry, composite, resave, mutations, recoveredNaming, productionDriftClassification, orderedPairClassification] = await Promise.all([
|
||||
load('config/freecad-tsn-stage-correlation-oracle.json'),
|
||||
load('config/freecad-isomorphic-provenance-verification.json'),
|
||||
load('config/chrome-freecad-naming-production-verification.json'),
|
||||
@@ -14,6 +14,8 @@ const [desktop, production, productionRegistry, composite, resave, mutations, re
|
||||
load('config/freecad-composite-history-resave-verification.json'),
|
||||
load('config/freecad-parameter-mutation-report.json'),
|
||||
load('config/freecad-recovered-naming-classification.json'),
|
||||
load('config/freecad-production-drift-classification.json'),
|
||||
load('config/freecad-ordered-operation-pair-classification.json'),
|
||||
])
|
||||
const operations = ['cut', 'rotate', 'fillet', 'mirrored', 'linear-pattern']
|
||||
if (desktop.schemaVersion !== 1 || desktop.baselineId !== 'freecad-1.1.1-tsn-stage-correlation' || desktop.freecadVersion !== '1.1.1' || desktop.gitCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || desktop.status !== 'pass') fail('desktop oracle is not locked to FreeCAD 1.1.1.')
|
||||
@@ -77,13 +79,28 @@ const proxyStageOperations = desktop.stages.filter(({ nativeBuilder }) => native
|
||||
if (JSON.stringify(proxyStageOperations) !== '["rotate","mirrored","linear-pattern"]' || desktop.summary?.nativeBuilderStages !== 2 || desktop.summary?.proxyStages !== 3 || desktop.exactCorrelationReady !== false) fail('desktop native-builder/proxy boundary drifted.')
|
||||
|
||||
const registeredOperations = productionRegistry.operations || []
|
||||
const coveredTransitions = operations.slice(1).map((operation, index) => `${operations[index]}->${operation}`)
|
||||
const productionDriftMutations = desktop.mutations.filter(({ restoreTopologyDriftStages }) => restoreTopologyDriftStages > 0)
|
||||
const productionDriftByOperation = new Map(productionDriftMutations.map((mutation) => [mutation.operation, mutation]))
|
||||
const productionDriftClassifications = productionDriftClassification.classifications || []
|
||||
if (productionDriftClassification.baseline?.freecadVersion !== desktop.freecadVersion || productionDriftClassification.baseline?.commit !== desktop.gitCommit || productionDriftClassifications.length !== new Set(productionDriftClassifications.map(({ operation }) => operation)).size) fail('production drift classifications have invalid baseline or duplicate operations.')
|
||||
let classifiedProductionDriftStages = 0
|
||||
for (const entry of productionDriftClassifications) {
|
||||
const mutation = productionDriftByOperation.get(entry.operation)
|
||||
if (!mutation || !['stable_semantics', 'allowed_evolution', 'implementation_defect'].includes(entry.classification) || entry.replayStable !== true) fail(`production drift classification ${entry.operation} is invalid.`)
|
||||
classifiedProductionDriftStages += mutation.restoreTopologyDriftStages
|
||||
}
|
||||
const unclassifiedProductionDriftCases = productionDriftMutations.length - productionDriftClassifications.length
|
||||
const unclassifiedProductionDriftStages = desktop.summary.mutationRestoreTopologyDriftStages - classifiedProductionDriftStages
|
||||
const orderedPairClassifications = orderedPairClassification.classifications || []
|
||||
if (orderedPairClassification.baseline?.freecadVersion !== desktop.freecadVersion || orderedPairClassification.baseline?.commit !== desktop.gitCommit || orderedPairClassifications.length !== new Set(orderedPairClassifications.map(({ pair }) => pair)).size) fail('ordered operation pair classifications have invalid baseline or duplicate pairs.')
|
||||
for (const entry of orderedPairClassifications) if (!['accepted', 'rejected'].includes(entry.classification) || entry.nativeDecision !== entry.classification) fail(`ordered operation pair classification ${entry.pair} is invalid.`)
|
||||
const coveredTransitions = [...new Set([...operations.slice(1).map((operation, index) => `${operations[index]}->${operation}`), ...orderedPairClassifications.map(({ pair }) => pair)])]
|
||||
const orderedOperationPairs = registeredOperations.length * registeredOperations.length
|
||||
const blockers = [
|
||||
`Native desktop builder stages missing from the sequential chain: ${proxyStageOperations.join(', ')}`,
|
||||
`Native parameter-mutation family missing for production operation: ${missingNativeMutationOperations.join(', ')}`,
|
||||
`${desktop.summary.mutationRestoreTopologyDriftCases}/5 edit/restore cases changed ${desktop.summary.mutationRestoreTopologyDriftStages} downstream semantic topology digests without wrong binding`,
|
||||
`${unclassifiedNamingDriftCases}/${compositeMutation.mutationNamingRestoreDriftCases} native naming-drift cases and ${unclassifiedNamingDriftStages}/${compositeMutation.mutationNamingRestoreDriftStages} recovered naming stages remain unclassified; ${recoveredNamingClassifications.length} case is classified from an independent native replay`,
|
||||
`${unclassifiedProductionDriftCases}/${productionDriftMutations.length} production restore-topology cases and ${unclassifiedProductionDriftStages}/${desktop.summary.mutationRestoreTopologyDriftStages} downstream stages remain unclassified; ${productionDriftClassifications.length} ${productionDriftClassifications.length === 1 ? 'case is' : 'cases are'} classified from an independent native replay`,
|
||||
`${unclassifiedNamingDriftCases}/${compositeMutation.mutationNamingRestoreDriftCases} native naming-drift cases and ${unclassifiedNamingDriftStages}/${compositeMutation.mutationNamingRestoreDriftStages} recovered naming stages remain unclassified; ${recoveredNamingClassifications.length} ${recoveredNamingClassifications.length === 1 ? 'case is' : 'cases are'} classified from an independent native replay`,
|
||||
`Only ${coveredTransitions.length}/${orderedOperationPairs} ordered production operation pairs are classified and replayed; ${orderedOperationPairs - coveredTransitions.length} remain unclassified for type compatibility`,
|
||||
]
|
||||
console.log(JSON.stringify({
|
||||
@@ -94,7 +111,7 @@ console.log(JSON.stringify({
|
||||
nativeBuilderStages: desktop.summary.nativeBuilderStages,
|
||||
proxyStageOperations,
|
||||
compositeMutations: { cases: compositeMutation.mutationCases, passed: compositeMutation.mutationPassed, caseReconciliations: compositeCaseReconciliations, categoryCases: compositeMutation.categoryCases, stageRecords: compositeMutation.mutationStageRecords, phaseStageRecords: compositeMutation.mutationPhaseStageRecords, finalPropagationFailures: compositeMutation.mutationFinalPropagationFailures, geometryRestoreFailures: compositeMutation.mutationStageRestoreFailures, namingRestoreDriftCases: compositeMutation.mutationNamingRestoreDriftCases, namingRestoreDriftStages: compositeMutation.mutationNamingRestoreDriftStages, classifiedNamingDriftCases: recoveredNamingClassifications.length, classifiedNamingDriftStages, unclassifiedNamingDriftCases, unclassifiedNamingDriftStages },
|
||||
mutations: { cases: desktop.summary.mutationCases, passed: desktop.summary.mutationPassed, nativeCorrelated: nativeMutationCorrelation.filter(({ complete }) => complete).length, missingNativeMutationOperations, restoreTopologyDriftCases: desktop.summary.mutationRestoreTopologyDriftCases, restoreTopologyDriftStages: desktop.summary.mutationRestoreTopologyDriftStages },
|
||||
mutations: { cases: desktop.summary.mutationCases, passed: desktop.summary.mutationPassed, nativeCorrelated: nativeMutationCorrelation.filter(({ complete }) => complete).length, missingNativeMutationOperations, restoreTopologyDriftCases: desktop.summary.mutationRestoreTopologyDriftCases, restoreTopologyDriftStages: desktop.summary.mutationRestoreTopologyDriftStages, classifiedRestoreTopologyDriftCases: productionDriftClassifications.length, classifiedRestoreTopologyDriftStages: classifiedProductionDriftStages, unclassifiedRestoreTopologyDriftCases: unclassifiedProductionDriftCases, unclassifiedRestoreTopologyDriftStages: unclassifiedProductionDriftStages },
|
||||
transitions: { registeredOperations: registeredOperations.length, orderedOperationPairs, covered: coveredTransitions.length, unclassified: orderedOperationPairs - coveredTransitions.length, coveredTransitions },
|
||||
nativeDesktopResaveCases: resave.summary.nativeDesktopResaveCases + 1,
|
||||
wrongBindings: desktop.summary.wrongBindings,
|
||||
|
||||
@@ -87,7 +87,7 @@ def relation_from_mapped_name(mapped_name):
|
||||
def normalize_mapped_name(mapped_name):
|
||||
# StringHasher IDs are process-local. Preserve the token grammar while
|
||||
# removing only the volatile hash-table slot assigned in this process.
|
||||
return re.sub(r":H[0-9a-fA-F]+", ":H#", mapped_name or "")
|
||||
return re.sub(r":H-?[0-9a-fA-F]+", ":H#", mapped_name or "")
|
||||
|
||||
|
||||
def source_element_name(mapped_name):
|
||||
|
||||
113
scripts/freecad-ordered-operation-pair-resave.py
Normal file
113
scripts/freecad-ordered-operation-pair-resave.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
|
||||
import FreeCAD as App
|
||||
import Import
|
||||
import Part
|
||||
|
||||
|
||||
FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d"
|
||||
|
||||
|
||||
def version_text():
|
||||
return ".".join(str(value) for value in App.Version()[:3])
|
||||
|
||||
|
||||
def shape_snapshot(feature):
|
||||
shape = feature.Shape
|
||||
if shape.isNull():
|
||||
raise RuntimeError("ordered pair produced a null Shape")
|
||||
bounds = shape.BoundBox
|
||||
return {
|
||||
"valid": bool(shape.isValid()),
|
||||
"solids": len(shape.Solids),
|
||||
"faces": len(shape.Faces),
|
||||
"edges": len(shape.Edges),
|
||||
"vertices": len(shape.Vertexes),
|
||||
"volume": round(float(shape.Volume), 7),
|
||||
"area": round(float(shape.Area), 7),
|
||||
"bounds": [round(float(value), 7) for value in (bounds.XMin, bounds.YMin, bounds.ZMin, bounds.XMax, bounds.YMax, bounds.ZMax)],
|
||||
}
|
||||
|
||||
|
||||
def phase_snapshot(document):
|
||||
feature = document.getObject("PairResult")
|
||||
if feature is None:
|
||||
raise RuntimeError("PairResult is absent after FCStd persistence")
|
||||
evidence = str(feature.NamingEvidence)
|
||||
return {
|
||||
"pair": str(feature.OrderedPair),
|
||||
"namingEvidenceBytes": len(evidence.encode("utf-8")),
|
||||
"namingEvidenceSha256": hashlib.sha256(evidence.encode("utf-8")).hexdigest(),
|
||||
"shape": shape_snapshot(feature),
|
||||
}
|
||||
|
||||
|
||||
def collect():
|
||||
step_path = os.environ["FREECAD_PAIR_STEP_PATH"]
|
||||
evidence_path = os.environ["FREECAD_PAIR_EVIDENCE_PATH"]
|
||||
pair = os.environ["FREECAD_ORDERED_PAIR"]
|
||||
output_directory = os.environ["FREECAD_PAIR_OUTPUT_DIRECTORY"]
|
||||
with open(evidence_path, "r", encoding="utf-8") as evidence_file:
|
||||
evidence = evidence_file.read()
|
||||
document = App.newDocument("FreeCadOrderedPairResave")
|
||||
try:
|
||||
Import.insert(step_path, document.Name)
|
||||
document.recompute()
|
||||
source_shapes = [obj.Shape for obj in document.Objects if hasattr(obj, "Shape") and not obj.Shape.isNull()]
|
||||
if not source_shapes:
|
||||
raise RuntimeError("STEP import produced no Shape")
|
||||
result = document.addObject("Part::Feature", "PairResult")
|
||||
result.Shape = source_shapes[0].copy() if len(source_shapes) == 1 else Part.makeCompound([shape.copy() for shape in source_shapes])
|
||||
result.addProperty("App::PropertyString", "OrderedPair", "Parity")
|
||||
result.addProperty("App::PropertyString", "NamingEvidence", "Parity")
|
||||
result.OrderedPair = pair
|
||||
result.NamingEvidence = evidence
|
||||
document.recompute()
|
||||
initial = phase_snapshot(document)
|
||||
initial_path = os.path.join(output_directory, "ordered-pair.FCStd")
|
||||
resaved_path = os.path.join(output_directory, "ordered-pair-resaved.FCStd")
|
||||
document.saveAs(initial_path)
|
||||
App.closeDocument(document.Name)
|
||||
reopened = App.openDocument(initial_path)
|
||||
reopened.recompute()
|
||||
reopened_snapshot = phase_snapshot(reopened)
|
||||
reopened.saveAs(resaved_path)
|
||||
App.closeDocument(reopened.Name)
|
||||
resaved = App.openDocument(resaved_path)
|
||||
resaved.recompute()
|
||||
resaved_snapshot = phase_snapshot(resaved)
|
||||
App.closeDocument(resaved.Name)
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"baselineId": "freecad-1.1.1-ordered-operation-pair-resave",
|
||||
"freecadVersion": version_text(),
|
||||
"gitCommit": FREECAD_COMMIT,
|
||||
"pair": pair,
|
||||
"phases": {"initial": initial, "reopened": reopened_snapshot, "resaved": resaved_snapshot},
|
||||
"checks": {
|
||||
"shapeStable": initial["shape"] == reopened_snapshot["shape"] and initial["shape"] == resaved_snapshot["shape"],
|
||||
"namingEvidenceStable": initial["namingEvidenceSha256"] == reopened_snapshot["namingEvidenceSha256"] and initial["namingEvidenceSha256"] == resaved_snapshot["namingEvidenceSha256"],
|
||||
},
|
||||
"status": "pass",
|
||||
}
|
||||
finally:
|
||||
for name in list(App.listDocuments().keys()):
|
||||
App.closeDocument(name)
|
||||
|
||||
|
||||
try:
|
||||
report = collect()
|
||||
except Exception as error:
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"baselineId": "freecad-1.1.1-ordered-operation-pair-resave",
|
||||
"freecadVersion": version_text(),
|
||||
"gitCommit": FREECAD_COMMIT,
|
||||
"status": "failed",
|
||||
"errorType": type(error).__name__,
|
||||
"error": str(error),
|
||||
}
|
||||
|
||||
print("FREECAD_ORDERED_OPERATION_PAIR_RESAVE_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))
|
||||
102
scripts/freecad-production-drift-classification.mjs
Normal file
102
scripts/freecad-production-drift-classification.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
const sha256 = (value) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value)
|
||||
const canonical = (value) => Array.isArray(value)
|
||||
? value.map(canonical)
|
||||
: value && typeof value === 'object'
|
||||
? Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, canonical(entry)]))
|
||||
: value
|
||||
|
||||
export const productionDriftDecisions = [
|
||||
{
|
||||
taskId: 'TSN-PROD-DRIFT-000',
|
||||
operation: 'cut',
|
||||
classification: 'allowed_evolution',
|
||||
reasonCode: 'native-downstream-topology-stabilizes-after-edit-restore',
|
||||
rationale: 'FreeCAD deterministically evolves the downstream Fillet, Mirrored, and LinearPattern semantic topology after a Pocket Length edit/restore while restoring their geometry; the evolved topology and mapped names remain stable through save, reopen, and resave.',
|
||||
},
|
||||
{
|
||||
taskId: 'TSN-PROD-DRIFT-001',
|
||||
operation: 'rotate',
|
||||
classification: 'allowed_evolution',
|
||||
reasonCode: 'native-downstream-topology-stabilizes-after-edit-restore',
|
||||
rationale: 'FreeCAD deterministically evolves the downstream Mirrored and LinearPattern semantic topology after a Rotate Angle edit/restore while restoring their geometry; the evolved topology and mapped names remain stable through save, reopen, and resave.',
|
||||
},
|
||||
]
|
||||
|
||||
export const same = (left, right) => JSON.stringify(canonical(left)) === JSON.stringify(canonical(right))
|
||||
|
||||
const stageFingerprint = (driftReplay, ordinal, phase) => {
|
||||
const mutation = driftReplay.mutation
|
||||
const persistence = driftReplay.persistence?.[phase]?.[ordinal]
|
||||
if (!persistence) throw new Error(`${driftReplay.operation}/${ordinal} lacks ${phase} persistence evidence.`)
|
||||
return {
|
||||
geometrySignature: persistence.geometrySignature,
|
||||
semanticTopologyDigest: persistence.semanticTopologyDigest,
|
||||
mappedNames: persistence.mappedNames,
|
||||
mappedNameDigest: persistence.mappedNameDigest,
|
||||
}
|
||||
}
|
||||
|
||||
export const buildProductionDriftSnapshot = (report, operation) => {
|
||||
const driftReplay = report?.driftReplays?.find((entry) => entry.operation === operation)
|
||||
if (!driftReplay) throw new Error(`${operation} has no isolated production drift replay.`)
|
||||
const mutation = driftReplay.mutation
|
||||
const ordinals = mutation.restoreTopologyDriftOrdinals
|
||||
if (!Array.isArray(ordinals) || ordinals.length === 0) throw new Error(`${operation} has no recovered production topology drift.`)
|
||||
const stages = ordinals.map((ordinal) => ({
|
||||
ordinal,
|
||||
operation: driftReplay.persistence.initial[ordinal].operation,
|
||||
name: driftReplay.persistence.initial[ordinal].name,
|
||||
before: {
|
||||
geometrySignature: mutation.beforeGeometrySignatures[ordinal],
|
||||
semanticTopologyDigest: mutation.beforeTopologyDigests[ordinal],
|
||||
},
|
||||
edited: {
|
||||
geometrySignature: mutation.editedGeometrySignatures[ordinal],
|
||||
semanticTopologyDigest: mutation.editedTopologyDigests[ordinal],
|
||||
},
|
||||
restored: {
|
||||
geometrySignature: mutation.restoredGeometrySignatures[ordinal],
|
||||
semanticTopologyDigest: mutation.restoredTopologyDigests[ordinal],
|
||||
},
|
||||
persistence: Object.fromEntries(['initial', 'reopened', 'resaved'].map((phase) => [phase, stageFingerprint(driftReplay, ordinal, phase)])),
|
||||
}))
|
||||
return {
|
||||
operation,
|
||||
contract: {
|
||||
featureName: mutation.featureName,
|
||||
typeId: mutation.typeId,
|
||||
propertyPath: mutation.propertyPath,
|
||||
},
|
||||
values: {
|
||||
before: mutation.beforeParameter,
|
||||
edited: mutation.editedParameter,
|
||||
restored: mutation.restoredParameter,
|
||||
},
|
||||
driftOrdinals: ordinals,
|
||||
stages,
|
||||
checks: {
|
||||
parameterRestored: mutation.parameterChanged === true && same(mutation.beforeParameter, mutation.restoredParameter),
|
||||
geometryRestored: mutation.restoredGeometrically === true && mutation.restoredExactly === true && same(mutation.beforeGeometrySignatures, mutation.restoredGeometrySignatures),
|
||||
topologyEvolved: stages.every(({ before, restored }) => before.semanticTopologyDigest !== restored.semanticTopologyDigest),
|
||||
restoredMatchesInitial: driftReplay.checks?.restoredMatchesInitial === true && stages.every(({ restored, persistence }) => same(restored.geometrySignature, persistence.initial.geometrySignature) && restored.semanticTopologyDigest === persistence.initial.semanticTopologyDigest),
|
||||
persistenceStable: driftReplay.checks?.geometryStable === true && driftReplay.checks?.topologyStable === true && driftReplay.checks?.mappedNamesStable === true && stages.every(({ persistence }) => ['reopened', 'resaved'].every((phase) => same(persistence.initial, persistence[phase]))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const validateProductionDriftSnapshot = (snapshot) => {
|
||||
if (!snapshot || typeof snapshot.operation !== 'string' || !snapshot.operation) throw new Error('production drift snapshot has no operation identity.')
|
||||
if (!Array.isArray(snapshot.driftOrdinals) || snapshot.driftOrdinals.length === 0 || snapshot.stages?.length !== snapshot.driftOrdinals.length) throw new Error(`${snapshot.operation} has an invalid drift-stage set.`)
|
||||
if (!same(snapshot.stages.map(({ ordinal }) => ordinal), snapshot.driftOrdinals)) throw new Error(`${snapshot.operation} drift-stage identities are inconsistent.`)
|
||||
for (const stage of snapshot.stages) {
|
||||
if (!Number.isInteger(stage.ordinal) || !stage.operation || !stage.name) throw new Error(`${snapshot.operation} has an invalid stage identity.`)
|
||||
for (const phase of ['before', 'edited', 'restored']) {
|
||||
if (!Array.isArray(stage[phase]?.geometrySignature) || !sha256(stage[phase]?.semanticTopologyDigest)) throw new Error(`${snapshot.operation}/${stage.name}/${phase} has an invalid fingerprint.`)
|
||||
}
|
||||
for (const phase of ['initial', 'reopened', 'resaved']) {
|
||||
const value = stage.persistence?.[phase]
|
||||
if (!Array.isArray(value?.geometrySignature) || !value?.mappedNames || typeof value.mappedNames !== 'object' || !sha256(value?.semanticTopologyDigest) || !sha256(value?.mappedNameDigest)) throw new Error(`${snapshot.operation}/${stage.name}/${phase} has invalid persistence evidence.`)
|
||||
}
|
||||
}
|
||||
if (!Object.values(snapshot.checks ?? {}).every((value) => value === true)) throw new Error(`${snapshot.operation} does not satisfy the allowed-evolution evidence contract.`)
|
||||
}
|
||||
@@ -20,6 +20,69 @@ export const recoveredNamingDecisions = [
|
||||
reasonCode: 'native-history-stabilizes-after-edit-restore',
|
||||
rationale: 'FreeCAD applies the same deterministic history evolution to a Midplane Pad Length edit/restore: Body and Pad geometry returns exactly, and the evolved names remain stable through save, reopen, and resave.',
|
||||
},
|
||||
{
|
||||
taskId: 'TSN-DRIFT-002',
|
||||
caseId: 'partdesign-reverse',
|
||||
classification: 'allowed_evolution',
|
||||
reasonCode: 'native-history-stabilizes-after-edit-restore',
|
||||
rationale: 'FreeCAD applies the same deterministic history evolution to a Reversed Pad Length edit/restore: Body and Pad geometry returns exactly, and the evolved names remain stable through save, reopen, and resave.',
|
||||
},
|
||||
{
|
||||
taskId: 'TSN-DRIFT-003',
|
||||
caseId: 'partdesign-taper',
|
||||
classification: 'allowed_evolution',
|
||||
reasonCode: 'native-history-stabilizes-after-edit-restore',
|
||||
rationale: 'FreeCAD applies the same deterministic history evolution to a Tapered Pad Length edit/restore: Body and Pad geometry returns exactly, and the evolved names remain stable through save, reopen, and resave.',
|
||||
},
|
||||
{
|
||||
taskId: 'TSN-DRIFT-004',
|
||||
caseId: 'partdesign-twoside',
|
||||
classification: 'allowed_evolution',
|
||||
reasonCode: 'native-history-stabilizes-after-edit-restore',
|
||||
rationale: 'FreeCAD applies the same deterministic history evolution to a Two Lengths Pad Length edit/restore: Body and Pad geometry returns exactly, and the evolved names remain stable through save, reopen, and resave.',
|
||||
},
|
||||
{
|
||||
taskId: 'TSN-DRIFT-005',
|
||||
caseId: 'partdesign-pocket',
|
||||
classification: 'allowed_evolution',
|
||||
reasonCode: 'native-history-stabilizes-after-edit-restore',
|
||||
rationale: 'FreeCAD applies deterministic history evolution to an upstream Pad Length edit/restore: Body, Pad, and downstream Pocket geometry returns exactly, and the evolved names remain stable through save, reopen, and resave.',
|
||||
},
|
||||
{
|
||||
taskId: 'TSN-DRIFT-006',
|
||||
caseId: 'partdesign-pocket-through',
|
||||
classification: 'allowed_evolution',
|
||||
reasonCode: 'native-history-stabilizes-after-edit-restore',
|
||||
rationale: 'FreeCAD applies deterministic history evolution through a Through All Pocket after an upstream Pad Length edit/restore: Body, Pad, and Pocket geometry returns exactly, and the evolved names remain stable through save, reopen, and resave.',
|
||||
},
|
||||
{
|
||||
taskId: 'TSN-DRIFT-007',
|
||||
caseId: 'partdesign-pocket-midplane',
|
||||
classification: 'allowed_evolution',
|
||||
reasonCode: 'native-history-stabilizes-after-edit-restore',
|
||||
rationale: 'FreeCAD applies deterministic history evolution through a Midplane Pocket after an upstream Pad Length edit/restore: Body, Pad, and Pocket geometry returns exactly, and the evolved names remain stable through save, reopen, and resave.',
|
||||
},
|
||||
{
|
||||
taskId: 'TSN-DRIFT-008',
|
||||
caseId: 'partdesign-pocket-twoside',
|
||||
classification: 'allowed_evolution',
|
||||
reasonCode: 'native-history-stabilizes-after-edit-restore',
|
||||
rationale: 'FreeCAD applies deterministic history evolution through a Two Lengths Pocket after an upstream Pad Length edit/restore: Body, Pad, and Pocket geometry returns exactly, and the evolved names remain stable through save, reopen, and resave.',
|
||||
},
|
||||
{
|
||||
taskId: 'TSN-DRIFT-009',
|
||||
caseId: 'partdesign-pocket-taper',
|
||||
classification: 'allowed_evolution',
|
||||
reasonCode: 'native-history-stabilizes-after-edit-restore',
|
||||
rationale: 'FreeCAD applies deterministic history evolution through a Tapered Pocket after an upstream Pad Length edit/restore: Body, Pad, and Pocket geometry returns exactly, and the evolved names remain stable through save, reopen, and resave.',
|
||||
},
|
||||
{
|
||||
taskId: 'TSN-DRIFT-010',
|
||||
caseId: 'partdesign-pocket-up-to-face',
|
||||
classification: 'allowed_evolution',
|
||||
reasonCode: 'native-history-stabilizes-after-edit-restore',
|
||||
rationale: 'FreeCAD applies deterministic history evolution through an Up To Face Pocket after an upstream Pad Length edit/restore: Body, Pad, and Pocket geometry returns exactly, and the evolved names remain stable through save, reopen, and resave.',
|
||||
},
|
||||
]
|
||||
|
||||
export const same = (left, right) => JSON.stringify(canonical(left)) === JSON.stringify(canonical(right))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import FreeCAD as App
|
||||
@@ -56,7 +57,8 @@ def mapped_names(feature):
|
||||
indexed, _ = shape.getElementIndexedName(name, True)
|
||||
except Exception:
|
||||
indexed = ""
|
||||
result[name] = mapped or indexed or name
|
||||
normalized = re.sub(r":H-?[0-9a-fA-F]+", ":H#", mapped or indexed or name)
|
||||
result[name] = re.sub(r":H:-?[0-9a-fA-F]+(?=[,;])", ":H:#", normalized)
|
||||
return result
|
||||
|
||||
|
||||
@@ -192,14 +194,19 @@ def rebuild_derived(document, features):
|
||||
|
||||
|
||||
def topology_snapshot(features):
|
||||
return [{
|
||||
"operation": operation,
|
||||
"name": feature.Name,
|
||||
"shape": shape_snapshot(feature),
|
||||
"mappedNameCount": len(mapped_names(feature)),
|
||||
"mappedNameDigest": mapped_name_digest(feature),
|
||||
"semanticTopologyDigest": semantic_topology_digest(feature),
|
||||
} for operation, feature in zip(OPERATIONS, features)]
|
||||
result = []
|
||||
for operation, feature in zip(OPERATIONS, features):
|
||||
names = mapped_names(feature)
|
||||
result.append({
|
||||
"operation": operation,
|
||||
"name": feature.Name,
|
||||
"shape": shape_snapshot(feature),
|
||||
"mappedNames": names,
|
||||
"mappedNameCount": len(names),
|
||||
"mappedNameDigest": hashlib.sha256(json.dumps(names, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest(),
|
||||
"semanticTopologyDigest": semantic_topology_digest(feature),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def parameter_value(value):
|
||||
@@ -230,10 +237,15 @@ def capture_mutation(document, features, operation, feature, property_name, edit
|
||||
restored_parameter = parameter_value(getattr(feature, property_name))
|
||||
restored = topology_snapshot(features)
|
||||
before_digests = [stage["semanticTopologyDigest"] for stage in before]
|
||||
edited_digests = [stage["semanticTopologyDigest"] for stage in edited]
|
||||
restored_digests = [stage["semanticTopologyDigest"] for stage in restored]
|
||||
drift_ordinals = [index for index, (before_digest, restored_digest) in enumerate(zip(before_digests, restored_digests)) if before_digest != restored_digest]
|
||||
before_geometry = [geometry_signature(stage) for stage in before]
|
||||
edited_geometry = [geometry_signature(stage) for stage in edited]
|
||||
restored_geometry = [geometry_signature(stage) for stage in restored]
|
||||
before_final = before[-1]["shape"]["brepSha256"]
|
||||
edited_final = edited[-1]["shape"]["brepSha256"]
|
||||
restored_geometrically = [geometry_signature(stage) for stage in before] == [geometry_signature(stage) for stage in restored]
|
||||
restored_geometrically = before_geometry == restored_geometry
|
||||
restored_topology_exactly = before_digests == restored_digests
|
||||
passed = before_parameter != edited_parameter and before_final != edited_final and before_parameter == restored_parameter and restored_geometrically
|
||||
return {
|
||||
@@ -249,14 +261,98 @@ def capture_mutation(document, features, operation, feature, property_name, edit
|
||||
"restoredExactly": before_parameter == restored_parameter and restored_geometrically,
|
||||
"restoredGeometrically": restored_geometrically,
|
||||
"restoredTopologyExactly": restored_topology_exactly,
|
||||
"restoreTopologyDriftStages": sum(1 for before_digest, restored_digest in zip(before_digests, restored_digests) if before_digest != restored_digest),
|
||||
"restoreTopologyDriftStages": len(drift_ordinals),
|
||||
"restoreTopologyDriftOrdinals": drift_ordinals,
|
||||
"beforeGeometrySignatures": before_geometry,
|
||||
"editedGeometrySignatures": edited_geometry,
|
||||
"restoredGeometrySignatures": restored_geometry,
|
||||
"beforeTopologyDigests": before_digests,
|
||||
"editedTopologyDigests": [stage["semanticTopologyDigest"] for stage in edited],
|
||||
"editedTopologyDigests": edited_digests,
|
||||
"restoredTopologyDigests": restored_digests,
|
||||
"status": "pass" if passed else "failed",
|
||||
}
|
||||
|
||||
|
||||
def mutation_specs(features):
|
||||
pocket, rotated, fillet, mirrored, pattern = features
|
||||
return [
|
||||
("cut", pocket, "Length", 2.5),
|
||||
("rotate", rotated, "Angle", 22.5),
|
||||
("fillet", fillet, "Radius", 0.35),
|
||||
("mirrored", mirrored, "PlaneNormal", App.Vector(0, 0, 1)),
|
||||
("linear-pattern", pattern, "Occurrences", 3),
|
||||
]
|
||||
|
||||
|
||||
def persistence_projection(features):
|
||||
return [{
|
||||
"operation": stage["operation"],
|
||||
"name": stage["name"],
|
||||
"geometrySignature": geometry_signature(stage),
|
||||
"semanticTopologyDigest": stage["semanticTopologyDigest"],
|
||||
"mappedNames": stage["mappedNames"],
|
||||
"mappedNameDigest": stage["mappedNameDigest"],
|
||||
} for stage in topology_snapshot(features)]
|
||||
|
||||
|
||||
def collect_drift_replay(target_index):
|
||||
target_operation = OPERATIONS[target_index]
|
||||
document = App.newDocument("TsnStageCorrelation{}Replay".format(target_operation.title()))
|
||||
try:
|
||||
features = create_chain(document)
|
||||
rebuild_derived(document, features)
|
||||
mutations = []
|
||||
for operation, feature, property_name, edited_value in mutation_specs(features)[:target_index + 1]:
|
||||
mutations.append(capture_mutation(document, features, operation, feature, property_name, edited_value))
|
||||
target_mutation = mutations[-1]
|
||||
feature_names = [feature.Name for feature in features]
|
||||
with tempfile.TemporaryDirectory(prefix="freecad-tsn-{}-drift-".format(target_operation)) as directory:
|
||||
initial_path = os.path.join(directory, "{}-restored.FCStd".format(target_operation))
|
||||
resaved_path = os.path.join(directory, "{}-restored-resaved.FCStd".format(target_operation))
|
||||
initial = persistence_projection(features)
|
||||
document.saveAs(initial_path)
|
||||
App.closeDocument(document.Name)
|
||||
reopened = App.openDocument(initial_path)
|
||||
reopened.recompute()
|
||||
reopened_features = [reopened.getObject(name) for name in feature_names]
|
||||
reopened_projection = persistence_projection(reopened_features)
|
||||
reopened.saveAs(resaved_path)
|
||||
App.closeDocument(reopened.Name)
|
||||
resaved = App.openDocument(resaved_path)
|
||||
resaved.recompute()
|
||||
resaved_features = [resaved.getObject(name) for name in feature_names]
|
||||
resaved_projection = persistence_projection(resaved_features)
|
||||
App.closeDocument(resaved.Name)
|
||||
initial_topology = [stage["semanticTopologyDigest"] for stage in initial]
|
||||
reopened_topology = [stage["semanticTopologyDigest"] for stage in reopened_projection]
|
||||
resaved_topology = [stage["semanticTopologyDigest"] for stage in resaved_projection]
|
||||
initial_names = [stage["mappedNameDigest"] for stage in initial]
|
||||
reopened_names = [stage["mappedNameDigest"] for stage in reopened_projection]
|
||||
resaved_names = [stage["mappedNameDigest"] for stage in resaved_projection]
|
||||
initial_geometry = [stage["geometrySignature"] for stage in initial]
|
||||
reopened_geometry = [stage["geometrySignature"] for stage in reopened_projection]
|
||||
resaved_geometry = [stage["geometrySignature"] for stage in resaved_projection]
|
||||
return {
|
||||
"operation": target_operation,
|
||||
"mutation": target_mutation,
|
||||
"persistence": {
|
||||
"initial": initial,
|
||||
"reopened": reopened_projection,
|
||||
"resaved": resaved_projection,
|
||||
},
|
||||
"checks": {
|
||||
"restoredMatchesInitial": target_mutation["restoredTopologyDigests"] == initial_topology,
|
||||
"geometryStable": initial_geometry == reopened_geometry and initial_geometry == resaved_geometry,
|
||||
"topologyStable": initial_topology == reopened_topology and initial_topology == resaved_topology,
|
||||
"mappedNamesStable": initial_names == reopened_names and initial_names == resaved_names,
|
||||
},
|
||||
}
|
||||
finally:
|
||||
for name in list(App.listDocuments().keys()):
|
||||
if name.startswith("TsnStageCorrelation{}Replay".format(target_operation.title())):
|
||||
App.closeDocument(name)
|
||||
|
||||
|
||||
def stage_report(operation, feature):
|
||||
sources, relations = history_sources(feature)
|
||||
names = mapped_names(feature)
|
||||
@@ -279,14 +375,7 @@ def collect():
|
||||
try:
|
||||
features = create_chain(document)
|
||||
rebuild_derived(document, features)
|
||||
pocket, rotated, fillet, mirrored, pattern = features
|
||||
mutations = [
|
||||
capture_mutation(document, features, "cut", pocket, "Length", 2.5),
|
||||
capture_mutation(document, features, "rotate", rotated, "Angle", 22.5),
|
||||
capture_mutation(document, features, "fillet", fillet, "Radius", 0.35),
|
||||
capture_mutation(document, features, "mirrored", mirrored, "PlaneNormal", App.Vector(0, 0, 1)),
|
||||
capture_mutation(document, features, "linear-pattern", pattern, "Occurrences", 3),
|
||||
]
|
||||
mutations = [capture_mutation(document, features, operation, feature, property_name, edited_value) for operation, feature, property_name, edited_value in mutation_specs(features)]
|
||||
stages = [stage_report(operation, feature) for operation, feature in zip(OPERATIONS, features)]
|
||||
with tempfile.TemporaryDirectory(prefix="freecad-tsn-stage-correlation-") as directory:
|
||||
initial_path = os.path.join(directory, "tsn-stage-correlation.FCStd")
|
||||
@@ -312,6 +401,7 @@ def collect():
|
||||
unexplained_relations += sum(1 for source in stage["historySources"] if source not in known_sources)
|
||||
if stage["mappedNameCount"] <= 0:
|
||||
wrong_bindings += 1
|
||||
drift_replays = [collect_drift_replay(index) for index, mutation in enumerate(mutations) if mutation["restoreTopologyDriftStages"] > 0]
|
||||
with open(__file__, "rb") as harness_file:
|
||||
harness_content = harness_file.read()
|
||||
return {
|
||||
@@ -324,6 +414,7 @@ def collect():
|
||||
"reopenedStages": reopened_stages,
|
||||
"resavedStages": resaved_stages,
|
||||
"mutations": mutations,
|
||||
"driftReplays": drift_replays,
|
||||
"summary": {
|
||||
"stages": len(stages),
|
||||
"nativeBuilderStages": sum(1 for stage in stages if stage["nativeBuilder"]),
|
||||
|
||||
@@ -4,13 +4,15 @@ import { resolve } from 'node:path'
|
||||
const root = resolve(new URL('..', import.meta.url).pathname)
|
||||
const outputPath = resolve(root, 'config/freecad-active-work-queue.json')
|
||||
const load = (path) => readFile(resolve(root, path), 'utf8').then(JSON.parse)
|
||||
const [gui, workflowPlan, composite, correlation, production, recoveredNaming] = await Promise.all([
|
||||
const [gui, workflowPlan, composite, correlation, production, recoveredNaming, productionDriftClassification, orderedPairClassification] = await Promise.all([
|
||||
load('.cache/freecad/reference-desktop-gui-commands.json'),
|
||||
load('config/freecad-gui-workflow-plan.json'),
|
||||
load('config/freecad-composite-history-elementmap-oracle.json'),
|
||||
load('config/freecad-tsn-stage-correlation-oracle.json'),
|
||||
load('config/chrome-freecad-naming-production-verification.json'),
|
||||
load('config/freecad-recovered-naming-classification.json'),
|
||||
load('config/freecad-production-drift-classification.json'),
|
||||
load('config/freecad-ordered-operation-pair-classification.json'),
|
||||
])
|
||||
|
||||
const guiShardTasks = gui.guiCommands.mergedShards.map((shard, index) => ({
|
||||
@@ -61,26 +63,47 @@ const driftTasks = driftCases.map((fixture, index) => ({
|
||||
...(classifications.has(fixture.id) ? { classification: classifications.get(fixture.id).classification, evidence: ['config/freecad-recovered-naming-classification.json', classifications.get(fixture.id).reasonCode] } : {}),
|
||||
exit: 'the case is classified as stable semantics, allowed evolution, or an implementation defect',
|
||||
}))
|
||||
const recoveredNamingClosed = completedDriftCases === driftCases.length
|
||||
const productionDriftClassifications = new Map(productionDriftClassification.classifications.map((entry) => [entry.operation, entry]))
|
||||
if (productionDriftClassifications.size !== productionDriftClassification.classifications.length) throw new Error('Production drift classifications contain duplicate operations.')
|
||||
const productionDriftMutations = correlation.mutations.filter((mutation) => mutation.restoreTopologyDriftStages > 0)
|
||||
const productionClassifiedPrefix = productionDriftMutations.findIndex(({ operation }) => !productionDriftClassifications.has(operation))
|
||||
const completedProductionDrifts = productionClassifiedPrefix < 0 ? productionDriftMutations.length : productionClassifiedPrefix
|
||||
if (productionDriftMutations.slice(completedProductionDrifts).some(({ operation }) => productionDriftClassifications.has(operation))) throw new Error('Production drift classifications must form a contiguous serial prefix.')
|
||||
for (const entry of productionDriftClassification.classifications) {
|
||||
if (!productionDriftMutations.some(({ operation }) => operation === entry.operation) || !['stable_semantics', 'allowed_evolution', 'implementation_defect'].includes(entry.classification)) throw new Error(`Production drift classification ${entry.operation} is invalid.`)
|
||||
}
|
||||
const productionDriftTasks = correlation.mutations
|
||||
.filter((mutation) => mutation.restoreTopologyDriftStages > 0)
|
||||
.map((mutation, index) => ({
|
||||
id: `TSN-PROD-DRIFT-${String(index).padStart(3, '0')}`,
|
||||
title: `Classify production restore topology drift for ${mutation.operation}`,
|
||||
status: 'pending',
|
||||
status: index < completedProductionDrifts ? 'completed' : recoveredNamingClosed && index === completedProductionDrifts ? 'in_progress' : 'pending',
|
||||
dependencies: [index === 0 ? driftTasks.at(-1).id : `TSN-PROD-DRIFT-${String(index - 1).padStart(3, '0')}`],
|
||||
operation: mutation.operation,
|
||||
driftStages: mutation.restoreTopologyDriftStages,
|
||||
...(productionDriftClassifications.has(mutation.operation) ? { classification: productionDriftClassifications.get(mutation.operation).classification, evidence: ['config/freecad-production-drift-classification.json', productionDriftClassifications.get(mutation.operation).reasonCode] } : {}),
|
||||
exit: 'every changed downstream stage has a native comparison and classification',
|
||||
}))
|
||||
const coveredTransitions = new Set(['cut->rotate', 'rotate->fillet', 'fillet->mirrored', 'mirrored->linear-pattern'])
|
||||
const productionDriftClosed = completedProductionDrifts === productionDriftTasks.length
|
||||
const orderedPairClassifications = new Map(orderedPairClassification.classifications.map((entry) => [entry.pair, entry]))
|
||||
if (orderedPairClassifications.size !== orderedPairClassification.classifications.length) throw new Error('Ordered operation pair classifications contain duplicate pairs.')
|
||||
for (const entry of orderedPairClassification.classifications) {
|
||||
if (!['accepted', 'rejected'].includes(entry.classification) || entry.nativeDecision !== entry.classification) throw new Error(`Ordered operation pair classification ${entry.pair} is invalid.`)
|
||||
}
|
||||
const coveredTransitions = new Set(['cut->rotate', 'rotate->fillet', 'fillet->mirrored', 'mirrored->linear-pattern', ...orderedPairClassifications.keys()])
|
||||
let nextTransitionSelected = false
|
||||
const transitionTasks = production.operations.flatMap((from) => production.operations.map((to) => {
|
||||
const pair = `${from}->${to}`
|
||||
const inProgress = productionDriftClosed && !coveredTransitions.has(pair) && !nextTransitionSelected
|
||||
if (inProgress) nextTransitionSelected = true
|
||||
return {
|
||||
id: `TSN-PAIR-${from}-${to}`,
|
||||
title: `Classify ordered operation pair ${pair}`,
|
||||
status: coveredTransitions.has(pair) ? 'completed' : 'pending',
|
||||
status: coveredTransitions.has(pair) ? 'completed' : inProgress ? 'in_progress' : 'pending',
|
||||
dependencies: coveredTransitions.has(pair) ? [] : [productionDriftTasks.at(-1).id],
|
||||
pair,
|
||||
...(orderedPairClassifications.has(pair) ? { classification: orderedPairClassifications.get(pair).classification, evidence: ['config/freecad-ordered-operation-pair-classification.json', orderedPairClassifications.get(pair).reasonCode] } : {}),
|
||||
exit: 'native acceptance or rejection is recorded; accepted pairs include builder, mutation, naming, and resave evidence',
|
||||
}
|
||||
}))
|
||||
@@ -98,8 +121,8 @@ const closureTasks = [
|
||||
const milestones = [
|
||||
{ id: 'ORA-GUI-BASELINE', exactTask: 'EX-ORA-01', status: 'completed', tasks: [...setupTasks, ...guiShardTasks, ...closureTasks] },
|
||||
{ id: 'ORA-GUI-WORKFLOWS', exactTask: 'EX-ORA-01', status: workflowClosed ? 'completed' : 'in_progress', tasks: workflowTasks },
|
||||
{ id: 'TSN-RECOVERY-DRIFT', exactTask: 'EX-TSN-04', status: workflowClosed ? 'in_progress' : 'pending', tasks: [...driftTasks, ...productionDriftTasks] },
|
||||
{ id: 'TSN-ORDERED-PAIRS', exactTask: 'EX-TSN-04', status: 'pending', tasks: transitionTasks },
|
||||
{ id: 'TSN-RECOVERY-DRIFT', exactTask: 'EX-TSN-04', status: productionDriftClosed ? 'completed' : workflowClosed ? 'in_progress' : 'pending', tasks: [...driftTasks, ...productionDriftTasks] },
|
||||
{ id: 'TSN-ORDERED-PAIRS', exactTask: 'EX-TSN-04', status: productionDriftClosed ? 'in_progress' : 'pending', tasks: transitionTasks },
|
||||
]
|
||||
const allTasks = milestones.flatMap((milestone) => milestone.tasks)
|
||||
const count = (status) => allTasks.filter((task) => task.status === status).length
|
||||
|
||||
220
scripts/run-freecad-ordered-operation-pair-classification.mjs
Normal file
220
scripts/run-freecad-ordered-operation-pair-classification.mjs
Normal file
@@ -0,0 +1,220 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { captureFreeCadPrivateNamingEvidence, classifyFreeCadPrivateNamingHistory, createFreeCadPrivateNamingAbiRequest } from '../src/facade/nativeNamingAbi.ts'
|
||||
|
||||
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 freecad = process.env.FREECAD_CMD || resolve(root, '.cache/freecad/install-desktop/bin/FreeCADCmd')
|
||||
const sysroot = resolve(root, '.cache/freecad/sysroot')
|
||||
const executorPath = resolve(root, 'scripts/run-freecad-ordered-operation-pair-classification.mjs')
|
||||
const resaveHarnessPath = resolve(root, 'scripts/freecad-ordered-operation-pair-resave.py')
|
||||
const outputPath = resolve(root, 'config/freecad-ordered-operation-pair-classification.json')
|
||||
const artifactNames = ['bitbybit-occt-history.js', 'bitbybit-occt-history.wasm', 'bitbybit-occt-history.data']
|
||||
const fail = (message) => { throw new Error(`FreeCAD ordered operation pair classification: ${message}`) }
|
||||
if (!existsSync(modulePath) || !existsSync(freecad)) fail('native OCCT or FreeCAD executable is missing.')
|
||||
|
||||
const sha256 = (value) => createHash('sha256').update(value).digest('hex')
|
||||
const shapeStep = (module, method, ...args) => {
|
||||
const shape = module[method](...args)
|
||||
try { return module.shapeToStep(shape) }
|
||||
finally { shape.delete?.() }
|
||||
}
|
||||
const input = (inputId, role, objectId, objectTag, step, namingEvidence) => ({ inputId, role, objectId, objectTag, step, ...(namingEvidence ? { namingEvidence } : {}) })
|
||||
const serializableHistory = (response, records) => ({ provider: response.provider, occtVersion: response.occtVersion, records, hasModified: response.hasModified, hasGenerated: response.hasGenerated, hasDeleted: response.hasDeleted, resultStep: response.resultStep, resultBrep: response.resultBrep })
|
||||
const namingSemanticSnapshot = (evidence) => (evidence?.mappedNames ?? []).map((entry) => ({
|
||||
kind: entry.kind,
|
||||
resultIndex: entry.resultIndex,
|
||||
resultPersistentId: entry.resultPersistentId,
|
||||
relation: entry.relation,
|
||||
candidates: (entry.candidates ?? []).map((candidate) => ({ objectId: candidate.objectId, persistentId: candidate.persistentId, stageId: candidate.stageId, relation: candidate.relation })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))),
|
||||
})).sort((left, right) => `${left.kind}:${left.resultIndex}`.localeCompare(`${right.kind}:${right.resultIndex}`))
|
||||
const canonicalSummary = (summary) => ({
|
||||
isValid: summary.isValid,
|
||||
solids: summary.solids,
|
||||
faces: summary.faces,
|
||||
edges: summary.edges,
|
||||
vertices: summary.vertices,
|
||||
volume: Number(summary.volume.toFixed(7)),
|
||||
area: Number(summary.area.toFixed(7)),
|
||||
bounds: [...summary.boundingBox.min, ...summary.boundingBox.max].map((value) => Number(value.toFixed(7))),
|
||||
})
|
||||
|
||||
const operationSpecs = {
|
||||
fuse: { builder: 'BRepAlgoAPI_Fuse', nominal: { toolSize: 5, toolOffset: 12 }, edited: { toolSize: 5, toolOffset: 11 }, parameter: 'toolOffsetX', beforeValue: 12, editedValue: 11 },
|
||||
cut: { builder: 'BRepAlgoAPI_Cut', nominal: { toolSize: 4, toolOffset: 2 }, edited: { toolSize: 3, toolOffset: 2 }, parameter: 'toolSize', beforeValue: 4, editedValue: 3 },
|
||||
common: { builder: 'BRepAlgoAPI_Common', nominal: { toolSize: 5, toolOffset: 10 }, edited: { toolSize: 5, toolOffset: 9 }, parameter: 'toolOffsetX', beforeValue: 10, editedValue: 9 },
|
||||
rotate: { builder: 'BRepBuilderAPI_Transform', nominal: { angle: 15 }, edited: { angle: 22.5 }, parameter: 'angle', beforeValue: 15, editedValue: 22.5 },
|
||||
}
|
||||
|
||||
const createOperation = (module, { operation, objectStep, toolSize, toolOffset, angle, stage }) => {
|
||||
const baseStep = objectStep ?? shapeStep(module, 'makeBox', 10, 10, 10)
|
||||
const objectInput = input('object', 'object', stage.objectId, stage.objectTag, baseStep, stage.namingEvidence)
|
||||
if (operation === 'rotate') return { response: module.rotateHistoryFromStep(baseStep, 0, 0, 0, 0, 0, 1, angle), inputs: [objectInput] }
|
||||
const toolStep = shapeStep(module, 'makeBoxPlaced', toolSize, toolSize, toolSize, toolOffset, 0, 0)
|
||||
const inputs = [
|
||||
objectInput,
|
||||
input('tool', 'tool', `${stage.resultObjectId}:tool`, stage.objectTag + 1, toolStep),
|
||||
]
|
||||
return { response: module.booleanHistoryFromStep(baseStep, toolStep, operation), inputs }
|
||||
}
|
||||
|
||||
const captureStage = (module, definition) => {
|
||||
const { response, inputs } = createOperation(module, definition)
|
||||
try {
|
||||
const provenance = classifyFreeCadPrivateNamingHistory(response.records, inputs)
|
||||
const stableRecords = provenance.records.filter(({ relation }) => relation !== 'deleted').length
|
||||
if (response.provider !== 'occt-native' || !response.resultStep?.startsWith('ISO-10303-21;') || response.summary?.isValid !== true || (stableRecords < 1 && provenance.ambiguities.length < 1)) fail(`${definition.stage.stageId} native ${definition.operation} builder returned no usable result.`)
|
||||
const request = createFreeCadPrivateNamingAbiRequest({
|
||||
requestId: `${definition.stage.stageId}:request`,
|
||||
documentId: 'ordered-operation-pair',
|
||||
documentVersion: definition.stage.ordinal + 1,
|
||||
operationId: definition.stage.stageId,
|
||||
operation: definition.operation,
|
||||
stageId: definition.stage.stageId,
|
||||
resultObjectId: definition.stage.resultObjectId,
|
||||
resultObjectTag: definition.stage.resultObjectTag,
|
||||
inputs,
|
||||
stages: [{ stageId: definition.stage.stageId, operation: definition.operation, inputIds: inputs.map(({ inputId }) => inputId), ordinal: definition.stage.ordinal }],
|
||||
resultStep: response.resultStep,
|
||||
resultBrep: response.resultBrep,
|
||||
history: serializableHistory(response, response.records),
|
||||
})
|
||||
const namingEvidence = captureFreeCadPrivateNamingEvidence(module, request)
|
||||
const mappedResults = stableRecords + provenance.ambiguities.length
|
||||
if (!namingEvidence || !['native-evidence', 'ambiguous'].includes(namingEvidence.status) || namingEvidence.mappedNames?.length !== mappedResults) fail(`${definition.stage.stageId} FreeCAD naming callback evidence is incomplete: ${JSON.stringify(namingEvidence)}`)
|
||||
return {
|
||||
response,
|
||||
namingEvidence,
|
||||
report: {
|
||||
operation: definition.operation,
|
||||
builder: operationSpecs[definition.operation].builder,
|
||||
inputCount: inputs.length,
|
||||
historyProvider: response.provider,
|
||||
namingStatus: namingEvidence.status,
|
||||
summary: canonicalSummary(response.summary),
|
||||
historyRecords: response.records.length,
|
||||
selectedRecords: stableRecords,
|
||||
ambiguousResults: provenance.ambiguities.length,
|
||||
historySha256: sha256(JSON.stringify(response.records)),
|
||||
namingEvidenceSha256: sha256(JSON.stringify(namingEvidence)),
|
||||
namingSemanticSha256: sha256(JSON.stringify(namingSemanticSnapshot(namingEvidence))),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
response.result?.delete?.()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const createModule = (await import(pathToFileURL(modulePath).href)).default
|
||||
const module = await createModule({ locateFile: (path) => resolve(dist, path) })
|
||||
const persistAcceptedPair = async (pair, second) => {
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'freecad-ordered-pair-'))
|
||||
try {
|
||||
const stepPath = join(temporaryDirectory, 'pair-result.step')
|
||||
const evidencePath = join(temporaryDirectory, 'pair-naming.json')
|
||||
await Promise.all([writeFile(stepPath, second.response.resultStep), writeFile(evidencePath, JSON.stringify(second.namingEvidence))])
|
||||
const execution = spawnSync(freecad, ['--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), resaveHarnessPath], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
timeout: 180_000,
|
||||
maxBuffer: 40 * 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
FREECAD_ORDERED_PAIR: pair,
|
||||
FREECAD_PAIR_STEP_PATH: stepPath,
|
||||
FREECAD_PAIR_EVIDENCE_PATH: evidencePath,
|
||||
FREECAD_PAIR_OUTPUT_DIRECTORY: temporaryDirectory,
|
||||
PYTHONPATH: `${resolve(sysroot, 'usr/lib/python3/dist-packages')}${process.env.PYTHONPATH ? `:${process.env.PYTHONPATH}` : ''}`,
|
||||
LD_LIBRARY_PATH: `${resolve(sysroot, 'usr/lib/x86_64-linux-gnu')}${process.env.LD_LIBRARY_PATH ? `:${process.env.LD_LIBRARY_PATH}` : ''}`,
|
||||
MATPLOTLIBRC: resolve(sysroot, 'usr/share/matplotlib/mpl-data/matplotlibrc'),
|
||||
MPLBACKEND: 'Agg',
|
||||
},
|
||||
})
|
||||
const output = `${execution.stdout || ''}\n${execution.stderr || ''}`
|
||||
const marker = 'FREECAD_ORDERED_OPERATION_PAIR_RESAVE_RESULT='
|
||||
const markerIndex = output.indexOf(marker)
|
||||
const payload = markerIndex >= 0 ? output.slice(markerIndex + marker.length).match(/\{.*\}/s)?.[0] : undefined
|
||||
if (execution.error || execution.status !== 0 || !payload) fail(`FreeCAD FCStd replay exited with ${execution.status}: ${execution.error?.message || output.trim()}`)
|
||||
const persistence = JSON.parse(payload)
|
||||
if (persistence.status !== 'pass' || persistence.freecadVersion !== '1.1.1' || persistence.gitCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || !Object.values(persistence.checks).every(Boolean)) fail(`FreeCAD FCStd replay failed: ${JSON.stringify(persistence)}`)
|
||||
return persistence
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const classifyFusePair = async (toOperation, pairIndex) => {
|
||||
const pair = `fuse->${toOperation}`
|
||||
const pairId = pair.replace('->', '-')
|
||||
const first = captureStage(module, {
|
||||
operation: 'fuse', toolSize: 5, toolOffset: 8,
|
||||
stage: { stageId: `pair:${pairId}:first`, ordinal: 0, objectId: `pair:${pairId}:base`, objectTag: 1001 + pairIndex * 100, resultObjectId: `pair:${pairId}:first`, resultObjectTag: 1101 + pairIndex * 100 },
|
||||
})
|
||||
const spec = operationSpecs[toOperation]
|
||||
const secondDefinition = {
|
||||
operation: toOperation,
|
||||
objectStep: first.response.resultStep,
|
||||
...spec.nominal,
|
||||
stage: { stageId: `pair:${pairId}:second`, ordinal: 1, objectId: `pair:${pairId}:first`, objectTag: 1101 + pairIndex * 100, resultObjectId: `pair:${pairId}:second`, resultObjectTag: 1201 + pairIndex * 100, namingEvidence: first.namingEvidence },
|
||||
}
|
||||
const second = captureStage(module, secondDefinition)
|
||||
const mutated = captureStage(module, { ...secondDefinition, ...spec.edited })
|
||||
const restored = captureStage(module, secondDefinition)
|
||||
try {
|
||||
const mutationChanged = JSON.stringify(second.report.summary) !== JSON.stringify(mutated.report.summary)
|
||||
const restoredExactly = JSON.stringify(second.report.summary) === JSON.stringify(restored.report.summary) && second.report.historySha256 === restored.report.historySha256 && second.report.namingSemanticSha256 === restored.report.namingSemanticSha256
|
||||
if (!mutationChanged || !restoredExactly) fail(`${pair} mutation did not change and restore native geometry/history.`)
|
||||
const persistence = await persistAcceptedPair(pair, second)
|
||||
return {
|
||||
taskId: `TSN-PAIR-fuse-${toOperation}`,
|
||||
pair,
|
||||
classification: 'accepted',
|
||||
reasonCode: 'native-builders-mutation-naming-and-fcstd-resave-pass',
|
||||
nativeDecision: 'accepted',
|
||||
first: first.report,
|
||||
second: second.report,
|
||||
mutation: { scope: 'second-operation-only', parameter: spec.parameter, trajectory: [spec.beforeValue, spec.editedValue, spec.beforeValue], beforeValue: spec.beforeValue, editedValue: spec.editedValue, restoredValue: spec.beforeValue, changed: mutationChanged, restoredExactly, editedEvidence: mutated.report, restoredEvidence: restored.report },
|
||||
naming: { upstreamEvidenceRestored: Boolean(secondDefinition.stage.namingEvidence), downstreamEvidence: Boolean(second.namingEvidence), jsonRoundtripStable: JSON.stringify(JSON.parse(JSON.stringify(second.namingEvidence))) === JSON.stringify(second.namingEvidence) },
|
||||
persistence,
|
||||
}
|
||||
} finally {
|
||||
for (const capture of [first, second, mutated, restored]) capture.response.result?.delete?.()
|
||||
}
|
||||
}
|
||||
|
||||
const classifications = []
|
||||
for (const [index, operation] of ['fuse', 'cut', 'common', 'rotate'].entries()) classifications.push(await classifyFusePair(operation, index))
|
||||
|
||||
const [artifacts, executorHarness, matrixHarness, resaveHarness] = await Promise.all([
|
||||
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: sha256(content) }
|
||||
})),
|
||||
readFile(executorPath),
|
||||
readFile(resolve(root, 'scripts/freecad-naming-production-matrix.mjs')),
|
||||
readFile(resaveHarnessPath),
|
||||
])
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
baseline: { freecadVersion: '1.1.1', commit: '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d', occtVersion: module.occtVersion() },
|
||||
generatedBy: './npmw run probe:freecad-ordered-operation-pairs',
|
||||
checkedBy: './npmw run check:freecad-ordered-operation-pairs',
|
||||
nativeProbe: {
|
||||
executor: { path: 'scripts/run-freecad-ordered-operation-pair-classification.mjs', bytes: executorHarness.length, sha256: sha256(executorHarness) },
|
||||
matrix: { path: 'scripts/freecad-naming-production-matrix.mjs', bytes: matrixHarness.length, sha256: sha256(matrixHarness) },
|
||||
resave: { path: 'scripts/freecad-ordered-operation-pair-resave.py', bytes: resaveHarness.length, sha256: sha256(resaveHarness) },
|
||||
artifacts,
|
||||
},
|
||||
classifications,
|
||||
summary: { registeredOperations: 19, orderedPairs: 361, classifiedPairs: classifications.length, accepted: classifications.filter(({ classification }) => classification === 'accepted').length, rejected: classifications.filter(({ classification }) => classification === 'rejected').length, unknown: 361 - classifications.length },
|
||||
}
|
||||
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(JSON.stringify({ status: 'freecad-ordered-operation-pair-classification-generated', pairs: classifications.map(({ pair, classification }) => ({ pair, classification })), nativeBuilderRuns: classifications.length * 4, fcstdPhases: classifications.length * 3, output: 'config/freecad-ordered-operation-pair-classification.json' }, null, 2))
|
||||
105
scripts/run-freecad-production-drift-classification.mjs
Normal file
105
scripts/run-freecad-production-drift-classification.mjs
Normal file
@@ -0,0 +1,105 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { buildProductionDriftSnapshot, productionDriftDecisions, same, validateProductionDriftSnapshot } from './freecad-production-drift-classification.mjs'
|
||||
|
||||
const root = resolve(new URL('..', import.meta.url).pathname)
|
||||
const executable = process.env.FREECAD_CMD || resolve(root, '.cache/freecad/install-desktop/bin/FreeCADCmd')
|
||||
const sysroot = resolve(root, '.cache/freecad/sysroot')
|
||||
const harnessPath = resolve(root, 'scripts/freecad-tsn-stage-correlation-oracle.py')
|
||||
const oraclePath = resolve(root, 'config/freecad-tsn-stage-correlation-oracle.json')
|
||||
const outputPath = resolve(root, 'config/freecad-production-drift-classification.json')
|
||||
const fail = (message) => { throw new Error(`FreeCAD production drift classification: ${message}`) }
|
||||
const collectDifferences = (reference, replay, path = '$', differences = []) => {
|
||||
if (same(reference, replay)) return differences
|
||||
if (Array.isArray(reference) && Array.isArray(replay)) {
|
||||
for (let index = 0; index < Math.max(reference.length, replay.length); index += 1) collectDifferences(reference[index], replay[index], `${path}[${index}]`, differences)
|
||||
return differences
|
||||
}
|
||||
if (reference && replay && typeof reference === 'object' && typeof replay === 'object') {
|
||||
for (const key of new Set([...Object.keys(reference), ...Object.keys(replay)])) collectDifferences(reference[key], replay[key], `${path}.${key}`, differences)
|
||||
return differences
|
||||
}
|
||||
differences.push({ path, reference, replay })
|
||||
return differences
|
||||
}
|
||||
if (!existsSync(executable)) fail(`native executable is missing: ${executable}`)
|
||||
|
||||
const [reference, harnessContent, harnessBytes] = await Promise.all([
|
||||
readFile(oraclePath, 'utf8').then(JSON.parse),
|
||||
readFile(harnessPath),
|
||||
stat(harnessPath).then(({ size }) => size),
|
||||
])
|
||||
const execution = spawnSync(executable, ['--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), harnessPath], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
timeout: 180_000,
|
||||
maxBuffer: 80 * 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONPATH: `${resolve(sysroot, 'usr/lib/python3/dist-packages')}${process.env.PYTHONPATH ? `:${process.env.PYTHONPATH}` : ''}`,
|
||||
LD_LIBRARY_PATH: `${resolve(sysroot, 'usr/lib/x86_64-linux-gnu')}${process.env.LD_LIBRARY_PATH ? `:${process.env.LD_LIBRARY_PATH}` : ''}`,
|
||||
MATPLOTLIBRC: resolve(sysroot, 'usr/share/matplotlib/mpl-data/matplotlibrc'),
|
||||
MPLBACKEND: 'Agg',
|
||||
},
|
||||
})
|
||||
const output = `${execution.stdout || ''}\n${execution.stderr || ''}`
|
||||
const marker = 'FREECAD_TSN_STAGE_CORRELATION_RESULT='
|
||||
const markerIndex = output.indexOf(marker)
|
||||
const payload = markerIndex >= 0 ? output.slice(markerIndex + marker.length).match(/\{.*\}/s)?.[0] : undefined
|
||||
if (execution.error || execution.status !== 0 || !payload) fail(`native replay exited with ${execution.status}: ${execution.error?.message || output.trim()}`)
|
||||
const replay = JSON.parse(payload)
|
||||
if (reference.freecadVersion !== '1.1.1' || reference.gitCommit !== '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d' || replay.freecadVersion !== reference.freecadVersion || replay.gitCommit !== reference.gitCommit || replay.status !== 'pass') fail('native baseline or replay status is invalid.')
|
||||
|
||||
const classifications = productionDriftDecisions.map((decision) => {
|
||||
const referenceSnapshot = buildProductionDriftSnapshot(reference, decision.operation)
|
||||
const replaySnapshot = buildProductionDriftSnapshot(replay, decision.operation)
|
||||
validateProductionDriftSnapshot(referenceSnapshot)
|
||||
validateProductionDriftSnapshot(replaySnapshot)
|
||||
if (!same(referenceSnapshot, replaySnapshot)) fail(`${decision.operation} fingerprints differ between the locked reference and independent native replay: ${JSON.stringify(collectDifferences(referenceSnapshot, replaySnapshot))}`)
|
||||
return {
|
||||
...decision,
|
||||
reference: referenceSnapshot,
|
||||
replay: replaySnapshot,
|
||||
replayStable: true,
|
||||
implementationDefect: decision.classification === 'implementation_defect',
|
||||
}
|
||||
})
|
||||
|
||||
const driftCases = reference.driftReplays.length
|
||||
const countClassification = (classification) => classifications.filter((entry) => entry.classification === classification).length
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
baseline: {
|
||||
freecadVersion: reference.freecadVersion,
|
||||
commit: reference.gitCommit,
|
||||
oracleId: reference.baselineId,
|
||||
},
|
||||
generatedBy: './npmw run probe:freecad-production-drift-classification',
|
||||
checkedBy: './npmw run check:freecad-production-drift-classification',
|
||||
nativeProbe: {
|
||||
path: 'scripts/freecad-tsn-stage-correlation-oracle.py',
|
||||
bytes: harnessBytes,
|
||||
sha256: createHash('sha256').update(harnessContent).digest('hex'),
|
||||
independentRuns: 2,
|
||||
},
|
||||
classifications,
|
||||
summary: {
|
||||
driftCases,
|
||||
classifiedCases: classifications.length,
|
||||
classifiedStages: classifications.reduce((sum, entry) => sum + entry.reference.driftOrdinals.length, 0),
|
||||
stableSemantics: countClassification('stable_semantics'),
|
||||
allowedEvolution: countClassification('allowed_evolution'),
|
||||
implementationDefects: countClassification('implementation_defect'),
|
||||
unknown: driftCases - classifications.length,
|
||||
},
|
||||
}
|
||||
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(JSON.stringify({
|
||||
status: 'freecad-production-drift-classification-generated',
|
||||
cases: report.classifications.map(({ taskId, operation, classification, reference: snapshot }) => ({ taskId, operation, classification, driftOrdinals: snapshot.driftOrdinals })),
|
||||
independentRuns: report.nativeProbe.independentRuns,
|
||||
output: 'config/freecad-production-drift-classification.json',
|
||||
}, null, 2))
|
||||
@@ -13,6 +13,19 @@ const oraclePath = resolve(root, 'config/freecad-composite-history-elementmap-or
|
||||
const resavePath = resolve(root, 'config/freecad-composite-history-resave-verification.json')
|
||||
const outputPath = resolve(root, 'config/freecad-recovered-naming-classification.json')
|
||||
const fail = (message) => { throw new Error(`FreeCAD recovered naming classification: ${message}`) }
|
||||
const collectDifferences = (reference, replay, path = '$', differences = []) => {
|
||||
if (same(reference, replay)) return differences
|
||||
if (Array.isArray(reference) && Array.isArray(replay)) {
|
||||
for (let index = 0; index < Math.max(reference.length, replay.length); index += 1) collectDifferences(reference[index], replay[index], `${path}[${index}]`, differences)
|
||||
return differences
|
||||
}
|
||||
if (reference && replay && typeof reference === 'object' && typeof replay === 'object') {
|
||||
for (const key of new Set([...Object.keys(reference), ...Object.keys(replay)])) collectDifferences(reference[key], replay[key], `${path}.${key}`, differences)
|
||||
return differences
|
||||
}
|
||||
differences.push({ path, reference, replay })
|
||||
return differences
|
||||
}
|
||||
if (!existsSync(executable)) fail(`native executable is missing: ${executable}`)
|
||||
|
||||
const [reference, referenceResave, harnessContent, harnessBytes] = await Promise.all([
|
||||
@@ -56,7 +69,7 @@ const classifications = recoveredNamingDecisions.map((decision) => {
|
||||
})
|
||||
validateRecoveredNamingSnapshot(referenceSnapshot)
|
||||
validateRecoveredNamingSnapshot(replaySnapshot)
|
||||
if (!same(referenceSnapshot, replaySnapshot)) fail(`${decision.caseId} fingerprints differ between the locked reference and independent native replay.`)
|
||||
if (!same(referenceSnapshot, replaySnapshot)) fail(`${decision.caseId} fingerprints differ between the locked reference and independent native replay: ${JSON.stringify(collectDifferences(referenceSnapshot, replaySnapshot))}`)
|
||||
return {
|
||||
...decision,
|
||||
reference: referenceSnapshot,
|
||||
|
||||
Reference in New Issue
Block a user