Skip to content

Commit 419d7f8

Browse files
committed
fix(gpu): apply identical Z-axis disc projection in bad-frame fallback
The bad-frame fallback re-projected last-good positions with a divergent Y-axis separation, so a rejected frame would briefly re-orient or collapse the two discs. Route the fallback through project_node_xy with the same per-population centroids and fixed DISC_RIM_RADIUS rim-clamp as the healthy path and ForceFullBroadcast, keeping all three projection call sites in sync. Reconcile the architecture diagrams with the landed code: decoupled rim radius (DISC_RIM_RADIUS=2600, not sep*0.85), single PUT/broadcast write path, population SSOT via metadata["type"], and reader-vs-writer ownership in the analytics wire. Co-Authored-By: jjohare <github@thedreamlab.uk>
1 parent e87143c commit 419d7f8

8 files changed

Lines changed: 93 additions & 37 deletions

docs/architecture/diagrams/01-settings-flow.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ sequenceDiagram
5252
Note over SQLite: settings_routes.rs:405 — persisted after<br/>in-memory update succeeds
5353
REST_PUT-->>SApi: 200 OK {new PhysicsSettings}
5454
55-
Note over ZS,SApi: PARALLEL PATH (also triggered by updatePhysics):<br/>physicsSlice.ts:130 — notifyPhysicsUpdate fires<br/>settingsApi.updatePhysics() DIRECTLY,<br/>bypassing autoSaveManager entirely
55+
Note over ZS,SApi: SINGLE PUT PATH (T2 resolved 2026-06-03):<br/>physicsSlice.ts:129-139 — notifyPhysicsUpdate is now a NO-OP<br/>for persistence. It used to fire settingsApi.updatePhysics()<br/>DIRECTLY (a 2nd PUT per commit / double warmup reset).<br/>Persistence is owned solely by the debounced autoSaveManager<br/>path, so each change reaches the backend exactly once.
5656
```
5757

5858
---

docs/architecture/diagrams/03-interaction-events.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ sequenceDiagram
7777
Handlers->>GI: updateNodePosition(nodeId, pos)
7878
Handlers->>Handlers: throttledWebSocketUpdate (100ms)
7979
80-
Handlers->>WS: sendMessage("nodeDragUpdate", {nodeId, position})<br/>+ sendNodePositionUpdates([{nodeId, position, velocity:{0,0,0}}])
81-
Note over Handlers: Two sends per throttle tick:<br/>JSON "nodeDragUpdate" + legacy binary frame
80+
Handlers->>WS: sendMessage("nodeDragUpdate", {nodeId, position, timestamp})
81+
Note over Handlers: Single send per throttle tick (T2 resolved 2026-06-03):<br/>JSON "nodeDragUpdate" only. The legacy binary frame<br/>(sendNodePositionUpdates) was removed —<br/>useGraphEventHandlers.ts:60-68 — it sent a redundant<br/>SECOND frame per drag move.
8282
8383
WS->>Server: WS frames received
8484
Server->>Physics: UpdateNodePosition (server-authoritative pin)
@@ -207,13 +207,13 @@ sequenceDiagram
207207
208208
User->>Slider: drag slider (e.g. repelK)
209209
Slider->>Store: updateSettings(draft => draft[path] = value)
210-
Store->>Store: notifyPhysicsUpdate(graphName, params)
211-
Store->>API: settingsApi.updatePhysics(params)<br/>→ GET current + PUT merged (endpoints.ts:79-97)
210+
Store->>Store: notifyPhysicsUpdate(graphName, params)<br/>NO-OP for persistence (physicsSlice.ts:129-139)
212211
213212
Slider->>ASM: autoSaveManager.queueChange(path, value) (debounced ~500ms)
214213
ASM->>API: updateSettingsByPaths([{path, value}])<br/>→ routes to updatePhysics if path contains .physics.
214+
API->>Server: PUT /api/settings/physics
215215
216-
Note over API,Server: Both paths call PUT /api/settings/physics<br/>physicsSlice.notifyPhysicsUpdate fires immediately (no debounce)<br/>autoSaveManager fires after debounce (~500ms)<br/>→ DOUBLE PUT for every slider move
216+
Note over API,Server: SINGLE PUT per slider move (T2 resolved 2026-06-03):<br/>notifyPhysicsUpdate no longer fires an immediate PUT.<br/>Only the debounced autoSaveManager path (~500ms) persists,<br/>so the change reaches the backend exactly once.
217217
```
218218

