From ddbb94762271f5ad525d3fa528573de88b6a3f17 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 17:55:02 +0000 Subject: [PATCH 5/5] test(engine): multi-layout mesh-convergence gate (#70 agreement clauses) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MeshConvergenceGateTest — a pure-:common acceptance gate for the delivered convergence stack (#61 defer, #69 self-solve, :498 adopt). Across four varied VALID layouts (raised square, tetrahedron, L-shape, wide-5) it asserts the AGREEMENT criteria of a healthy self-solved mesh: (1) every device reports the SAME constellation (identical pairwise inter-anchor distances within epsilon) — followers first fed a CONFLICTING local range set to prove the #61 defer gate holds; (2) frame == LOCKED (not CONVERGING) for root and followers; (3) engine mesh mode settles in QUORUM with no DEGRADED churn (the pure-engine analogue; Raft-quorum churn scoped out in a comment); (4) degenerate layouts (collapsed, under-ranged) are reported DEGENERATE by the #47 classifier and never lock; a fully-ranged collinear line is flagged MARGINAL and the lock gate keeps it from latching. Plus an accuracy probe against truth SHAPE, gated by a loose PROVISIONAL_TOLERANCE_M = 0.05 (one-line edit for the real bar later). Uses existing public engine/harness APIs only; no commonMain changes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../mofe/engine/MeshConvergenceGateTest.kt | 489 ++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 common/src/commonTest/kotlin/com/aether/mofe/engine/MeshConvergenceGateTest.kt diff --git a/common/src/commonTest/kotlin/com/aether/mofe/engine/MeshConvergenceGateTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/engine/MeshConvergenceGateTest.kt new file mode 100644 index 0000000..084991e --- /dev/null +++ b/common/src/commonTest/kotlin/com/aether/mofe/engine/MeshConvergenceGateTest.kt @@ -0,0 +1,489 @@ +package com.aether.mofe.engine + +import com.aether.mofe.integration.MofeTestHarness +import com.aether.mofe.model.ConstellationFrameState +import com.aether.mofe.model.DeviceId +import com.aether.mofe.model.MeshOperatingMode +import com.aether.mofe.model.MofeConfig +import com.aether.mofe.model.RawRangingMeasurement +import com.aether.mofe.model.Vector3D +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * #70 — MESH-CONVERGENCE ACCEPTANCE GATE (agreement clauses). + * + * Given the delivered convergence stack — #61 (non-root followers ADOPT the root's constellation and + * DEFER their own solve, `defersConstellationToRoot()`), #69 (the root's over-determined self-solve), + * and :498 (a non-leader adopts a leader-replicated ANCHOR position into its engine frame) — a + * HEALTHY self-solved mesh must satisfy, across MANY valid anchor layouts, the AGREEMENT criteria: + * + * (1) every device reports the SAME constellation — pairwise inter-anchor distances identical + * across devices within an epsilon (the capture4 failure was each anchor showing a DIFFERENT + * shape); + * (2) the frame reads LOCKED (not perpetual CONVERGING) once solved/adopted; + * (3) the mesh operating mode is a converged, non-DEGRADED state and never churns THROUGH + * DEGRADED while healthy (see the scoping note on [meshModeIsStableAndConverged]); + * (4) a DEGENERATE layout is DETECTED + REPORTED by the #47 placement classifier + * ([constellationGeometryReport] / [GeometryQuality]) and is NOT silently locked wrong. + * + * Plus an ACCURACY probe: the solved SHAPE (its pairwise inter-anchor distances) matches truth + * within [PROVISIONAL_TOLERANCE_M]. + * + * These are PURE-ENGINE assertions over existing public APIs — they mirror the mechanics proven in + * `ConstellationFrameLockTest` (root self-solve → LOCK) and `ConstellationAdoptionTest` (follower + * adopt + defer), and extend them to an explicit CROSS-DEVICE agreement check over varied layouts. + * No engine (`commonMain`) source is modified. + */ +class MeshConvergenceGateTest { + + // ───────────────────────────────────────────────────────────────────────────────────────────── + // Tolerances + // ───────────────────────────────────────────────────────────────────────────────────────────── + + /** + * How closely the SOLVED constellation shape must match ground truth (max abs difference over the + * sorted pairwise inter-anchor distances, metres). + * + * PROVISIONAL — pending dev tolerance decision (#70). Deliberately LOOSE: with the perfect + * synthetic inter-anchor ranges fed here the current engine recovers the shape to well under a + * millimetre, so 5 cm is generous headroom — it exists so a future, real accuracy bar (measured + * against noisy on-device captures) is a ONE-LINE edit, not a suite rewrite. Tightening it is the + * dev's call; do NOT read this value as the shipping accuracy target. + */ + private companion object { + private const val PROVISIONAL_TOLERANCE_M = 0.05 // PROVISIONAL — pending dev tolerance decision (#70) + + /** + * Cross-device agreement epsilon (metres). Under #61/:498 every follower ADOPTS the root's + * exact positions, so their pairwise-distance signatures equal the root's to floating-point + * noise — hence a tight bound. The clause is not a tautology: each follower below is first fed + * a COMPLETE but CONFLICTING local range set that, absent the defer gate, WOULD re-solve a + * divergent shape (the field failure). The bound proves the gate held. + */ + private const val AGREEMENT_EPSILON_M = 1e-6 + } + + // ───────────────────────────────────────────────────────────────────────────────────────────── + // Layouts under test — each VALID: ≥4 anchors, a complete inter-anchor graph, non-degenerate, + // and exactly one clearly-RAISED anchor so the orientation solve (and thus the LOCK) can engage. + // ───────────────────────────────────────────────────────────────────────────────────────────── + + /** A valid anchor layout: [truth] positions (first id = root at ORIGIN) and the [raised] anchor. */ + private class Layout( + val name: String, + val truth: Map, + val raised: DeviceId, + ) { + val root: DeviceId get() = truth.keys.first() + val peers: List get() = truth.keys.drop(1) + val ids: List get() = truth.keys.toList() + } + + private fun dev(s: String) = DeviceId(s) + + /** 4-anchor SQUARE footprint (2.4 m) with one corner lifted onto a shelf (+0.7 m). */ + private fun squareRaised() = Layout( + name = "square-raised(4)", + truth = linkedMapOf( + dev("A1") to Vector3D(0.0, 0.0, 0.0), + dev("A2") to Vector3D(2.4, 0.0, 0.0), + dev("A3") to Vector3D(2.4, 2.4, 0.0), + dev("A4") to Vector3D(0.0, 2.4, 0.7), + ), + raised = dev("A4"), + ) + + /** 4-anchor TETRAHEDRON with genuine elevation (apex +1.7 m). */ + private fun tetrahedron() = Layout( + name = "tetrahedron(4)", + truth = linkedMapOf( + dev("A1") to Vector3D(0.0, 0.0, 0.0), + dev("A2") to Vector3D(2.6, 0.0, 0.0), + dev("A3") to Vector3D(1.3, 2.3, 0.0), + dev("A4") to Vector3D(1.3, 0.8, 1.7), + ), + raised = dev("A4"), + ) + + /** 5-anchor L-SHAPED room, one anchor raised (+0.9 m). */ + private fun lShape() = Layout( + name = "l-shape(5)", + truth = linkedMapOf( + dev("A1") to Vector3D(0.0, 0.0, 0.0), + dev("A2") to Vector3D(3.0, 0.0, 0.0), + dev("A3") to Vector3D(3.0, 1.4, 0.0), + dev("A4") to Vector3D(1.4, 1.4, 0.0), + dev("A5") to Vector3D(1.4, 3.4, 0.9), + ), + raised = dev("A5"), + ) + + /** 5-anchor wide room, four flat corners + a central raised anchor (+1.4 m). */ + private fun fiveAnchor() = Layout( + name = "wide-5(5)", + truth = linkedMapOf( + dev("A1") to Vector3D(0.0, 0.0, 0.0), + dev("A2") to Vector3D(4.5, 0.0, 0.0), + dev("A3") to Vector3D(0.0, 4.5, 0.0), + dev("A4") to Vector3D(4.5, 4.5, 0.0), + dev("A5") to Vector3D(2.0, 2.5, 1.4), + ), + raised = dev("A5"), + ) + + // ───────────────────────────────────────────────────────────────────────────────────────────── + // Shared drivers (mirror ConstellationFrameLockTest / ConstellationAdoptionTest mechanics) + // ───────────────────────────────────────────────────────────────────────────────────────────── + + /** Sorted multiset of all pairwise distances — a rigid-motion-invariant SHAPE signature (the same + * invariant the engine's own lock-stability gate uses). Ids sorted for a stable pairing order. */ + private fun signature(positions: Map): DoubleArray { + val pts = positions.entries.sortedBy { it.key.value }.map { it.value } + val out = ArrayList(pts.size * (pts.size - 1) / 2) + for (i in pts.indices) for (j in i + 1 until pts.size) out.add(pts[i].distanceTo(pts[j])) + return out.toDoubleArray().also { it.sort() } + } + + private fun maxAbsDiff(a: DoubleArray, b: DoubleArray): Double { + require(a.size == b.size) { "signatures differ in size: ${a.size} vs ${b.size}" } + var m = 0.0 + for (i in a.indices) m = maxOf(m, abs(a[i] - b[i])) + return m + } + + /** Feed every inter-anchor edge of [positions] into the engine ([skip] omits one, modelling a + * permanently-obstructed link). EMA-smoothed inside the engine, so several reps settle it. */ + private fun feedInterAnchor( + h: MofeTestHarness, + ids: List, + positions: Map, + reps: Int = 20, + skip: Pair? = null, + ) { + for (i in ids.indices) for (j in i + 1 until ids.size) { + val a = ids[i]; val b = ids[j] + if (skip != null && (a to b == skip || b to a == skip)) continue + val d = positions.getValue(a).distanceTo(positions.getValue(b)) + repeat(reps) { h.engine.processInterAnchorRanging(a, b, d) } + } + } + + /** Feed the ROOT's own AoA so the orientation solve has ≥2 azimuth bearings and can ORDINALLY + * rank the raised anchor (it reads a clearly-higher elevation than the coplanar base). */ + private fun feedSelfAoa(h: MofeTestHarness, layout: Layout) { + val rootPos = layout.truth.getValue(layout.root) + for (peer in layout.peers) { + val p = layout.truth.getValue(peer) + val az = atan2(p.y, p.x) + val el = if (peer == layout.raised) 0.6 else 0.0 + repeat(4) { + h.clock.advance(10_000) + h.engine.processRanging( + RawRangingMeasurement( + anchorId = layout.root, targetId = peer, timestamp = h.clock.now(), + distance = rootPos.distanceTo(p), + azimuth = az, elevation = el, signalQuality = 1.0, rssi = -55.0, + ), + ) + } + } + } + + /** Drive maintenance until the trust gate locks the frame (or a tick cap is hit). */ + private fun driveUntilLocked(h: MofeTestHarness, maxTicks: Int = 25): Boolean { + repeat(maxTicks) { + if (h.engine.constellationFrameState() == ConstellationFrameState.LOCKED) return true + h.engine.bootstrapReferenceConstellation() + } + return h.engine.constellationFrameState() == ConstellationFrameState.LOCKED + } + + /** + * Build the ROOT device and drive it to a LOCKED, oriented self-solve of [layout]. Peers are + * seated as reference ANCHORS (so the mesh reaches QUORUM, not just PRE_QUORUM) at truth — exactly + * as `ConstellationFrameLockTest` seeds — then the bootstrap re-solves from the inter-anchor ranges + * and must EARN the lock through its own residual/stability/orientation trust gate. + */ + private fun buildLockedRoot(layout: Layout): MofeTestHarness { + val h = MofeTestHarness(MofeConfig()).build() + h.engine.setSelfId(layout.root) // self == origin ⇒ the root SOLVES (does not defer) + h.engine.initializeAsRoot(layout.root) + for (peer in layout.peers) h.engine.registerReferenceAnchor(peer, layout.truth.getValue(peer)) + feedSelfAoa(h, layout) + feedInterAnchor(h, layout.ids, layout.truth) + assertTrue( + driveUntilLocked(h), + "[${layout.name}] the root must LOCK its self-solved constellation " + + "(state=${h.engine.constellationFrameState()})", + ) + return h + } + + // ───────────────────────────────────────────────────────────────────────────────────────────── + // Clause (3) reachability note + // ───────────────────────────────────────────────────────────────────────────────────────────── + // + // The task's "quorum stable / no DEGRADED churn" is ultimately a RAFT/registry property owned by + // MeshCoordinator (the consensus quorum), which a pure-engine test does not spin up. What IS + // reachable here is the engine's own MeshOperatingMode ([CoordinateFrameManager.operatingMode], + // surfaced via the frame manager) and its transition log ([RecordingListener.modeChanges]). + // MeshOperatingMode includes DEGRADED (QUORUM lost), so the engine-level analogue of the clause IS + // expressible and asserted below: a healthy self-solved mesh settles in QUORUM and never emits a + // transition INTO DEGRADED. The Raft-quorum-churn dimension proper (leader failover, voter loss) + // lives in the messaging/ClusterHarness suites and is out of scope for this engine-only gate. + private fun meshModeIsStableAndConverged(h: MofeTestHarness, layout: String) { + assertTrue( + h.listener.modeChanges.none { (_, to) -> to == MeshOperatingMode.DEGRADED }, + "[$layout] a healthy self-solve must never churn THROUGH DEGRADED; " + + "transitions=${h.listener.modeChanges}", + ) + assertEquals( + MeshOperatingMode.QUORUM, h.frameManager.operatingMode, + "[$layout] a ≥4-anchor healthy mesh must settle in QUORUM", + ) + } + + // ═════════════════════════════════════════════════════════════════════════════════════════════ + // Clauses (1)+(2)+(3) + accuracy, run across every valid layout + // ═════════════════════════════════════════════════════════════════════════════════════════════ + + private fun runAgreementGate(layout: Layout) { + // ── ROOT self-solves the authoritative constellation ────────────────────────────────────── + val rootH = buildLockedRoot(layout) + + // (2) root frame is LOCKED, not CONVERGING. + assertEquals( + ConstellationFrameState.LOCKED, rootH.engine.constellationFrameState(), + "[${layout.name}] root frame must read LOCKED once solved", + ) + // (3) mesh mode converged + no DEGRADED churn. + meshModeIsStableAndConverged(rootH, layout.name) + + // The root's authoritative constellation (root at ORIGIN + solved peers). + val authoritative = rootH.engine.referencePointPositions().associate { it.first to it.second } + assertEquals( + layout.truth.keys, authoritative.keys, + "[${layout.name}] the solved constellation must hold every anchor", + ) + + // ── ACCURACY probe: solved SHAPE vs truth SHAPE (orientation-invariant pairwise distances) ── + val truthSig = signature(layout.truth) + val solvedSig = signature(authoritative) + val shapeErr = maxAbsDiff(truthSig, solvedSig) + assertTrue( + shapeErr < PROVISIONAL_TOLERANCE_M, + "[${layout.name}] solved shape must match truth within the provisional bar " + + "($PROVISIONAL_TOLERANCE_M m); max pairwise error=$shapeErr m", + ) + + // ── FOLLOWERS adopt the root frame, DEFER their own (conflicting) solve, and must AGREE ───── + // Absent #61's defer gate, each follower — here fed a COMPLETE but deliberately CONFLICTING + // inter-anchor range set (truth ×1.4) — would re-solve a DIVERGENT constellation. The gate + // makes every device hold the root's ONE frame; that is what clause (1) asserts. + val deviceSignatures = linkedMapOf(layout.root to solvedSig) + val rogue = layout.truth.mapValues { (_, p) -> p * 1.4 } + for (follower in layout.peers) { + val fH = MofeTestHarness(MofeConfig()).build() + fH.engine.initializeAsRoot(layout.root) // frame rooted at the ELECTED root… + fH.engine.setSelfId(follower) // …but THIS device is a NON-root follower + + // :498 adoption path — seat the leader/root-replicated positions as reference anchors + // (registering the origin itself is a no-op, mirroring the on-device guard). + for ((id, pos) in authoritative) fH.engine.registerReferenceAnchor(id, pos) + + // (2) an adopted follower advertises LOCKED (shared frame), not perpetual CONVERGING. + assertEquals( + ConstellationFrameState.LOCKED, fH.engine.constellationFrameState(), + "[${layout.name}] follower ${follower.value} on the adopted frame must read LOCKED", + ) + + // #61 defer — feed the follower complete, solvable, CONFLICTING ranges; it must NOT + // re-solve or refine its own frame. + feedInterAnchor(fH, layout.ids, rogue, reps = 12) + assertEquals( + 0, fH.engine.refineAnchorConstellation(), + "[${layout.name}] follower ${follower.value} must not refine its own frame", + ) + repeat(5) { + assertEquals( + 0, fH.engine.bootstrapReferenceConstellation(), + "[${layout.name}] follower ${follower.value} must not re-solve despite complete local ranges", + ) + } + assertEquals( + ConstellationFrameState.LOCKED, fH.engine.constellationFrameState(), + "[${layout.name}] follower ${follower.value} must stay LOCKED after the conflicting feed", + ) + + // Every adopted position stays byte-for-byte the root's (not the rogue local solve). + val followerRefs = fH.engine.referencePointPositions().associate { it.first to it.second } + for ((id, pos) in authoritative) { + assertTrue( + followerRefs.getValue(id).distanceTo(pos) < 1e-9, + "[${layout.name}] follower ${follower.value} must hold the root's $id, not its rogue solve", + ) + } + deviceSignatures[follower] = signature(followerRefs) + } + + // (1) AGREEMENT — every device reports the SAME constellation: identical pairwise inter-anchor + // distances across the whole mesh, within epsilon. + val reference = deviceSignatures.getValue(layout.root) + for ((device, sig) in deviceSignatures) { + assertEquals( + reference.size, sig.size, + "[${layout.name}] ${device.value} must report the same number of inter-anchor edges", + ) + val err = maxAbsDiff(reference, sig) + assertTrue( + err < AGREEMENT_EPSILON_M, + "[${layout.name}] ${device.value} must agree with the mesh-wide constellation; " + + "max pairwise divergence=$err m", + ) + } + } + + @Test fun agreement_gate_square_raised() = runAgreementGate(squareRaised()) + @Test fun agreement_gate_tetrahedron() = runAgreementGate(tetrahedron()) + @Test fun agreement_gate_l_shape() = runAgreementGate(lShape()) + @Test fun agreement_gate_five_anchor() = runAgreementGate(fiveAnchor()) + + // ═════════════════════════════════════════════════════════════════════════════════════════════ + // Clause (4) — DEGENERATE layouts are DETECTED by the #47 classifier and NOT silently locked + // ═════════════════════════════════════════════════════════════════════════════════════════════ + + private fun degenerateHarness(seeded: Map): MofeTestHarness { + val h = MofeTestHarness(MofeConfig()).build() + val root = seeded.keys.first() + h.engine.setSelfId(root) + h.engine.initializeAsRoot(root) + for ((id, pos) in seeded) if (id != root) h.engine.registerReferenceAnchor(id, pos) + return h + } + + /** + * COLLAPSED constellation: 4 anchors, a complete inter-anchor graph (so it is rigid by edge count), + * but two anchors sit ~2 cm apart — below [MofeConfig.anchorMinSeparationMeters]. The #47 classifier + * must call this DEGENERATE, and the lock trust gate must never latch it. + */ + @Test + fun degenerate_collapsed_is_reported_and_never_locks() { + val root = dev("A1") + val collapsed = linkedMapOf( + root to Vector3D(0.0, 0.0, 0.0), + dev("A2") to Vector3D(2.0, 0.0, 0.0), + dev("A3") to Vector3D(0.0, 2.0, 0.0), + dev("A4") to Vector3D(0.02, 0.0, 0.0), // ~2 cm from the root → collapsed pair + ) + val h = degenerateHarness(collapsed) + feedInterAnchor(h, collapsed.keys.toList(), collapsed, reps = 20) + + val report = assertNotNull( + h.engine.constellationGeometryReport(), + "a topology exists, so the classifier must return a report", + ) + assertEquals( + GeometryQuality.DEGENERATE, report.quality, + "a collapsed (near-overlapping) constellation must be classified DEGENERATE; report=$report", + ) + assertTrue(report.issues.isNotEmpty(), "a DEGENERATE report must carry operator-facing guidance") + + repeat(25) { h.engine.bootstrapReferenceConstellation() } + assertTrue( + h.engine.constellationFrameState() != ConstellationFrameState.LOCKED, + "a degenerate (collapsed) layout must NEVER lock; state=${h.engine.constellationFrameState()}", + ) + } + + /** + * UNDER-RANGED constellation ("too few constraints"): 4 well-separated non-coplanar anchors but a + * permanently-missing inter-anchor edge, so the distance graph is not rigid (5 of 6 edges). The #47 + * classifier must flag this DEGENERATE, and the incomplete-graph solve (prior-folded fallback) must + * never lock — mirroring `ConstellationFrameLockTest.never_locks_an_incomplete_graph_solve`. + */ + @Test + fun degenerate_under_ranged_is_reported_and_never_locks() { + val layout = tetrahedron() + val h = degenerateHarness(layout.truth) + feedSelfAoa(h, layout) + feedInterAnchor(h, layout.ids, layout.truth, skip = dev("A2") to dev("A4")) + + val report = assertNotNull( + h.engine.constellationGeometryReport(), + "a topology exists, so the classifier must return a report", + ) + assertEquals( + GeometryQuality.DEGENERATE, report.quality, + "an under-ranged (non-rigid) constellation must be classified DEGENERATE; report=$report", + ) + assertTrue( + report.edgeCount < report.edgesForRigidity, + "the report must show the missing constraint (edges ${report.edgeCount} < ${report.edgesForRigidity})", + ) + + repeat(25) { h.engine.bootstrapReferenceConstellation() } + assertTrue( + h.engine.constellationFrameState() != ConstellationFrameState.LOCKED, + "an under-ranged (incomplete-graph) solve must NEVER lock; state=${h.engine.constellationFrameState()}", + ) + } + + /** + * COLLINEAR anchors — 4 anchors on a single line, fully ranged. + * + * Honest scoping of the #47 classifier: its DEGENERATE verdict is driven by anchor COUNT, edge + * RIGIDITY, and minimum SEPARATION — NOT by the rank/collinearity of the point set. A fully-ranged + * collinear line is therefore rigid-by-edge-count and reads MARGINAL (its only complaint is "no + * clearly-raised anchor", because a line has no base plane), not DEGENERATE. The guarantee that + * actually matters — "NOT silently locked wrong" — is upheld by the ORIENTATION + trust gate: a + * collinear set has no solvable up-axis, so orientation never engages and the frame never locks. + * This test asserts both facts precisely (rather than pretending #47 escalates collinearity). + */ + @Test + fun collinear_is_flagged_marginal_and_never_locks() { + val root = dev("A1") + val collinear = linkedMapOf( + root to Vector3D(0.0, 0.0, 0.0), + dev("A2") to Vector3D(1.0, 0.0, 0.0), + dev("A3") to Vector3D(2.0, 0.0, 0.0), + dev("A4") to Vector3D(3.0, 0.0, 0.0), + ) + val h = degenerateHarness(collinear) + // Give it every chance to lock: feed AoA + a complete edge set, then drive hard. + for (peer in listOf(dev("A2"), dev("A3"), dev("A4"))) { + val p = collinear.getValue(peer) + repeat(4) { + h.clock.advance(10_000) + h.engine.processRanging( + RawRangingMeasurement( + anchorId = root, targetId = peer, timestamp = h.clock.now(), + distance = p.magnitude, azimuth = 0.0, elevation = 0.0, + signalQuality = 1.0, rssi = -55.0, + ), + ) + } + } + feedInterAnchor(h, collinear.keys.toList(), collinear, reps = 20) + + val report = assertNotNull( + h.engine.constellationGeometryReport(), + "a topology exists, so the classifier must return a report", + ) + assertEquals( + GeometryQuality.MARGINAL, report.quality, + "a fully-ranged collinear line is rigid-by-edge-count but has no raised anchor → MARGINAL; report=$report", + ) + assertTrue(report.issues.isNotEmpty(), "the classifier must still surface a placement complaint") + + repeat(25) { h.engine.bootstrapReferenceConstellation() } + assertTrue( + h.engine.constellationFrameState() != ConstellationFrameState.LOCKED, + "a collinear (unorientable) layout must NEVER lock; state=${h.engine.constellationFrameState()}", + ) + } +} -- 2.43.0