diff --git a/frontend/src/components/PacketVisualizer3D.tsx b/frontend/src/components/PacketVisualizer3D.tsx
index 520d1c8..b472218 100644
--- a/frontend/src/components/PacketVisualizer3D.tsx
+++ b/frontend/src/components/PacketVisualizer3D.tsx
@@ -29,6 +29,9 @@ export function PacketVisualizer3D({
const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(savedSettings.showAmbiguousPaths);
const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(savedSettings.showAmbiguousNodes);
const [useAdvertPathHints, setUseAdvertPathHints] = useState(savedSettings.useAdvertPathHints);
+ const [collapseLikelyKnownSiblingRepeaters, setCollapseLikelyKnownSiblingRepeaters] = useState(
+ savedSettings.collapseLikelyKnownSiblingRepeaters
+ );
const [splitAmbiguousByTraffic, setSplitAmbiguousByTraffic] = useState(
savedSettings.splitAmbiguousByTraffic
);
@@ -52,6 +55,7 @@ export function PacketVisualizer3D({
showAmbiguousPaths,
showAmbiguousNodes,
useAdvertPathHints,
+ collapseLikelyKnownSiblingRepeaters,
splitAmbiguousByTraffic,
chargeStrength,
observationWindowSec,
@@ -66,6 +70,7 @@ export function PacketVisualizer3D({
showAmbiguousPaths,
showAmbiguousNodes,
useAdvertPathHints,
+ collapseLikelyKnownSiblingRepeaters,
splitAmbiguousByTraffic,
chargeStrength,
observationWindowSec,
@@ -108,6 +113,7 @@ export function PacketVisualizer3D({
showAmbiguousPaths,
showAmbiguousNodes,
useAdvertPathHints,
+ collapseLikelyKnownSiblingRepeaters,
splitAmbiguousByTraffic,
chargeStrength,
letEmDrift,
@@ -143,6 +149,8 @@ export function PacketVisualizer3D({
setShowAmbiguousNodes={setShowAmbiguousNodes}
useAdvertPathHints={useAdvertPathHints}
setUseAdvertPathHints={setUseAdvertPathHints}
+ collapseLikelyKnownSiblingRepeaters={collapseLikelyKnownSiblingRepeaters}
+ setCollapseLikelyKnownSiblingRepeaters={setCollapseLikelyKnownSiblingRepeaters}
splitAmbiguousByTraffic={splitAmbiguousByTraffic}
setSplitAmbiguousByTraffic={setSplitAmbiguousByTraffic}
observationWindowSec={observationWindowSec}
diff --git a/frontend/src/components/visualizer/AGENTS_packet_visualizer.md b/frontend/src/components/visualizer/AGENTS_packet_visualizer.md
index 31dbf93..7d9b82d 100644
--- a/frontend/src/components/visualizer/AGENTS_packet_visualizer.md
+++ b/frontend/src/components/visualizer/AGENTS_packet_visualizer.md
@@ -94,8 +94,9 @@ When a new packet arrives from the WebSocket:
```typescript
packets.forEach((packet) => {
- if (processedRef.current.has(packet.id)) return; // Skip duplicates
- processedRef.current.add(packet.id);
+ const observationKey = getRawPacketObservationKey(packet);
+ if (processedRef.current.has(observationKey)) return; // Skip duplicates
+ processedRef.current.add(observationKey);
const parsed = parsePacket(packet.data);
const key = generatePacketKey(parsed, packet);
@@ -215,6 +216,8 @@ When a winner is found, the ambiguous node gets a `probableIdentity` label (the
**Interaction with traffic splitting:** Advert-path hints run first. If a probable identity is found, the display name is set. Traffic splitting can still produce separate node IDs (`?XX:>YY`), but won't overwrite the advert-path display name.
+**Sibling collapse projection:** When an ambiguous repeater has a high-confidence likely identity and that likely repeater also appears as a definitely-known sibling connecting to the same next hop, the projection layer can collapse the ambiguous node into the known repeater. This is projection-only: canonical observations and canonical neighbor truth remain unchanged.
+
**Toggle:** "Use repeater advert-path identity hints" checkbox (enabled by default, disabled when ambiguous repeaters are hidden).
### Traffic Pattern Splitting (Experimental)
@@ -331,21 +334,22 @@ function buildPath(parsed, packet, myPrefix): string[] {
## Configuration Options
-| Option | Default | Description |
-| -------------------------- | ------- | --------------------------------------------------------- |
-| Ambiguous repeaters | On | Show nodes when only partial prefix known |
-| Ambiguous sender/recipient | Off | Show placeholder nodes for unknown senders |
-| Advert-path identity hints | On | Use stored advert paths to label ambiguous repeaters |
-| Split by traffic pattern | Off | Split ambiguous repeaters by next-hop routing (see above) |
-| Observation window | 15 sec | Wait time for duplicate packets before animating (1-60s) |
-| Let 'em drift | On | Continuous layout optimization |
-| Repulsion | 200 | Force strength (50-2500) |
-| Packet speed | 2x | Particle animation speed multiplier (1x-5x) |
-| Shuffle layout | - | Button to randomize node positions and reheat sim |
-| Oooh Big Stretch! | - | Button to temporarily increase repulsion then relax |
-| Clear & Reset | - | Button to clear all nodes, links, and packets |
-| Hide UI | Off | Hide legends and most controls for cleaner view |
-| Full screen | Off | Hide the packet feed panel (desktop only) |
+| Option | Default | Description |
+| -------------------------- | ------- | ----------------------------------------------------------- |
+| Ambiguous repeaters | On | Show nodes when only partial prefix known |
+| Ambiguous sender/recipient | Off | Show placeholder nodes for unknown senders |
+| Advert-path identity hints | On | Use stored advert paths to label ambiguous repeaters |
+| Collapse sibling repeaters | On | Merge likely ambiguous repeater with known sibling repeater |
+| Split by traffic pattern | Off | Split ambiguous repeaters by next-hop routing (see above) |
+| Observation window | 15 sec | Wait time for duplicate packets before animating (1-60s) |
+| Let 'em drift | On | Continuous layout optimization |
+| Repulsion | 200 | Force strength (50-2500) |
+| Packet speed | 2x | Particle animation speed multiplier (1x-5x) |
+| Shuffle layout | - | Button to randomize node positions and reheat sim |
+| Oooh Big Stretch! | - | Button to temporarily increase repulsion then relax |
+| Clear & Reset | - | Button to clear all nodes, links, and packets |
+| Hide UI | Off | Hide legends and most controls for cleaner view |
+| Full screen | Off | Hide the packet feed panel (desktop only) |
## File Structure
diff --git a/frontend/src/components/visualizer/VisualizerControls.tsx b/frontend/src/components/visualizer/VisualizerControls.tsx
index 4dd4be8..a27ca85 100644
--- a/frontend/src/components/visualizer/VisualizerControls.tsx
+++ b/frontend/src/components/visualizer/VisualizerControls.tsx
@@ -13,6 +13,8 @@ interface VisualizerControlsProps {
setShowAmbiguousNodes: (value: boolean) => void;
useAdvertPathHints: boolean;
setUseAdvertPathHints: (value: boolean) => void;
+ collapseLikelyKnownSiblingRepeaters: boolean;
+ setCollapseLikelyKnownSiblingRepeaters: (value: boolean) => void;
splitAmbiguousByTraffic: boolean;
setSplitAmbiguousByTraffic: (value: boolean) => void;
observationWindowSec: number;
@@ -46,6 +48,8 @@ export function VisualizerControls({
setShowAmbiguousNodes,
useAdvertPathHints,
setUseAdvertPathHints,
+ collapseLikelyKnownSiblingRepeaters,
+ setCollapseLikelyKnownSiblingRepeaters,
splitAmbiguousByTraffic,
setSplitAmbiguousByTraffic,
observationWindowSec,
@@ -149,55 +153,77 @@ export function VisualizerControls({
Show ambiguous sender/recipient
-
-
-
-
-
- setObservationWindowSec(
- Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1))
- )
- }
- className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center"
- />
- sec
-
+
+
+ Advanced
+
+
+
+
+
+
+
+
+ setObservationWindowSec(
+ Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1))
+ )
+ }
+ className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center"
+ />
+ sec
+
+
+