219219
---

docs/architecture/diagrams/05-wire-analytics-types.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,20 +95,20 @@ sequenceDiagram
9595
participant CL as ClusteringActor<br/>(GPU)
9696
participant AD as AnomalyDetectionActor<br/>(GPU)
9797
participant MAP as node_analytics<br/>Arc<RwLock<HashMap<u32,NodeAnalytics>>>
98-
participant CC as ClientCoordinatorActor<br/>(single-writer consolidation)
98+
participant CC as ClientCoordinatorActor<br/>(READER — broadcast encoder, NOT a writer)
9999
participant ENC as encode_node_data_extended_with_sssp()<br/>src/utils/binary_protocol.rs
100100
participant WS as WebSocket<br/>(V5 frame)
101101
participant DEC as parseBinaryNodeData()<br/>client/src/types/binaryProtocol.ts
102102
participant WRK as graph.worker.ts<br/>binary-processor.ts
103103
participant GEM as GemNodes.tsx<br/>(render channel)
104104
105-
Note over PR,AD: GPU kernel results read back to CPU
106-
PR ->> MAP: write entry.centrality = normalised_pagerank<br/>(single writer, resets stale nodes to 0.0)
107-
CL ->> MAP: write entry.cluster_id (1-based, 0=unclustered)<br/>write entry.community_id (Louvain)
108-
AD ->> MAP: write entry.anomaly (LOF/z-score, 0.0–1.0)
105+
Note over PR,AD: GPU kernel results read back to CPU.<br/>WRITE OWNERSHIP: each analytics actor is the sole writer of its OWN<br/>field(s) into node_analytics. ClusteringActor owns cluster_id +<br/>community_id (ADR-031 D3 single writer, write_cluster_id_from_assignments);<br/>PageRankActor owns centrality; AnomalyDetectionActor owns anomaly.<br/>ClientCoordinatorActor does NOT write — it only reads at broadcast time.
106+
PR ->> MAP: write entry.centrality = normalised_pagerank<br/>(sole writer of centrality, resets stale nodes to 0.0)
107+
CL ->> MAP: write entry.cluster_id (1-based, 0=unclustered)<br/>write entry.community_id (Louvain)<br/>(sole writer of cluster_id + community_id)
108+
AD ->> MAP: write entry.anomaly (LOF/z-score, 0.0–1.0)<br/>(sole writer of anomaly)
109109
110-
Note over CC: Physics tick broadcast
111-
CC ->> MAP: node_analytics.read().ok()
110+
Note over CC: Physics tick broadcast — CC READS only
111+
CC ->> MAP: node_analytics.read().ok()<br/>(client_coordinator_actor.rs:813,954,1491 — reader)
112112
MAP -->> CC: Option<&HashMap<u32, NodeAnalytics>>
113113
CC ->> ENC: encode_node_data_extended_with_sssp(<br/> nodes, …, sssp_data=None, analytics_data)
114114
Note over ENC: Writes 52 B per node into Vec<u8>.<br/>Strips flag bits from node_id before<br/>HashMap lookup (base_id = id & NODE_ID_MASK).

