User Story #80 » 0005-test-engine-multi-layout-mesh-convergence-gate-70-ag.patch
| common/src/commonTest/kotlin/com/aether/mofe/engine/MeshConvergenceGateTest.kt | ||
|---|---|---|
|
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<DeviceId, Vector3D>,
|
||
|
val raised: DeviceId,
|
||
|
) {
|
||
|
val root: DeviceId get() = truth.keys.first()
|
||
|
val peers: List<DeviceId> get() = truth.keys.drop(1)
|
||
|
val ids: List<DeviceId> 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<DeviceId, Vector3D>): DoubleArray {
|
||
|
val pts = positions.entries.sortedBy { it.key.value }.map { it.value }
|
||
|
val out = ArrayList<Double>(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<DeviceId>,
|
||
|
positions: Map<DeviceId, Vector3D>,
|
||
|
reps: Int = 20,
|
||
|
skip: Pair<DeviceId, DeviceId>? = 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<DeviceId, DoubleArray>(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<DeviceId, Vector3D>): 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()}",
|
||
|
)
|
||
|
}
|
||
|
}
|
||