From 8294626ab4d6d1ac43a368972dc610579b8a9744 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 18:45:37 +0000 Subject: [PATCH 6/6] =?UTF-8?q?feat(ui/render):=20role-aware=20render=20sa?= =?UTF-8?q?feguard=20=E2=80=94=20anchors=20see=20diagnostics,=20clients=20?= =?UTF-8?q?hold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render-readiness gate was role-blind: it protected EVERY device from unsolved locations pre-quorum, including anchors/root — for whom that pre-quorum mesh state (links, quorum progress, geometry) is exactly what the operator is there to watch. Add MeshDisplayPolicy (commonMain, pure): given this device's mesh role, the objective MeshRenderReadiness, and whether the app is auto-joining its last mesh, it picks the surface — - CLIENT: HOLD until READY, then SPATIAL_SCENE (never shows unsolved locations) - ANCHOR/ROOT: DIAGNOSTICS pre-quorum (not a client hold); SPATIAL_SCENE once READY; on auto-join bringup DEFAULTS to DIAGNOSTICS regardless of readiness Safeguard invariant: only a client can ever be put in HOLDING. - MeshDisplayPolicy + MeshSurface (commonMain) — pure, platform-agnostic - MeshDisplayPolicyTest — full decision table, 8 cases - AppShell: NavHost startDestination now computed from the policy (anchor/root -> Diagnostics route) [review-only: androidApp needs AGP, not compiled here] Verified offline: :common:jvmTest 759 tests, 0 failures (MeshDisplayPolicyTest 8/8). Follow-up: AppShell reads the live mesh roster for role; a cold-start bringup decision should prefer the device's PERSISTED role so an anchor isn't briefly mis-seen as a client before the roster populates. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../main/java/com/aether/mofe/ui/AppShell.kt | 22 ++++- .../mofe/engine/render/MeshDisplayPolicy.kt | 74 +++++++++++++++ .../engine/render/MeshDisplayPolicyTest.kt | 89 +++++++++++++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 common/src/commonMain/kotlin/com/aether/mofe/engine/render/MeshDisplayPolicy.kt create mode 100644 common/src/commonTest/kotlin/com/aether/mofe/engine/render/MeshDisplayPolicyTest.kt diff --git a/androidApp/src/main/java/com/aether/mofe/ui/AppShell.kt b/androidApp/src/main/java/com/aether/mofe/ui/AppShell.kt index 549d684..2430ec0 100644 --- a/androidApp/src/main/java/com/aether/mofe/ui/AppShell.kt +++ b/androidApp/src/main/java/com/aether/mofe/ui/AppShell.kt @@ -57,6 +57,8 @@ import com.aether.mofe.platform.UplinkStatus import com.aether.mofe.ui.design.ScreenHeader import com.aether.mofe.ui.design.StatusPill import com.aether.mofe.ui.design.ToastHost +import com.aether.mofe.engine.render.MeshDisplayPolicy +import com.aether.mofe.engine.render.MeshSurface import com.aether.mofe.ui.design.ToastTone import com.aether.mofe.ui.design.meshLabel import com.aether.mofe.ui.design.meshTone @@ -116,6 +118,24 @@ fun AppShell(app: AetherApp, hud: HudViewModel) { val constellationFrame by app.mofeEngineHost.constellationFrame.collectAsState() val geometryReport by app.mofeEngineHost.geometryReport.collectAsState() + // Role-aware startup surface (safeguard). A CLIENT is protected from unsolved locations + // pre-quorum (the scene holds); an ANCHOR/ROOT is instead SHOWN that state — coming up + // auto-joining its last mesh, it defaults straight to the diagnostic panel. The rule lives + // in engine.render.MeshDisplayPolicy (pure + unit-tested); here we only supply role + readiness. + val selfIsAnchorOrRoot = shared.nodes + .firstOrNull { it.nodeId == selfId } + ?.meshRoles?.any { it.equals("ANCHOR", true) || it.equals("ROOT", true) } == true + val startRoute = when ( + MeshDisplayPolicy.startupSurface( + isAnchorOrRoot = selfIsAnchorOrRoot, + readiness = renderReadiness, + autoJoinBringup = true, // first composition = app bringup into the last mesh + ) + ) { + MeshSurface.DIAGNOSTICS -> Routes.Calibrate + else -> Routes.Visualizer + } + val realizerScope = rememberCoroutineScope() val realizerVm = remember { PredicateRealizerViewModel(app.meshRepository, realizerScope) } @@ -188,7 +208,7 @@ fun AppShell(app: AetherApp, hud: HudViewModel) { }, ) { Box(Modifier.fillMaxSize()) { - NavHost(navController = navController, startDestination = Routes.Visualizer) { + NavHost(navController = navController, startDestination = startRoute) { // HOME is the Filament scene (3b promotion). Its own top-left hamburger opens the drawer. composable(Routes.Visualizer) { val anchorIds = shared.nodes diff --git a/common/src/commonMain/kotlin/com/aether/mofe/engine/render/MeshDisplayPolicy.kt b/common/src/commonMain/kotlin/com/aether/mofe/engine/render/MeshDisplayPolicy.kt new file mode 100644 index 0000000..0036c63 --- /dev/null +++ b/common/src/commonMain/kotlin/com/aether/mofe/engine/render/MeshDisplayPolicy.kt @@ -0,0 +1,74 @@ +package com.aether.mofe.engine.render + +/** + * Role-aware surface selection — the second half of the render-readiness safeguard. + * + * [MeshRenderReadiness] answers the objective question *"may solved locations be drawn + * yet?"*. It does NOT know this device's mesh role, and the field rule depends on it: + * + * - A **client** (a plain member / observer) is *protected*. Before the mesh is + * [RenderReadinessState.READY] it must not be shown unsolved locations — the raw, + * spatially-wrong, jittery view is indistinguishable from a working one — so it + * HOLDS with a status. Once READY it renders the spatial scene. + * - An **anchor / root** is the opposite party. The pre-quorum mesh state (links, + * quorum progress, geometry) is exactly what its operator is there to watch, so it + * is shown the DIAGNOSTICS surface rather than a client hold. And when the app comes + * up auto-joining its last mesh *as* an anchor/root, DIAGNOSTICS is the default + * surface it lands on — building/monitoring the mesh is the job in front of them. + * + * Pure and platform-agnostic: the host supplies the role + readiness + bringup signal; + * the UI maps the resulting [MeshSurface] to its own navigation destination / content. + */ +enum class MeshSurface { + /** Render the 3-D / FPV spatial scene (solved node positions + FPV camera). */ + SPATIAL_SCENE, + /** Hold with a status label — a client that must not draw unsolved locations yet. */ + HOLDING, + /** The diagnostic panel: mesh health, quorum progress, inter-anchor links, calibration. */ + DIAGNOSTICS, +} + +/** The chosen [surface] plus a one-line [reason] for the dev diagnostic / logs. */ +data class MeshDisplayDecision(val surface: MeshSurface, val reason: String) + +object MeshDisplayPolicy { + + /** + * Decide what this device should display. + * + * @param isAnchorOrRoot this device backs the mesh frame — it holds an ANCHOR or ROOT + * mesh role (as opposed to a plain client / member). The host computes it from + * the self node's mesh roles (or the current Raft root). + * @param readiness objective spatial readiness from [MeshRenderReadiness.evaluate]. + * @param autoJoinBringup the app has just launched and is auto-joining its last mesh + * (i.e. this is the startup surface decision, not a mid-session re-evaluation). + */ + fun decide( + isAnchorOrRoot: Boolean, + readiness: MeshRenderReadiness, + autoJoinBringup: Boolean = false, + ): MeshDisplayDecision = when { + // Anchor/root auto-joining its last mesh → default straight to diagnostics, + // regardless of readiness: establishing/monitoring the mesh is its job and the + // pre-quorum state is its to see. The operator can navigate to the scene. + isAnchorOrRoot && autoJoinBringup -> + MeshDisplayDecision(MeshSurface.DIAGNOSTICS, "anchor/root auto-join bringup") + // Anchor/root before the mesh is solvable → diagnostics, NOT a client hold: this is + // the device where the unsolved / quorum-building state is prevalent and wanted. + isAnchorOrRoot && !readiness.canRenderSpatial -> + MeshDisplayDecision(MeshSurface.DIAGNOSTICS, "anchor/root pre-quorum (${readiness.detail})") + // Everyone else falls under the client spatial gate: render the scene only when + // READY, otherwise HOLD so a client never shows unsolved locations. + readiness.canRenderSpatial -> + MeshDisplayDecision(MeshSurface.SPATIAL_SCENE, "ready") + else -> + MeshDisplayDecision(MeshSurface.HOLDING, readiness.label) + } + + /** The app's start destination on launch — [decide] projected to its [MeshSurface]. */ + fun startupSurface( + isAnchorOrRoot: Boolean, + readiness: MeshRenderReadiness, + autoJoinBringup: Boolean, + ): MeshSurface = decide(isAnchorOrRoot, readiness, autoJoinBringup).surface +} diff --git a/common/src/commonTest/kotlin/com/aether/mofe/engine/render/MeshDisplayPolicyTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/engine/render/MeshDisplayPolicyTest.kt new file mode 100644 index 0000000..ae4da23 --- /dev/null +++ b/common/src/commonTest/kotlin/com/aether/mofe/engine/render/MeshDisplayPolicyTest.kt @@ -0,0 +1,89 @@ +package com.aether.mofe.engine.render + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The role-aware surface safeguard ([MeshDisplayPolicy]). + * + * Clients are protected from unsolved locations pre-quorum (HOLD); anchors/root are + * instead shown the DIAGNOSTICS surface for that same state, and default to it on + * auto-join bringup. Readiness alone (role-blind) can't express this — that's why the + * policy sits on top of [MeshRenderReadiness]. + */ +class MeshDisplayPolicyTest { + + private val ready = MeshRenderReadiness.evaluate(engineStarted = true, meshSolved = true, referenceCount = 4, selfLocalized = true) + private val acquiringMesh = MeshRenderReadiness.evaluate(engineStarted = true, meshSolved = false, referenceCount = 0, selfLocalized = false) + private val acquiringSelf = MeshRenderReadiness.evaluate(engineStarted = true, meshSolved = true, referenceCount = 4, selfLocalized = false) + + private fun surface(isAnchorOrRoot: Boolean, r: MeshRenderReadiness, autoJoin: Boolean = false) = + MeshDisplayPolicy.decide(isAnchorOrRoot, r, autoJoin).surface + + // ─────────────────────────── CLIENT: protected ─────────────────────────── + + @Test + fun client_holds_before_the_mesh_is_ready() { + assertEquals(MeshSurface.HOLDING, surface(isAnchorOrRoot = false, acquiringMesh)) + assertEquals(MeshSurface.HOLDING, surface(isAnchorOrRoot = false, acquiringSelf)) + } + + @Test + fun client_renders_the_scene_only_when_ready() { + assertEquals(MeshSurface.SPATIAL_SCENE, surface(isAnchorOrRoot = false, ready)) + } + + @Test + fun client_auto_join_is_still_gated_never_shown_diagnostics_or_unsolved_locations() { + // autoJoin must not change the client rule — it is not an anchor/root. + assertEquals(MeshSurface.HOLDING, surface(isAnchorOrRoot = false, acquiringMesh, autoJoin = true)) + assertEquals(MeshSurface.SPATIAL_SCENE, surface(isAnchorOrRoot = false, ready, autoJoin = true)) + } + + // ─────────────────────── ANCHOR / ROOT: informed ───────────────────────── + + @Test + fun anchor_root_auto_join_bringup_defaults_to_the_diagnostic_panel() { + // Regardless of readiness — the whole point of the bringup default. + assertEquals(MeshSurface.DIAGNOSTICS, surface(isAnchorOrRoot = true, acquiringMesh, autoJoin = true)) + assertEquals(MeshSurface.DIAGNOSTICS, surface(isAnchorOrRoot = true, ready, autoJoin = true)) + } + + @Test + fun anchor_root_sees_diagnostics_pre_quorum_not_a_client_hold() { + assertEquals(MeshSurface.DIAGNOSTICS, surface(isAnchorOrRoot = true, acquiringMesh)) + assertEquals(MeshSurface.DIAGNOSTICS, surface(isAnchorOrRoot = true, acquiringSelf)) + } + + @Test + fun anchor_root_not_auto_joining_falls_through_to_the_scene_once_ready() { + assertEquals(MeshSurface.SPATIAL_SCENE, surface(isAnchorOrRoot = true, ready)) + } + + // ─────────────────────────────── contract ──────────────────────────────── + + @Test + fun startupSurface_matches_decide() { + for (anchor in listOf(false, true)) + for (r in listOf(ready, acquiringMesh, acquiringSelf)) + for (auto in listOf(false, true)) + assertEquals( + MeshDisplayPolicy.decide(anchor, r, auto).surface, + MeshDisplayPolicy.startupSurface(anchor, r, auto), + "startupSurface must project decide().surface for anchor=$anchor auto=$auto state=${r.state}", + ) + } + + @Test + fun a_client_is_the_only_role_that_can_be_held() { + // Safeguard invariant: HOLDING is reachable only for a non-anchor (client). An + // anchor/root is always either shown the scene (READY) or diagnostics — never a + // bland hold that would deny the operator the state they need. + for (r in listOf(ready, acquiringMesh, acquiringSelf)) + for (auto in listOf(false, true)) + assertEquals( + false, surface(isAnchorOrRoot = true, r, auto) == MeshSurface.HOLDING, + "anchor/root must never be put in HOLDING (state=${r.state} auto=$auto)", + ) + } +} -- 2.43.0