docs/architecture/diagrams/06-gpu-physics.md

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ sequenceDiagram
8787
8888
note over FCA: Good frame:<br/> Clamp positions to MAX_COORD, velocities to MAX_VELOCITY_MAGNITUDE<br/> Reset consecutive_bad_frames = 0<br/> Snapshot -> last_good_positions (pristine physics state)
8989
90-
note over FCA: DISC PROJECTION (display-only, applied to broadcast buffer):<br/> population_centroids_xy(): per-population median X,Y<br/> project_node_xy() per node:<br/> dx=pos.x-cx, dy=pos.y-cy; rim-clamp to r_max=sep*0.85<br/> pos.x = dx, pos.y = dy (re-centred around population median)<br/> pos.z = pos.z*face_scale + target_z (flatten Z, offset by ±sep)<br/> Knowledge: target_z=-sep Ontology: target_z=+sep Agent: target_z=0
90+
note over FCA: DISC PROJECTION (display-only, applied to broadcast buffer):<br/> population_centroids_xy(): per-population median X,Y<br/> project_node_xy() per node:<br/> dx=pos.x-cx, dy=pos.y-cy; rim-clamp to r_max=DISC_RIM_RADIUS=2600 (fixed, DECOUPLED from sep)<br/> pos.x = dx, pos.y = dy (re-centred around population median)<br/> pos.z = pos.z*face_scale + target_z (flatten Z, offset by ±sep)<br/> Knowledge: target_z=-sep Ontology: target_z=+sep Agent: target_z=0
9191
9292
FCA->>GS: UpdateNodePositions (projected broadcast positions)
9393
@@ -142,7 +142,7 @@ flowchart LR
142142
B -->|No| E["broadcast_buffer = raw physics\n(no transform)"]
143143
B -->|Yes| C["population_centroids_xy()\nmedian X,Y per population\n(Knowledge=0, Ontology=1, Agent=2)"]
144144
145-
C --> D["project_node_xy() per node\n dx = pos.x - centroid_x\n dy = pos.y - centroid_y\n rim-clamp to r_max = sep*0.85\n pos.x = dx\n pos.y = dy\n pos.z = pos.z*(1-flatten) + target_z\nAxis asymmetry: X/Y re-centred on\nPER-POPULATION median;\nZ flattened around GLOBAL origin (0)"]
145+
C --> D["project_node_xy() per node\n dx = pos.x - centroid_x\n dy = pos.y - centroid_y\n rim-clamp to r_max = DISC_RIM_RADIUS = 2600\n (fixed const, DECOUPLED from sep — small sep\n keeps discs full-size)\n pos.x = dx\n pos.y = dy\n pos.z = pos.z*face_scale + target_z\n (face_scale = 1 - flatten)\nAxis asymmetry: X/Y re-centred on\nPER-POPULATION median;\nZ flattened + separated on the SAME axis (Z)"]
146146
147147
D --> E["broadcast_buffer = projected positions\n(display-only: clients see discs)"]
148148
E --> F["UpdateNodePositions -> GraphService -> WebSocket clients"]
@@ -169,10 +169,15 @@ discs would never stabilise.
169169

170170
**Costs of the current design:**
171171
- Every step allocates and reads `last_good_positions` (O(N) clone, host memory).
172-
- The projection is computed twice when a divergent frame falls back to
173-
`last_good_positions` (lines 1731-1763 also project the fallback snapshot).
172+
- The projection is computed a third time when a divergent frame falls back to
173+
`last_good_positions` (lines 1744-1779). As of 2026-06-03 the fallback calls the
174+
SAME `project_node_xy` (Z-separation + per-population median re-centre +
175+
`DISC_RIM_RADIUS` rim-clamp) as the main path. It previously separated on the
176+
Y axis with no centroid/rim-clamp, so a bad frame briefly re-oriented the discs
177+
onto a different axis — that inconsistency is now removed.
174178
- `ForceFullBroadcast` must independently re-implement the same projection
175-
(lines 2299-2321), creating a second code path that must stay in sync.
179+
(near line 2327, `let r_max = DISC_RIM_RADIUS`), creating a second code path
180+
that must stay in sync.
176181
- Population classification from `metadata["type"]` is a string-match at graph
177182
upload time (lines 607-633); the result is cached in `node_population` but
178183
is not re-verified if metadata changes without a graph reload.
@@ -313,12 +318,12 @@ counter and delays convergence detection.
313318

314319
### Projection per-step apply/undo cost
315320

