64 KiB
Topological Naming Spec
Status: v2, reviewed. v1 went through adversarial review on 2026-08-08; 27 confirmed findings forced structural changes, recorded in §9. This version is the build target for Phase 1–3 naming work.
The problem: downstream features reference faces and edges of upstream results. Identify them by ordinal index and a sketch edit silently reassigns a fillet to a different edge. The ruling invariants are #4 (identity never derives from index or pointer) and #5 (never silently resolve an ambiguous reference; fail closed).
1. Identity model: provenance, not history alone
OCCT history relates the inputs of one builder invocation to its outputs. It cannot connect two evaluations of the same feature — when a sketch edit re-runs an extrude, the old solid is not an input to anything. Cross-evaluation identity therefore comes from provenance:
- A provenance key describes how an entity came to exist, in terms of
stable upstream identities and roles — never indices. Examples:
extrude side face →
(Generated, sketch-edge E17); extrude start cap →(Role, Start); boolean fragment →(Split, parent key, adjacent tool face keys). - Each feature owns a persisted
NameTable: provenance key →EntityId. It is part of the document state: serialized, cloned into undo snapshots, restored by undo. - On every evaluation the feature computes each output entity's
provenance key (from OCCT history plus operation-specific knowledge
such as
FirstShape()/LastShape()for prism caps). Key present in the table → the entity keeps that id. Key absent → fresh id from the document allocator, recorded in the table. Table entries whose key did not appear this evaluation stay in the table but are dormant — the entity is not in this version, and may return (undo, parameter revert). - Because ids come from table lookups, re-running a feature on identical input reproduces identical ids (determinism, invariant #1), and undo + redo reproduces ids exactly (the table travels with the state).
- Ids are never allocated twice (document allocator is monotonic) and a given id never names two different provenances: keys may go dormant, but a key's id is immutable once assigned.
2. What the façade must report
Per operation, for every entity of the argument shapes and every entity of the result, a history verdict — this is richer than raw OCCT because OCCT's reporting is incomplete and operation-specific:
| Verdict | Meaning | Source |
|---|---|---|
Continued(new) |
same entity, possibly reshaped, exactly one image not shared with any other input | Modified() after global inversion (below) |
Split(new…) |
one entity became several | Modified()/BRepTools_History |
Merged(new) |
several inputs share one image | inversion pass |
Deleted |
affirmatively destroyed | IsDeleted() |
Untracked |
OCCT said nothing; absence of history is not deletion | fallthrough |
Mandatory façade duties, each one a known OCCT trap:
- Global inversion before verdicts.
Modified()is per-input and its lists contain self-images, duplicates, and images shared between inputs. Build the image→sources multimap over all inputs first; an image with several sources is a merge (all sources retire), never N independent "1→1"s. - Compose histories. Any post-processing (
SimplifyResult,ShapeUpgrade_UnifySameDomain, healing) merges its history into the operation's (BRepTools_History::Merge). Every id in the final delta must map to a sub-shape of the final result — the façade validates this closure and fails the operation otherwise. - Supplement the gaps. Primitives report nothing; prism caps only
exist via
FirstShape()/LastShape(); fillet edge history is weak. Each façade operation ships the operation-specific supplement that assigns every result entity either a source-based key or a role-based key. No result entity may be left keyless. - Sub-shape identity within one result version is registry-based
(
TopTools_IndexedMapOfShapewalked once at result registration); TShape pointer identity across operations is meaningless and must never be used — OCCT rebuilds TShapes even for untouched faces.
OpResult (vernier-kernel) will grow to carry verdicts and role-keyed
introductions in Phase 1; its current three maps are the v1 sketch.
3. Persistent references
TopoRef {
picked: EntityId // as originally picked; immutable
resolved: EntityId // last successful resolution; advances
owner: EntityId // feature whose output was picked
fingerprint: Fingerprint // refreshed on every successful resolution
state: Bound | Broken
}
resolved bounds chain-walking: resolution starts from the last known
identity, not from the original pick. state is persisted and latching:
Broken stays Broken until re-pick or until a resolution pass finds the
id alive again (undo healing a break is allowed and expected — see §6).
fingerprint is refreshed on every successful resolution so the fallback
tiers always compare against the entity's latest known geometry, not its
pick-era geometry.
4. Resolution on recompute
Given TopoRef, the new evaluation of owner (its NameTable + verdicts
- registry):
- Alive:
resolvedis present in the new version (its provenance key re-appeared, or its verdict isContinued) → bind, refresh fingerprint. - Deleted: the verdict chain affirmatively deleted it → Broken. History is truth; a lookalike must not overrule an explicit death (the deleted-then-recreated case must break).
- Merged: any path of the verdict chain ends in
Merged→ Broken. Rebinding onto a merged face would alias two distinct picks onto one entity (fixture G3); resolution is per-reference and cannot see the aliasing, so the merge itself is the stop sign. This also covers the mixed case: an entity that both split and merged is treated as merged (fail closed). - Followed: retired by recorded
Split→ candidates are the transitive frontier of successors (follow recorded verdicts to the leaves in the current version; no per-hop commitment). Score candidates (§5) with the follow threshold. Unique winner → bind, setresolvedto it, refresh fingerprint. Otherwise → Broken. A chain that loses track (an unrecorded orUntrackedtail) with no live recorded successor falls through to the orphan tier — history that went silent is not history that said "dead". - Untracked / orphaned: verdict
Untracked, or the owner re-ran from scratch (no verdicts connect old to new). Gated on the owner's own table (below): if the owner re-ran and this entity's key went dormant in it, the answer is Broken(Dormant) and the tier does not run. Otherwise candidates are all entities of the same kind in the new version ofowner's output. Score with the orphan threshold (stricter, and requires a margin). Unique winner → bind + advance + refresh. Otherwise → Broken.
The dormant-key gate (decided 2026-08-10; was §10's open question).
The orphan tier exists to rescue a reference when no verdict connects
old to new — the canonical cause being that the owner's NameTable is
absent, e.g. a lost store. It is not a general similarity search.
When the owning feature re-ran and produced a live table, and the
referenced key is simply not live in that table, that is not missing
evidence: it is affirmative evidence that the entity is gone, of the same
kind as tier 2's Deleted. Overruling it with a similarity score is
exactly the wrong-but-plausible assignment invariant #5 forbids, and it
is worse than the lost-store case, because the evidence of death is
present and being ignored. So:
- the tier is reachable only when the owner's table cannot account for the entity — absent, or present but never having assigned that id (a table rebuilt after a lost store knows none of the stale ids, which is precisely the case the tier was written for);
- only keys whose dormancy is conclusive may veto (added 2026-08-10,
review finding). Dormancy stands in for "the owner re-ran and did not
produce this entity", and that substitution is valid only if the owner
would have re-minted the same key had the entity survived. It holds
for
Role(a constant of the operation) and forGenerated(anchored on a sketch curve, a document identity outside the body's topology that resolution never rebinds). It fails for every key built from resolved upstream ids —FromFace,FromEdge,Split,Merged— because an upstream rebind re-mints the key underneath a living entity: a cut that splits face A and rebinds 101 → 401 makes a fillet mintFromEdge{401,102}where it mintedFromEdge{101,102}, so the old key goes dormant while its band is still there on the same edge at the same radius. Vetoing there would lose a reference that must survive, which is the worse half of invariant #5, so those kinds keep the tier and let the fingerprint decide. The classification isProvenanceKey::dormancy_is_conclusive, matched exhaustively so a new key kind must decide explicitly; - the tier has two entry points and both are gated: tier 5 proper
(no verdict at all connects old to new) asks the owner about
resolved; tier 4's fall-through (recorded hops that then lost track) asks about the unrecorded tail, which is where the chain actually ended and what the tier would be rescuing. The test over a tail isall, notany— one path the owner knows nothing about keeps the tier open for the whole reference, keeping the narrowing at its narrowest; - "not live" is judged against the owner's own new version, never against the running body: a face its owner still produces but a downstream boolean consumed is not dormant and keeps the tier;
- recorded history outranks dormancy, so tiers 2–4 are untouched. A key that split is expected to go dormant while its fragments live;
- the resulting
Brokencarries its own reason (Dormant), distinct fromBelowThreshold/Ambiguous, because nothing was inconclusive: the user must re-pick, not re-tune.
A threshold nudge would not have worked. Both milestone gates delete a
profile curve and leave a coplanar lookalike behind — check_m1_profile_ extrude's scores ≈0.885, check_m2_revolve's ≈0.975 with the runner-up
0.35 below it — so both sit above the 0.85 threshold and clear the 0.10
margin. Their breaks are still structural today: the production caller
(resolve_face_ref) passes both fingerprint: None — nothing is scored,
so nothing can score above a threshold — and OwnerTable::Absent, so the
gate is unreachable from compile_document and the observed reason is
NoFingerprint, not Dormant (measured). The gate is what will make those
breaks policy, and only if the owner is plumbed in the same change as
TopoRef fingerprint persistence: persistence alone opens the orphan tier
with the owner still absent, and both gates go red with the lookalike
silently taking the reference.
Broken marks the referencing feature failed in the timeline with the ref highlighted for re-picking. A wrong-but-plausible assignment is worse than an error, because the user ships the part.
Same-domain unification (M2-3, 2026-09-06, Option C — decisions-M2-M3.md):
with simplify on, a result face with exactly one target-input source and one
or more tool-input sources is the target face's own image — Continued when
it is that face's only image, a Split fragment otherwise — and the absorbed
tool faces are Merged into it and retire. Two or more target faces in one
output, or tool faces with no target face, are Merged{parents} with a minted
successor. No aliasing: a reference to an absorbed tool face breaks Merged;
a reference to the target face follows it. This is what push_pull_face
already gives a wall by construction (its prism sides reach the kernel as
covered edge rows), so the boolean agrees with the push/pull rather than
carrying a second rule. The mandatory adversarial fixture is
a_boss_flush_with_a_wall_keeps_the_walls_identity_and_breaks_the_boss_face;
disabling the promotion turns it, g3, both mirror tests and the bridging
fixture red.
5. Scored fingerprint
v1's pass-all-equality rules could never match a split fragment or any edited geometry — matching is a similarity job, per the brief's "best candidate above a confidence threshold". Hard gates first, then a score in [0, 1]:
Gates (must pass or the candidate is out):
- kind equal (face/edge/vertex);
- surface/curve type equal after canonicalization — the façade
simplifies representation (offset-of-cylinder → cylinder, planar
B-spline → plane,
GeomConvert-style) before typing, so construction path does not decide identity.
Score = weighted sum (weights fixed in code, one module):
- measure term:
1 − |a_c − a_s| / max(a_c, a_s)(area for faces, length for edges; term omitted for vertices); - centroid term:
1 − min(1, d / D)wheredis centroid distance andDis the owner output's bounding-box diagonal at evaluation time — scale-relative, not absolute; - adjacency term: Jaccard similarity of neighbour multisets (below).
Thresholds (named constants; changing them is a spec change): follow tier ≥ 0.60; orphan tier ≥ 0.85 and best − second ≥ 0.10. Ties or sub-threshold → Broken. Symmetric candidates (equal scores) must break rather than tie-break — that is the invariant, not a limitation.
Frame: centroids are stored in the owner feature's definition frame — the placement frame of its defining sketch at evaluation time; for features without a sketch (primitives, booleans), the part frame. Rigid motion of the whole part or of the owning sketch moves the frame with the geometry, so fingerprints survive it.
Encoding (persisted, versioned): Fingerprint carries a format
version byte. Adjacency multiset entries are (kind: u8, canonical type: u8) pairs from enums pinned in vernier-kernel (explicit discriminants,
append-only), sorted lexicographically and stored in full
(count-prefixed pairs) — the Jaccard term needs the multiset, so a
digest cannot replace it. An empty multiset on either side means
adjacency was not recorded for that snapshot (unknown, not isolated):
the term is omitted symmetrically and the remaining weights
renormalize. Vertices fingerprint on centroid + adjacency only. Any
encoding change bumps the version;
resolution treats a version mismatch as fingerprint-absent (orphan tier
unavailable → history tiers only), never as garbage comparison.
Noise floor: tolerances/terms must sit above OCCT's own noise (boolean tolerance inflation to ~1e-4 mm, section-curve re-approximation) and the sketch solver's convergence epsilon. The no-op fixture (§8, G1) exists to measure this; the score terms above are scale-relative for exactly this reason. The centroid scale D (the owner output's bounding-box diagonal) is captured by the evaluation itself — never hand-estimated by callers.
Measured floor (M2-4, 2026-09-06): δ* = 5e-7 mm is the smallest flush gap
OCCT's own confusion leaves open (2e-7 closes; two boxes,
measure_flush_wall_noise), and a fuzzy value closes a gap iff it is ≥ the
gap. FUZZY_FRACTION = 1e-7 of the TARGET's bounding-box diagonal
(merge.rs): floor 2δ*/52.2 = 1.9e-8 on the fixture, ceiling ~6e-7 because
OCCT's fuzzy inflates result tolerances and the tolerance-inclusive Bnd_Box
— this section's scale D — grows ~1.6× the fuzzy value against the
split-rebind fixture's 1e-6 pin on D. A fuzzy fuse FILLS a gap: V = V_sum +
δ·A_contact, not V_sum (the flush-wall fixture asserts 22500.000225, and the
bare sum is off by 1e-8, ten times the golden gate). The floor is met for
bodies with D ≥ 10 mm and not below: δ* is absolute
(Precision::Confusion() is not scale-relative) while the fraction is not, so
under D ≈ 5.8 mm the default drops beneath δ* itself — §10 carries the
question.
6. Undo, redo, and retirement
Deltas, verdicts, and NameTables are document state: cloned into undo snapshots, restored by undo. "Retired" is therefore scoped to a document state, not global history: undoing the operation that split a face restores the NameTable in which its id is live, and references heal. Redo re-executes and reproduces identical ids via the restored table. What is global-forever is allocation: an id number is never handed out twice, so a stale id from an abandoned redo branch can dangle but can never collide.
Amendment from store pruning at save (M2-7, lane D, 2026-09-07). Retirement is scoped to
the session; the file carries only what the document can name. The naming store prunes ON
PERSIST, never in memory: NamingStore::to_json_for(document) writes only the tables whose
feature is in the timeline, only the face records whose holder is and whose payload still names
the picked face (FeaturePayload::face_refs, the same list the load walk holds to the allocator
ceiling — which is also how a sketch's plane face came to be range-checked at load, as its doc
comment already claimed), and only the edge records whose holder is and whose faces both are.
Slots are never pruned by count — a live pattern's dormant instance tables are written, and
regrowing it from the file revives their ids. In memory nothing is pruned, so undo-then-redo in
a session reproduces ids through the retained tables exactly as this section asks; the stated
cost is that a document saved after an undo and reopened has no memory of the undone feature,
and redoing it in the new session would mint fresh ids (there is no redo across a reload). A
record whose owner table was pruned but whose holder survives is written with its owner intact
and breaks BrokenReference(picked, OwnerGone) on the next compile after reload — loud, never
the orphan tier, and the document still opens for a re-pick.
7. Determinism requirements
Resolution outcome is a pure function of (TopoRef, verdicts, NameTables,
candidate set) and must be candidate-order independent — required
explicitly because candidate enumeration order is OCCT traversal order
laundered through the allocator, which varies across OCCT versions. No
tie may be broken by id, position, or iteration order. Diagnostics that
list candidates (Broken-ref reports in --json-report) sort by score
then by provenance-key bytes, never by raw id.
8. Fixtures
v1 scheduled fixtures "day one of Phase 1" that required Phase 2–4 features. Corrected: the resolver is a pure function, so its semantics are testable in Phase 1 with synthetic inputs; geometry fixtures are tagged with the phase that can first build them.
Phase 1 — synthetic resolver tests (no kernel features needed):
- R1: hand-built verdict chains exercise every §4 tier, including transitive frontier following and Deleted → Broken.
- R2: scored matching selects the correct split fragment; equal-score symmetric candidates → Broken deterministically under candidate-order permutation.
- R3: NameTable reuse: same provenance keys across two synthetic evaluations → identical ids; dormant keys revive with their old ids.
- R4: fingerprint encoding golden: byte-stable across runs, version mismatch → orphan tier disabled, not garbage.
Phase 1 — geometry (primitives + booleans exist):
- G1 (no-op recompute): box − cylinder evaluated twice from identical input → identical ids, all refs Alive, fingerprint drift measured and asserted under the noise budget.
- G2: box − cylinder, cylinder moved: side-face refs survive (provenance), consumed-face ref → Broken; face split by moving the cylinder to the edge → follow tier picks the right fragment.
- G3: fuse two boxes with coplanar faces: merge detected by inversion (never two live ids on one face); refs to both sources → Broken.
Phase 3–4 — parametric (sketch → extrude → fillet exist):
- P1: fillet edge survives a sketch width change (provenance keys from sketch entities).
- P2: sketch edit changes face count → deleted side-face ref Broken, surviving refs intact.
- P3: symmetric part re-run from scratch → indistinguishable candidates → Broken, under candidate permutation.
- P4: whole-part and sketch-plane rigid motions → all refs survive.
9. Deviations and v1 → v2 changes (review 2026-08-08)
- Added the entire provenance/NameTable mechanism — v1 had no cross-evaluation identity at all; every recompute would have orphaned every reference.
- Replaced pass-all-equality matching with gated scoring — v1's own split fixture was mathematically unsatisfiable under its rules; this also restores the brief's confidence-threshold model, superseding v1's silent deviation from it.
- Added history verdicts with
Untracked— OCCT's silence was being conflated with deletion; affirmative deletion now breaks immediately. - Merges detected by global inversion — per-input "single image" is not a 1→1 test.
TopoRefgainedownerand advancingresolved; fingerprints refresh on success — v1's candidate scope was uncomputable, chains unbounded, snapshots stale by construction.- Undo semantics defined (state-scoped retirement).
- Pinned frames, encoding bytes, versioning, vertex rules; fixtures re-scoped to buildable phases.
Amendments from the implementation review (2026-08-08, post-G-fixtures):
Mergedbreaks instead of following (§4 tier 3): v2's §4.3 had merged paths rebinding through the frontier, which fixture G3 contradicted — both parents would alias onto one face. Mixed split+merge fates are merged, i.e. Broken.- Unrecorded tails reach the orphan tier even when earlier hops were recorded (§4 tier 4) — a recorded hop must not foreclose matching for an id history later lost track of.
- Adjacency stored as full multiset, not an FNV digest (§5): the Jaccard term is uncomputable from a digest; empty-side omission rule added.
- Split sibling ranks (§10): colliding fragment keys disambiguate by geometric centroid order instead of minting unstable fresh ids.
- History rows are deduped before inversion; duplicate OCCT Modified entries must not fabricate singleton merges. Two result faces claiming one identity is a hard evaluation error, never a silent overwrite.
Amendment from the general-profile prism (2026-08-09):
- Prism side faces landed on
Generated { source: <sketch curve> }and the caps on the reservedPrismStart/PrismEndroles, exactly as §1 specified — no key-format change was needed. Each profile curve carries a documentEntityId, so the N side keys are pairwise distinct by construction and no sibling rank is involved: a side face's identity is a function of which curve drew it, not of centroid order or of OCCT's face enumeration. Verified adversarially — rotating the chain's starting curve permutes the result's face tokens (probe-confirmed) and moves no id. A segment that generated two faces would need a rank the key shape cannot express; the façade fails closed there instead (§10's open question, not §1's mechanism). - Every extrude goes through the prism, including a lone circle — the
circle→cylinder fast path is gone. Keeping it would have made a face's
identity depend on how the profile happened to be authored: the same
solid drawn as one circle or as two half-arcs would carry
Role{CylinderLateral}in one case andGenerated{curve}in the other, and a saved reference would resolve plausibly and wrongly against whichever it met — invariant #5's failure mode, invisible to every geometry-only test. A circle profile is one closed circular edge, so the topology (1 lateral + 2 caps), volume, area and centroid are identical toBRepPrimAPI_MakeCylinder; the cost was a one-time id churn for circle extrudes, taken deliberately behind the save-format version. TheCylinderLateral/CylinderBottom/CylinderToproles remain, but onlyevaluate_cylinder(the sample part and the primitive goldens) mints them now.
Amendment from the dormant-key review (2026-08-10):
Generatedwas carrying two provenances and now carries one. Swept side faces areGenerated { source: <sketch curve> }; faces an operation generates from a face of the input body — a shell's inner wall, a draft's new wall, a push/pull's side wall — are nowFromFace { source: <input face> }. The two anchor on different kinds of identity: a sketch curve is a document entity that resolution never rebinds, whereas an input face id is itself a resolution output. Only the first is stable enough for §4's dormancy veto, and one variant could not answer for both. The split is behind the save-format version: an old store'sGeneratedentries for shell/draft/push-pull walls no longer match, so those faces mint fresh ids once on first recompile.
Amendment from the profile revolve (2026-08-10):
- No key-format change was needed for the revolve either. Side faces
are
Generated { source: <sketch curve> }and the caps take reserved roles (RevolveStart/RevolveEnd), exactly as the prism's amendment above — one profile curve, one key, distinct by construction, with no sibling rank involved. Chain rotation permutes OCCT's face tokens and moves no id, the same adversarial check the prism carries. - The one new rule is angle-dependent: caps exist iff the sweep is
below a full turn. At 2π there are no cap faces, so the two role keys
are not minted at all, and the façade's
FirstShape()is not even a sub-shape of the result — the prism's "a cap token is never 0 on success" contract could not carry over, which is whyRevolveResultis its own type with an explicit on-axis flag. Across the boundary the cap ids behave exactly as §1 promises: minted once on the first partial sweep, dormant at the full turn (still in the table, absent from the live set), and revived with the same ids on the next partial one. Pinned by a 2π → π/2 → 2π → π/2 cycle against a singleNameTable. - A
Generatedkey that is legitimately never minted — absence by construction rather than by death. A curve lying on the axis sweeps nothing: not a degenerate face, no face. It therefore has noEntityIdever, which is a third state beside "live" and "dormant" and is new for the substrate: §4 tier 5's dormant gate reasons about a key that stopped being live and still holds an id, whereas this key never held one and nothing can hold a reference to it. Two supports keep it from reading as a fault. The façade requires an independent on-axis classification before it will accept zero images from a segment (images ∈ {0, 1}, and 0 only for a segment it classified on-axis), so "no face" is never OCCT's silence being promoted to a rule — §2's "absence of history is not deletion" still holds. And the orchestrator raisesCompileWarning::CurveOnAxis, so the absence is stated rather than inferred from a missing lookup. Both halves are asserted in the M2 gate.
Amendment from the loft (2026-08-10):
- A new key kind,
Bridged { sources: BTreeSet<EntityId>, sibling }, for a face no single curve owns. A ruled loft's side face is bounded by the corresponding curve of two adjacent sections, so §1'sGenerated { source }cannot name it.Bridgedis added, never a widening ofGenerated: every persistedGeneratedkey promises exactly one generating curve, and turning that field into a set would silently re-read every stored key. Tag byte 6 into_bytes, laid out likeSplit's — rank first, then the set in its own id order, which is what makes the encoding canonical without a sort at the call site. - Its dormancy is conclusive (§4 tier 5).
sourcesholds sketch-curve ids — document identities outside the body's topology that resolution never rebinds — which is preciselyGenerated's anchor and precisely whyGeneratedanswerstrue. TheFromFace/FromEdge/Split/Mergedhazard, an upstream rebind re-minting the key underneath a living face, cannot arise. Concretely: a dormantBridged{c₁, c₂}means either a curve left its profile or the two sections stopped being adjacent, and in both cases the span is genuinely gone. Answeringfalsewould let the coplanar lookalikes §10 already measured (0.885, 0.975) take the reference. - The roadmap's premise about the history was wrong, and the correction
is the interesting part. It assumed a middle section's edge generates
two faces and an end section's one. Measured on OCCT 7.9.3:
Generated(edge)is column-keyed, not span-keyed — every section's edge, first, middle and last alike, reports every face in its vertical strip.GeneratedFace(edge)is the span-resolving accessor (the header says so: for a ruled loft it returns the face generated by each edge "except the last wire"), and it is what the façade reads. - The lower/upper pairing is OCCT's to report, never the ordinal one.
CheckCompatibilityre-origins and re-orients the sections before lofting, so section 1's segment 2 routinely bounds the same face as section 0's segment 0. Reading the pairing off position would name the right faces only for sections the user happened to draw in step — and every volume, area and face-count assertion passes either way, so only a fixture whose chains disagree can see it (profile_loft.rs'sside_faces_are_keyed_by_the_pair_occt_reports). This is the paragraph the next person to add an N-input operation should read first:sourcescannot be read off ordinal position, for any operation, without a measurement saying it can. - Ruled and smooth have distinct provenance. A ruled span keeps
Bridged { sources: {lower, upper}, sibling }. A smooth loft makes one lateral face per complete column spanning every section, so it usesSmoothColumn { sources, sibling }(tag byte 11) with the full OCCT-reported section-edge set. The evaluator refuses a column unless every section contributes exactly one edge and every reported face maps unambiguously. Adding/removing/reordering a section therefore deliberately re-mints smooth lateral ids; switching ruled/smooth is likewise deliberate identity churn. Naming-store v6 makes that new spelling explicit rather than widening persistedBridgedkeys. siblingships at 0 and is unexercised, deliberately. The façade refuses any span claiming more than one face, so a source pair names at most one face per operation. The field is in the key anyway because adding it later would change the bytes of every persistedBridgedkey — the same argument that putsiblinginSplit. Owed:assemble_sourcehas no centroid ranking (onlyassemble_from_historydoes), so if a future operation makes collisions real, the ranking loop must be lifted into a shared helper, not copied.- Loft caps are mandatory, unlike the revolve's: a loft's two ends are
distinct sections by construction, so there is no closing-on-itself case
and the reserved
LoftStart/LoftEndroles are always minted on success.
Amendment from the sweep (2026-08-10):
Bridgedcarries the sweep too, and this is the case it was named for. A sweep's side face is the product of exactly one profile curve and one path segment, sosourcesis{profile curve, path curve}— the same two-element set of sketch-curve ids a loft's span carries, hence the same key kind, the same tag byte, and the samedormancy_is_conclusiveanswer. No key-format change was needed; the second consumer is the evidence that the shape was the right one rather than a loft-specific one.- The pair is read out of history here too, by a different accessor.
BRepOffsetAPI_MakePipeShellhas no two-argumentGenerated(spine, profile), so the face a pair produced is the unique element of the intersection ofGenerated(profileEdge)andGenerated(pathEdge)— measured to be a singleton in every configuration probed (straight, arc, sharp corner, closed path). Zero images is a history gap and two would need a rank the key ships at 0; both fail closed. Same discipline as the loft'sGeneratedFace, and the same rule the loft paragraph above states generally:sourcesis never read off ordinal position without a measurement saying it can be. - Sweep caps are optional, on the revolve's rule rather than the loft's. An
open path has both
SweepStartandSweepEnd; a closed path has neither, because the solid closes on itself and no cap face exists — and there tooFirstShape()comes back non-null while not being a sub-shape of the result, so the tokens are read asFindIndexand never asIsNull(the M2 full-turn situation verbatim). Across a closed → open → closed cycle against oneNameTablethe cap ids go dormant and revive unchanged, pinned byprofile_sweep.rs'sa_closed_path_has_no_cap_keys. - The roles exist because one profile serves both ends. A sweep's profile
curves generate side faces, so no source-based key can name a cap at all,
let alone tell the two apart — the argument that put
PrismStart/PrismEndandRevolveStart/RevolveEndin the enum, now with a third instance. - A closed path does not by itself guarantee zero cap tokens, so the two are
cross-checked. A closed rectangular path reports two cap tokens and builds
a shell
BRepCheck_Analyzerrejects (measured), so the façade takespath_closedas a statement, validates it against the chain, and refuses a result whose tokens disagree with it — never inferring closedness from the zeros.
Amendment from the vertex blend (2026-08-30):
- A new key kind,
VertexBlend { faces: BTreeSet<EntityId>, sibling }, for a face no single edge owns. A constant-radius fillet chain rounding 3 or more edges that converge at one vertex builds a corner-cap face bounded by all of them at once, soFromEdge's face PAIR cannot name it — the same reasonBridgedcould not be a widening ofGenerated.VertexBlendisFromEdge's own idiom widened from a pair to a set: tag byte 7 into_bytes, laid out exactly likeFromEdge's (rank first, then the set in its own id order). - Its dormancy is NOT conclusive (§4 tier 5), unlike
Bridged's.facesholds resolved input-body face ids — the same anchorFromEdge/FromFace/Split/Mergedhave, and precisely why those answerfalse: an upstream edit can split a bounding face and rebind it onto a successor, making the SAME corner mintVertexBlend{{401,102,205}}where it once mintedVertexBlend{{101,102,205}}— a fresh key for the same corner, at the same radius, while the old key goes dormant with the face it named still visible. Vetoing on that would destroy a reference that must survive. - The gap was measured, not assumed, before any façade code was written.
A throwaway probe filleted the 3 edges converging at one box vertex in a
single chain and got
EvaluateError::Unattributed— OCCT builds the corner face regardless (ChFi3d_Builderhas dedicated internal methods for exactly this case,PerformThreeCorner), but nothing askedGenerated()the vertex question.BRepFilletAPI_MakeFillet::Generated(vertex)— its own header names the parameterEorV, "edge or vertex" — was then probed directly against that same box and reported exactly the one result-face token the edge-only walk had left unattributed, and nothing else. - The vertex walk runs BEFORE the edge walk in
record_generated, and this ordering is load-bearing, not stylistic. Both loops share oneclaimedset; walking vertices first means a vertex-owned face is claimed before any adjacent edge's ownGenerated()list gets a chance to also mention it (measured: it does not, in every case probed, but the ordering removes the question rather than resting on that observation holding forever). - Scoped to constant-radius fillet only, via a plain
include_verticesparameterrecord_generateddefaults tofalse— shell, draft, push/pull, chamfer and variable-radius fillet all build no vertex-owned geometry to begin with, so the flag costs them nothing, but chamfer's asymmetric per-edge modes (two distances, or a distance and an angle) have no settled answer for which value applies in which direction at a shared corner and are deliberately not attempted. - Sibling ranking is not a new mechanism — it is the existing one, for
free.
explicit_generation_keysmints everyVertexBlendrow atsibling: 0as a placeholder, exactly as it already does forFromEdge;assemble_from_history's centroid-rank grouping treats both key kinds identically onceVertexBlendis added toranked_key's match, so two corners colliding on the same face set (not exercised by any fixture yet — every corner of a box touches a distinct combination of its six faces) get disambiguated the same way two edges sharing a face pair already do. - The closed form for the gate fixture was derived, then confirmed exact
against a standalone probe (0.0 difference at double precision) before
being pinned. Three mutually perpendicular edges of equal fillet radius R
meeting at a corner are tangent-continuous only if the corner cap is a
sphere of radius R — the unique surface tangent to all three cylindrical
fillet surfaces at once — making the exact volume
DX·DY·DZ − (1 − π/4)R²(DX+DY+DZ) + (2 − 7π/12)R³, the naive independent- edge total re-anchored against the true, once-counted octant-of-a-sphere corner (vernier-kernel/tests/fillet_corner.rs). NamingStore's own save-format version bumped to 2 for the new persisted key vocabulary — the same disciplineBridgedset at M4 (§6): a version-1 store predatesVertexBlend, so reading it as version 2 would be a claim about a vocabulary it never had.
9b. A sketch plane on a face: identity is not enough, orientation must be ours too
(Decided 2026-08-13 with sketch planes, card 38f55d09 phase D. Recorded here because it is a naming decision wearing geometry's clothes.)
A sketch attached to a planar face needs an origin, a normal and an in-plane X direction. The face supplies the first two. Nothing supplies the third, and the choice is load-bearing rather than cosmetic: a frame with the correct normal, still right-handed, but a different in-plane X rotates the sketch's own axes inside the plane — measured at 10 mm of centroid movement on a test block, and on a real part it is a tab pointing up versus sideways.
The obvious source is rejected. OCCT parametrises every planar face, so
BRepAdaptor_Surface(face).Plane().Position() hands back a gp_Ax3 for free,
and it is what the face means to the kernel. It is not used, because OCCT
re-derives that parametrisation when it rebuilds a face. FreeCAD's own
topological-naming documentation is a long account of features breaking for that
family of reasons, and its standing advice to users is to prefer datum planes
over faces precisely because of it.
This project spent §1–§5 making face IDENTITY survive an edit. Taking ORIENTATION from something a rebuild re-derives would give the identity back with one hand and lose the sketch with the other. A durable reference to a face is worth nothing if what we read off the face is not durable.
What is used instead: the world axis least parallel to the normal, projected
into the plane (OCCT's own (N ^ Vx) ^ N, adopted verbatim as the mechanism).
The origin is the world origin projected onto the plane, not the face centroid,
which moves when the face's outline changes. Together these make the frame a
pure function of the plane — not of the face's outline, not of OCCT's
parametrisation, not of anything a rebuild re-derives.
The guarantee that buys, and it is checkable: any edit that does not rotate the
face leaves the frame exactly unchanged. l1-bike-light-mount asserts the
drafted face's normal is bit-identical across an upstream edit for that reason.
The residual, named rather than hidden. "Least parallel" is discontinuous: a
normal that rotates past the point where a different world axis becomes the least
parallel one gets its X snapped 90°. The honest fix when it bites is a second
reference — an edge — which is what a user picking "this edge is X" is doing in
any real CAD package, not a cleverer tie-break. l1-bike-light-mount reports the
margin to that tie so a future part that narrows it fails loudly instead of
silently rotating.
Amendment from the angled construction plane (M3-9). SketchPlane::Angled { sketch, from, to, angle } resolves to a frame that is a pure function of the
plane, exactly as this section requires, but by a different route from Face —
and the difference retires §9b's own residual for this variant while making a
WEAKER promise than §9b's, which is the honest reading and is stated here rather
than inherited by silence.
The hinge is TWO POINT IDENTITIES of an earlier sketch
(MirrorPlane::SketchLine's shape), so the frame is
origin = the hinge's `from` point, in world
xdir = normalize(to - from)
normal = the base plane's normal rotated about xdir by `angle`
and none of the three reads anything OCCT re-derives — no face parametrisation, no centroid, no tessellation. That is the property this section is about.
What this does NOT promise. §9b's guarantee for a face plane is "any edit
that does not rotate the face leaves the frame exactly unchanged". The
equivalent here is narrower: origin and xdir are read from the base
sketch's SOLVED point positions, so a dimension edit in the base sketch, or a
drag of either hinge point, moves and turns this plane. That is the feature,
not a defect — a hinged plane whose hinge is a modelled line is supposed to
follow it, the way a sketch on a face follows the face. The checkable guarantee
is therefore:
an edit that changes neither hinge point's solved position nor angle
leaves the frame bit-identical
pinned in both directions by only_an_edit_to_the_hinge_moves_a_hinged_plane
(vernier-ui/src/compile/tests/angled_planes.rs), and the reason it is worth
stating is that the failure it excludes is the same one §9b excludes: a frame
that drifts under an edit that touched nothing it names.
The tie-break does not run here. Because the hinge SUPPLIES an in-plane direction, §9b's "world axis least parallel to the normal" rule — and its 90° discontinuity — is never reached for this variant. §9b's own honest fix for that residual was "a second reference — an edge — which is what a user picking «this edge is X» is doing in any real CAD package", and a hinge is exactly that reference.
Timeline order is the cycle guard, and it is a COMPARISON. The base sketch
must precede the sketch that carries the plane, and compile/resolve.rs's
angled_frame compares the two timeline positions the way resolve_input
compares a source against its consumer. Asking instead whether the base is
already in the walk's frames map — which the first implementation did — asks
a weaker question, namely whether the base precedes whoever is currently
asking: a consumer sitting below a forward-named base would resolve a plane
that the hinged sketch's own arm had refused, and the same document would build
in one place and refuse in another. A hinge naming its own sketch fails the same
comparison, so there is still no recursion to bound and no depth cap to tune.
a_forward_hinge_is_refused_wherever_the_consumer_sits drives both consumer
placements against one forged document, with the same rows reordered as its
control.
Only the EXTRUDE honours a sketch's placement today. The revolve, loft and
sweep arms build from raw sketch (u, v), which predates hinged planes (it is
equally true of Offset and of World(Zx)) but bites hardest here, since
placement is this variant's entire purpose. Recorded at all three arms and on
the variant itself.
Amendment from reference persistence (M2-1, 2026-09-06)
TopoRef exists as a type and lives in the naming store beside the tables,
keyed (holder feature, picked); payloads keep the bare id — for the crate map
(vernier-doc cannot hold a Fingerprint), the memo's payload digest,
determinism (compile(doc, store) stays pure) and the lost-store rule below.
owner is (feature, slot) — §10's item is closed. Derived from the tables
(injective; an id is minted once), written once, never downgraded: a recorded
owner whose table is gone is Broken(OwnerGone). OwnerTable::Live reads the
owner slot's live set from THIS compile's evaluations (owner_live, restored
through the memo prefix); a table present but unclaimed this compile (a pattern
shrunk past that instance) reads as all-dormant.
resolved is informational: every resolution walks from picked over the
complete verdict chain (§3's "resolution starts from the last known identity"
is retired — a cached start would skip a recorded Merged); stickiness is the
refreshed fingerprint, refreshed on every successful resolution, Alive
included. A pick that has never resolved has no record. Undo heals by identity
without snapshotting the store (§6's deviation stands, stated for references
too). Fingerprints are stored in the WORLD frame (deviation from §5 "Frame");
the frame rule applies the day the orphan tier must survive a rigid motion (P4).
§4's "e.g. a lost store" is retired: a lost store loses the records with the
tables and breaks as NoFingerprint — that is the contract (l2-jig,
lost_store_breaks_fillet_refs_loudly); the tier's production cases are the
re-minted non-conclusive key, a table rebuilt after a partial store loss, and an
Untracked argument.
The milestone gates break as Dormant (measured, asserted by name in m1 and
m2): disabling the owner turns m1-profile-extrude (BelowThreshold, since its
bait measures 0.752 not 0.885), m2-revolve (rebind at 0.875, not 0.975) and
m4-loft (rebind) red, and leaves m3-mirror green on Ambiguous at margin
0.0608 — §10's paragraph closes with those lines. Disabling the fingerprint is
invisible to the gates by construction (the veto precedes the fingerprint check)
and is caught by the compile-level split-rebind test (g2 through
compile_document: 0.834 vs 0.557).
A sketch's plane and projection picks are recorded under the SKETCH and refreshed at every position they are resolved at.
Naming-store version 3 (refs sorted by key, persisted fingerprint codes with the version honoured before codes, counterbore slot 10); version 2 refused by name before the body is parsed. (Superseded on one point by the lane-C amendment below: from version 4 on a version-3 store is MIGRATED forward rather than refused — the M4/M5 decision record's read-up rule — while version 2 stays refused.)
Amendment from booleans and seams (M2-3/4/5, lane B, 2026-09-06)
Same-domain unification is on for every push/pull (identity: Continued; the
prism sides are edge-generated; a pushed 20×20×10 box goes 10 → 6 faces with
every id kept, g4 pins it) and for every boolean merge and hole cut under
Option C (§4's tier-3 clause). Measured movers: selftest m3-mirror 14 → 12,
compile/tests/mirrors' canonical mirror 14 → 12, the app's mirrored starter
block 10 → 6, m2-score-landscape's M3 bait is now the unified rectangle wall
under the source extrude's Generated{top} id, l1-bike-light-mount's
centroid digits at 1e-15. Primary evaluations (prism/revolve/loft/sweep) are
not unified. One-time id churn: any saved document whose booleans produced
coplanar adjacent faces re-mints on first recompile — the tool side's absorbed
faces retire, the target's continue.
The bracket's y-walls do NOT unify, and the reason is angular, not linear
(corrected at the lane review; the first explanation, a ~1e-9 mm offset, was
contradicted by M2-4's own numbers — the unifier's linear tolerance is the
fuzzy value, 3e-6 mm on that body, and the walls stay apart under it): the
mirror plane is solved onto x = 0 and tilted about z by ~1.5e-10 rad, so the
copy's wall normals [±1.479e-10, ±1, 0] miss Precision::Angular() = 1e-12
by ~150×, which no linear tolerance reaches, while the caps' z-normals are
exactly ±1 and unify. A fixture "authored elsewhere, locked onto the plane" is
a near-coincidence generator in its own right.
Seams: one spelling, (t, t) in both EdgeCurveRow and EdgeGenRow — a face
that is its edge's ancestor twice. A free edge (one ancestor, the boundary of
an open shell) is (t, 0) in both, a different fact and outside the façade's
contract, since every shape this project builds is a closed solid; the kernel
refuses such a row (Unattributed) rather than naming the face by a guess. No
operation generates a face from a seam edge today (shell through a cap, draft
of a lateral, push/pull of a cap — tests/m2b_seams.rs). rank_sibling_keys
is the one centroid-ranking rule; the "two seams collide at sibling 0" note
that stood in explicit_generation_keys described a collision that never
existed. Its §7 residual: two fragments with equal centroids fall back to
insertion order (OCCT enumeration), unreachable in every fixture — a future
collision must fail closed rather than rank.
Amendment from edge and vertex references (M2-6, lane C, 2026-09-07)
EdgeRef { faces: [a, b] in pick order, sibling } and VertexRef { faces: set, sibling } (vernier-doc refs.rs) are the persistent forms of §1's index-free
names, resolved through face references: each face resolves through its own
TopoRef record, then the rank is looked up in the version's edge or vertex
survey. The sibling rank is the geometric order shape_edges (curve-midpoint
order) and the vertex survey (position order, tolerance-first per axis)
already define, and it shares their residual — a neighbour crossing the order
re-ranks. faces[0] is the reference face for an asymmetric chamfer; the
canonical lookup key sorts.
An edge reference keeps its own record in the naming store (edge_refs,
store version 4, keyed (holder, faces in pick order, sibling), sorted, written
before refs so refs stays the writer's last field): the edge's fingerprint
— kind Edge, curve class, length, the midpoint or a closed circle's centre, the
two faces' surface classes — and the pair's shared-edge count at the last
successful resolution. Resolution refuses, in this order, when the pair shares
no edge at the recorded rank (EdgeReferenceMissing); when the pair's count
changed; when no rank passes the fingerprint's hard gates; when the two best
ranks tie; and when the stored geometry matches a sibling rank BETTER than the
recorded one (EdgeReferenceMoved { SharedCountChanged | NoRankMatches | Ambiguous | Reranked { best } }): a face pair is not a name, and a rank is not
a proof. "Better than any sibling" rather than a threshold, because two shared
edges of one pair usually differ only by centroid (0.4·d/D) — measured on the
D-prism, the sibling scores 0.854 against the reference's snapshot and a 0.85
orphan threshold would have accepted it. A tie is a margin, not an
equality: EDGE_TIE_MARGIN = 1e-6 on the [0, 1] score (2.5e-6·D on the
centroid term, 14 nm on the D-prism), because two siblings at symmetric,
non-representable offsets from the snapshot score the same up to rounding and
exact bits would bind whichever the rounding favoured — measured under that
mutant, the D re-locked to (−12.1, 0)…(−7.9, 0) about (−10, 0) compiled at
29.98 mm³ with the band on the recorded rank and nothing said so. Refusal
mints nothing: it precedes claim_table, so the holder's tables and the
document's counter are unchanged.
What the check is worth, measured. With the rank-vs-fingerprint
comparison removed, the re-rank fixture (the D shrunk to radius 2, the
referenced vertical now rank 1) COMPILES at 23.56 mm³ — a plausible solid
rounded on the wrong edge, no error of any kind — and the m2-edge-refs gate
reports "the shrunk D compiled: the fillet moved to the other edge". The
first fixture used a D too small to round, so the mutant surfaced as a kernel
failure rather than a wrong body; corrected at the lane review.
An edge's name is geometric: nothing in it says which sketch vertex drew the edge, so an edit that puts another vertex's edge exactly where the referenced one stood resolves to it (same pair, same rank, score 1.0); two candidates both past the bbox diagonal from the snapshot tie and refuse.
First resolution and foreign snapshots. A reference with no snapshot
binds by rank and writes one — weaker than a face's NoFingerprint, stated
rather than promised: a store that lost its edge records rebinds every edge by
rank once. A snapshot written under another fingerprint generation is SAID,
not skipped: the edge rebinds by rank once under CompileWarning:: EdgeSnapshotForeign { holder, edge, version } and the snapshot is rewritten
under this build's generation, while the shared-edge count — a plain number
with no version — is still checked. Not a refusal, because the record's key is
the reference itself and a re-pick would land on the same foreign snapshot: a
refusal would have no way out. Faces differ (a foreign snapshot resolves as
absent and the fallback tiers break NoFingerprint) because a face HAS
fallback tiers; an edge's rank check is its only check.
Vertices. Face-set collisions are ordinary (a D-prism's two chord corners,
a two-arc circle's seam ends) and never fail an evaluation; only a reference
to a position-ambiguous vertex refuses (VertexReferenceAmbiguous), a missing
rank VertexReferenceMissing. A VertexRef carries no snapshot yet, so a
vertex crossing another of its set re-ranks silently — stated, not promised.
The pair form survives at the app boundary only. A face pick means "every
edge these two faces share", and the server expands it into one EdgeRef per
rank in pick order — against the body the fillet is APPLIED TO, the walk's
body just before the fillet (compile_document_observing, an EdgeSurvey of
keys only, never a second handle to a shape the walk releases). Not the end of
the timeline: a fillet replaces every edge its pair shares with a band, so on
a D-prism whose fillet rounds both verticals the final body has that pair
sharing nothing, and expanding there dealt a lone rank 0 that the compile then
bound at the fillet's position to one of the two — a chain silently shrunk
from two to one (corrected at the lane review; measured under the reverted
expansion, both regression fixtures pass the compile and fail only on the
chain's length). A pair sharing no edge at that position is passed through at
rank 0 so the compile refuses it by name — never a silent rank 0. A new fillet
is appended at the end, where the current body IS the body it is applied to.
Persistence. Naming store version 4; version 3 is MIGRATED forward (read
in its own shape, stepped to 4 with no edge records, which is what it
truthfully has), per the M4/M5 decision record and reversing version 3's
refuse-never-translate — from here on a store older than the build is migrated
step by step, never guessed, and one newer than the build, or below version 3,
is refused by name. Document format 25: Fillet.edges: Vec<EdgeRef> and
SketchReference::EdgeVertex/EdgeCenter { edge: EdgeRef, .. } — one spelling
for an edge; a version-24 (a, b) pair is not readable as any single
EdgeRef without the geometry, so there is no migration. The load walk holds
a sketch reference's faces and a fillet's EdgeRef faces to the id ceiling
alike, and refuses a hole past HOLE_POINTS_MAX = 62 (measured from the slot
stride: 11 + 4·61 = 255) by name — a save is a legal document too.
FaceRecord carries axis/radius/semi-angle and material_toward_axis: Option<bool> (C1) so a curved push/pull (M4-3) never reads OCCT's re-derived
parametrisation for the material side; m2-edge-refs measures a slot's bores
at radius 5, axis (±10, 0), Some(false).
Hand-off to pruning (M2-7, lane D). edge_refs is never pruned in
memory; a re-pick through SetFilletEdges leaves the old (holder, faces,
sibling) record, unread because its rank is in the key, and it is written to
disk until to_json_for prunes it. That prune must match the FULL EdgeRef —
faces in pick order AND sibling — against the holder's payload, not the faces
alone: a stale record of the same pair at a dropped rank (a chain shrunk from
ranks {0, 1} to {0}) would survive a faces-only rule, contradicting "the file
carries only what the document can name". FeaturePayload::face_refs should
therefore carry an edge variant for Fillet.edges when lane D writes it.
10. Open questions
-
Boolean fragment provenance keysResolved during Phase 1 implementation: adjacent-tool-face key sets validated on G2. When siblings share parent and tool set (a channel's two strips), the key gains asiblingrank from a deterministic geometric ordering (centroid, total order) — geometry-derived, so identical recomputes reproduce identical ids; not an enumeration index. The earlier fresh-id fallback churned ids on every recompute (review-probe finding) and is gone. -
Score weights (measure/centroid/adjacency): initial 0.4/0.4/0.2, to be calibrated against G1's measured noise before Phase 3.Score weights measured 2026-09 (M2-2) on real kernel geometry — kerneltests/score_calibration.rs, goldentests/golden/score_landscape.json, selftestm2-score-landscape— and KEPT at 0.40/0.40/0.20. Every set of the grid {0.4/0.4/0.2, 0.5/0.5/0, 0.45/0.45/0.1, 0.35/0.55/0.1, 0.55/0.35/0.1} meets the acceptance criteria under the unchanged thresholds 0.60/0.85/0.10 (g2 and the strip's dominant fragment win the follow tier uniquely, the re-minted band scores 1.0 with margin 0.473, the M3 bait loses to the margin rule); the recorded baits choose: M1 bait 0.752 / 0.857 / 0.804 / 0.816 / 0.793, planar swap 0.885±0.058 / 0.857±0.073 / 0.871±0.065 / 0.882±0.120 / 0.860±0.011, M2 bait 0.875 / 0.969 / 0.922 / 0.928 / 0.916, M3 margin 0.061 / 0.076 / 0.068 / 0.084 / 0.053, L24 twin 0.932 / 0.915 / 0.923 / 0.906 / 0.940. Production is the only set that refuses the M1 bait by threshold — adjacency discriminates on a profile SWAP (the lost wall's neighbours change class) even though it buys nothing within one swept solid — while the planar swap and M3 bait fall to the margin. The mirrored residual is now a number: L24's reflected twin scores 0.906–0.940 under every set (d/D 0.17), protected by ≈0.03 of margin and nothing else; mirrored picks with non-conclusive keys remain the open calibration case. -
TopoRef.ownermust become(feature, slot), notfeature. §4 documents the owner as "the feature whose output was picked", which is no longer enough: a pattern instance and — since M3 — a mirrored copy mint the same provenance key (Generated{curve}, the cap roles) in several NameTables of one feature, so a bare feature id names a set of entities rather than one. Nothing is wrong today:NameTables are keyed(feature, slot)so no id collides, andresolve_face_refstill passesOwnerTable::Absent, so nothing reads the owner at all. Not fixable here for exactly that reason — this is a precondition of theTopoReffingerprint-persistence change, not of the feature that made it visible. Pre-existing debt from patterns; mirrors double it. -
Mirrored bodies are the hardest case the score weights owe calibration against (2026-08-10, M3). On a mirrored part every face has a near-congruent twin by construction, so §5's margin rule is systematically load-bearing there and the orphan tier is close to unusable — which is fail-closed, and which the calibration item above must be told about: prisms and revolves are not the worst case.
-
Whether
Untrackedon an argument entity of a boolean should ever reach the orphan tier, or only a narrowed candidate set (unclaimed entities of the result). Proposed: unclaimed-only; decide with G2. -
A key that died in its owner's own table is not an orphanDecided 2026-08-10: it is affirmative death, and the orphan tier is now gated on it — the rule and its exact width are in §4 beside tier 5, the implementation isOwnerTableinnaming/resolve.rs, and the reason it reports isBrokenReason::Dormant. Decided beforeTopoReffingerprint persistence, as the question demanded, because the two gates below would otherwise have gone red on the day persistence landed — silently rebinding onto the wrong wall, which is the failure invariant #5 exists to prevent. Tested directly rather than through the gates: the production caller still passesfingerprint: None, so the tier is unreachable throughcompile_documentand a gate cannot exercise a scored break. The unit tests build M2's control as fingerprints and assert both halves — with the owner's table live the 0.975 lookalike is refused, and with the table absent the identical candidate still binds, so the tier is narrowed rather than deleted. Retained below, as raised, because the arithmetic is the reasoning:The M1 gate's third control swaps an extrude's profile from an obround to a chamfered rectangle, killing the curve a downstream push/pull references. Today that reaches a loud
BrokenReference, but only incidentally:resolve_face_refpassesfingerprint: None, so §4's orphan tier never runs at all. Scored by §5's weights, the replacement chain's coplanar wall — same outward normal, 80% of the area, 2 mm away against a ~23 mm bbox diagonal — comes out at roughly 0.885, over the 0.85 orphan threshold, with the runner-up near 0.61 so the 0.10 margin rule does not save it either. The day fingerprints persist, the gate goes red and the reference silently rebinds onto the wrong wall — invariant #5's exact failure.A threshold nudge is the wrong fix. The real distinction §4 cannot currently draw: this key went dormant in its own feature's NameTable while that feature re-ran, which is affirmative death (tier 2), not the tier-5 case of "no verdict connects old to new" that the orphan tier exists to rescue. Proposal: when the owning feature re-ran and its table is live, a dormant key is
Broken, full stop — the orphan tier is only reachable when the owner's table itself is absent. Decide with the fixture incheck_m1_profile_extrudeand the mirror unit testa_profile_swap_breaks_the_reference_it_removed. (That proposal was adopted verbatim, with one refinement the implementation forced: "absent" means the owner's table cannot account for the id, which covers a table rebuilt after a lost store, and dormancy is judged against the owner's own live set rather than the running body's.) (Narrowed again the same day, by review: the first cut used dormancy as a proxy for "the owner did not produce this entity" across all key kinds, but keys anchored on resolved upstream ids are re-minted by an upstream rebind and go dormant while their entity lives. Only conclusive kinds veto — see §4.) -
Adjacency is not discriminating on a prism, and assuming it is errs permissive. Every side wall of a prism neighbours both caps and two chain siblings, so a rectangle's six faces all carry
[(Face, Plane); 4]; the Jaccard term is 1.0 for every candidate, which underscore's renormalization lifts everything toward the threshold and shrinks the margin by a fifth. Prism side faces are told apart by theirGenerated{curve}keys, never by fingerprint. If the score weights are recalibrated (the item above this one), calibrate them against prism geometry too — the circle-and-box primitives the weights were first chosen against are not representative any more. The revolve is no better (2026-08-10): every side face of the M2 spool neighbours its two chain siblings and, on a partial sweep, both caps, so the Jaccard term is again 1.0 for every candidate —check_m2_revolve's bait reaches ≈0.975 partly because adjacency cannot discriminate. Swept solids as a class, not prisms specifically, are what the weights owe calibration against. The dormant-key gate (§4) makes both milestone gates safe whatever the weights turn out to be; it does not make the weights right. -
Fuzzy floor for small bodies (M2-4, 2026-09-06).
FUZZY_FRACTION · Dsits under the measured δ* = 5e-7 mm once D < 5.8 mm, so the zero-volume trap the flush-wall fixture closes is open again for small parts. Candidate:max(FUZZY_FRACTION · D, 2δ*). Related:bbox_diagonalis tolerance-inclusive (BRepBndLib::Add) and grows with fuzzy, which is what set the ceiling; a tolerance-free box (BRepBndLib::AddOptimal,useShapeTolerance = false) would free it. Planner's call — the spec prescribes the fraction form and the 52 mm fixture.