316-
The disc projection at `force_compute_actor.rs:1816-1831` and the restore at
317-
`1964-1969` both iterate over all nodes (O(N)) on the hot path every frame that
318-
has `project=true`. The `last_good_positions.clear()` and rebuild at `1797-1808`
321+
The disc projection at `force_compute_actor.rs:1829-1846` and the restore at
322+
`1979-1984` both iterate over all nodes (O(N)) on the hot path every frame that
323+
has `project=true`. The `last_good_positions.clear()` and rebuild at `1811-1821`
319324
also copies all N `(node_id, Vec3, Vec3)` triples. For graphs with tens of
320325
thousands of nodes this is three O(N) passes per frame on the Tokio actor thread
321326
(not offloaded to `spawn_blocking`), adding CPU-side latency to the
322-
broadcast-critical path. The `ForceFullBroadcast` handler at `2293-2335` performs
323-
its own independent projection pass with a separate `population_centroids_xy` call,
324-
duplicating the computation outside the main loop.
327+
broadcast-critical path. The `ForceFullBroadcast` handler (around line 2327)
328+
performs its own independent projection pass with a separate
329+
`population_centroids_xy` call, duplicating the computation outside the main loop.

docs/architecture/diagrams/qe-T1-population-ssot.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
# QE-T1 — Node Population Has No Single Source of Truth
22

3-
**Anomaly**: T1 (cartography audit #1). **Status**: REPRODUCED, defect confirmed.
3+
> **✅ SUPERSEDED — FIX LANDED 2026-06-03.** This is a frozen pre-fix reproduction
4+
> report, retained for historical context. Population now has a single source of
5+
> truth: `metadata["type"]`, read via `Node::population_type()` / `Node::population()`
6+
> (`crates/visionclaw-domain/src/models/node.rs:271,283`) and consumed uniformly by
7+
> `graph_state_actor.rs:242,288` and the GPU path. **Note:** the fix chose the
8+
> OPPOSITE field from this report's §6 fix-spec — `metadata["type"]` is authoritative,
9+
> NOT `node_type`. `ontology_enrichment_service` no longer rewrites `node_type`, so
10+
> the two fields can no longer desync. Authoritative current model:
11+
> `02-population-handoff.md §7` and `00-anomaly-register.md`. The §2/§4 truth tables
12+
> below describe pre-fix divergent behaviour that no longer occurs.
13+
14+
**Anomaly**: T1 (cartography audit #1). **Status**: RESOLVED (was: REPRODUCED, defect confirmed).
415
**Author**: agentic-QE investigator. **Date**: 2026-06-03.
5-
**Scope**: reproduction evidence + failing regression test + fix spec. **No production code changed.**
16+
**Scope**: reproduction evidence + failing regression test + fix spec. **No production code changed *by this report*.**
617

718
---
819

docs/architecture/diagrams/qe-T2-T4-writepaths-ceilings.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
# QE Anomaly Report: T2 (Doubled Write/Dispatch Paths) + T4 (Validation-Ceiling Mismatches)
22

3-
> Status: REPRODUCTION EVIDENCE — do NOT fix here. Minimal fix specs at bottom.
3+
> **✅ SUPERSEDED — FIXES LANDED 2026-06-03.** This is a frozen pre-fix reproduction
4+
> report. Both anomalies are resolved:
5+
> - **T2** (doubled write/dispatch): the duplicate `notifyPhysicsUpdate` DIRECT PUT
6+
> is now a no-op (`physicsSlice.ts:129-138`); slider commits take a single PUT path.
7+
> The legacy binary drag frame is removed (`useGraphEventHandlers.ts:61-64`) — one
8+
> JSON `nodeDragUpdate` per tick. Backend physics propagation routes solely via
9+
> `propagate_physics_to_gpu` (no direct-GPU fallback).
10+
> - **T4** (validation-ceiling mismatch): resolved by the physics-bounds SSOT in
11+
> `src/actors/gpu/physics_bounds.rs`.
12+
>
13+
> Authoritative current state: `00-anomaly-register.md` (resolution table T2/T4).
14+
> Reproduction evidence below is retained for historical context. Note line cites
15+
> may be stale (e.g. `force_compute_actor.rs:2188` warmup assignment is now at L2203).
16+
17+
> Status: ~~REPRODUCTION EVIDENCE — do NOT fix here~~ → RESOLVED. Minimal fix specs at bottom.
418
> Investigator: QE static analysis pass, 2026-06-03
519
620
---

docs/architecture/diagrams/qe-T5-analytics-shadow.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
11
# QE T5 — Analytics Shadow: Data-Path Audit and Dual-Modularity Evidence
22

3+
> **✅ SUPERSEDED — FIXES LANDED 2026-06-03.** This is a frozen pre-fix reproduction
4+
> report. Both defects are resolved:
5+
> - **Claim 1 (hulls never render / handler never writes `node_analytics`)**: the
6+
> clustering spawn task now routes its `Vec<Cluster>` through the
7+
> `WriteClusterAnalytics` message (`analytics_messages.rs:138`; handled at
8+
> `clustering_actor.rs:1193`, `gpu_manager_actor.rs:857`, `analytics_supervisor.rs:422`),
9+
> covering both the GPU and CPU-fallback branches via the single writer.
10+
> - **Claim 2 (dual divergent modularity)**: the CPU shadow `calculate_modularity`
11+
> is DELETED (only removal comments remain at `clustering_actor.rs:409,886`); a single
12+
> `modularity_csr` with `MODULARITY_GATE=0.3` (`clustering_actor.rs:1336`) remains.
13+
>
14+
> Authoritative current state: `07-analysis-clustering.md` (§3b, PARALLEL-1) and
15+
> `00-anomaly-register.md`. Hulls render on explicit clustering trigger; auto-trigger
16+
> remains opt-in/OFF by default. Evidence below is retained for historical context.
17+
318
**Audit date**: 2026-06-03
4-
**Status**: CONFIRMED DEFECT — reproduction test in `tests/qe_t5_shadow_modularity.rs`
19+
**Status**: ~~CONFIRMED DEFECT~~ → RESOLVED — reproduction test in `tests/qe_t5_shadow_modularity.rs`
520

621
---
722

src/actors/gpu/force_compute_actor.rs

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1745,19 +1745,30 @@ impl Handler<ComputeForces> for ForceComputeActor {
17451745
if let Some(_seq) = actor.backpressure.try_acquire() {
17461746
let mut node_updates =
17471747
Vec::with_capacity(actor.last_good_positions.len());
1748+
// Compute population centroids from the last-known-good
1749+
// layout so the fallback applies the IDENTICAL projection
1750+
// as the healthy path (Z-separation, per-population median
1751+
// re-centre, fixed DISC_RIM_RADIUS rim-clamp) instead of a
1752+
// divergent Y-separation. A bad frame must not briefly
1753+
// re-orient or collapse the two discs.
1754+
let centroids = if project {
1755+
population_centroids_xy(
1756+
&actor.node_population,
1757+
|i| {
1758+
let (_, p, _) = actor.last_good_positions[i];
1759+
(p.x, p.y)
1760+
},
1761+
actor.last_good_positions.len(),
1762+
)
1763+
} else {
1764+
Default::default()
1765+
};
1766+
let r_max = DISC_RIM_RADIUS;
17481767
for (idx, (node_id, pos, vel)) in actor.last_good_positions.iter().enumerate() {
1749-
// Apply the same display-only projection to the
1750-
// fallback so a divergent frame doesn't briefly
1751-
// collapse the two discs back together.
17521768
let mut p = *pos;
17531769
if project {
17541770
if let Some(&pop) = actor.node_population.get(idx) {
1755-
p.z *= face_scale;
1756-
match pop {
1757-
GraphPopulation::Knowledge => p.y -= sep,
1758-
GraphPopulation::Ontology => p.y += sep,
1759-
GraphPopulation::Agent => {}
1760-
}
1771+
project_node_xy(&mut p, pop, &centroids, sep, face_scale, r_max);
17611772
}
17621773
}
17631774
node_updates.push((*node_id, BinaryNodeDataClient::new(

0 commit comments

Comments
 (0)