Skip to main content

subetha_cxc/
shared_topology_map.rs

1//! `SharedTopologyMap` - K_process axis observer + recommendation
2//! substrate for cross-process message-flow topology selection.
3//!
4//! # Architectural role
5//!
6//! The K_process equivalent of intra-process Layer-2 adaptation:
7//! observes the flow pattern between N participating processes
8//! (per-edge message counts) and recommends one of three
9//! transport topologies based on the observed fan-in / fan-out
10//! statistics:
11//!
12//! - **PointToPoint** - single producer, single consumer. Use
13//!   [`SharedRing`](crate::SharedRing) directly.
14//! - **BroadcastTree** - one producer, many consumers each
15//!   receiving every message. Use
16//!   [`SharedBroadcastRing`](crate::SharedBroadcastRing).
17//! - **AllToAllMesh** - N peers, all-to-all routing. Use a grid of
18//!   `N*N` SharedRings indexed by `(src, dst)`.
19//!
20//! # Policy
21//!
22//! From the bead specification:
23//! - `max_fan_in >= fan_in_threshold` AND `max_fan_out >=
24//!    fan_out_threshold` → `AllToAllMesh`
25//! - `max_fan_out >= fan_out_threshold` → `BroadcastTree`
26//! - otherwise → `PointToPoint`
27//!
28//! Defaults: both thresholds = 3.
29//!
30//! # Layout
31//!
32//! ```text
33//! +---------------------------+
34//! | TopologyHeader (64B)      |
35//! |   magic, n_nodes          |
36//! |   total_msgs              |
37//! |   recommendation cell     |
38//! |   recommendation_epoch    |
39//! |   thresholds              |
40//! +---------------------------+
41//! | edge_counts [N*N AtomicU64] |
42//! +---------------------------+
43//! ```
44//!
45//! # Why separate observer from transport
46//!
47//! Each process picks its OWN role in a topology (publisher /
48//! subscriber for BroadcastTree; node index for Mesh), which is
49//! intrinsically per-process. The observer is the SHARED part
50//! (everyone reads the same recommendation). The transport
51//! instantiation is the per-process part. Keeping them separate
52//! avoids forcing all processes to share a single transport-
53//! switching state machine they can't all participate in
54//! symmetrically.
55
56use std::fs::{File, OpenOptions};
57use std::mem::size_of;
58use std::path::Path;
59use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
60
61use memmap2::{MmapMut, MmapOptions};
62
63pub const TOPOLOGY_MAGIC: u64 = 0x4150_544F_504F_3031;
64
65/// Default thresholds: 3 fan-out for BroadcastTree, 3 fan-in for
66/// AllToAllMesh. Match the bead specification.
67pub const DEFAULT_FAN_OUT_THRESHOLD: u32 = 3;
68pub const DEFAULT_FAN_IN_THRESHOLD: u32 = 3;
69
70/// Topology kind, encoded as a u32 in shared memory.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72#[repr(u32)]
73pub enum TopologyKind {
74    PointToPoint = 0,
75    BroadcastTree = 1,
76    AllToAllMesh = 2,
77}
78
79impl TopologyKind {
80    pub fn from_u32(v: u32) -> Self {
81        match v {
82            1 => Self::BroadcastTree,
83            2 => Self::AllToAllMesh,
84            _ => Self::PointToPoint,
85        }
86    }
87}
88
89#[repr(C, align(64))]
90pub struct TopologyHeader {
91    pub magic: u64,
92    pub n_nodes: u32,
93    pub fan_out_threshold: AtomicU32,
94    pub fan_in_threshold: AtomicU32,
95    _pad1: u32,
96    pub total_msgs: AtomicU64,
97    pub recommendation: AtomicU32,
98    pub recommendation_epoch: AtomicU64,
99    pub broadcast_root: AtomicU32,
100    _pad2: u32,
101    _pad3: [u8; 8],
102}
103
104const _: () = {
105    assert!(size_of::<TopologyHeader>() == 64);
106};
107
108pub const fn topology_file_size(n_nodes: usize) -> usize {
109    size_of::<TopologyHeader>() + n_nodes * n_nodes * size_of::<AtomicU64>()
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum TopologyError {
114    NodeIndexOutOfBounds,
115    LayoutMismatch,
116    IoError(std::io::ErrorKind),
117}
118
119impl From<std::io::Error> for TopologyError {
120    fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub struct TopologyStats {
125    pub total_msgs: u64,
126    pub max_fan_out: u32,
127    pub max_fan_in: u32,
128    pub max_fan_out_src: u32,
129    pub max_fan_in_dst: u32,
130    pub current_recommendation: TopologyKind,
131    pub recommendation_epoch: u64,
132}
133
134pub struct SharedTopologyMap {
135    _file: File,
136    mmap: MmapMut,
137    n_nodes: usize,
138    header_sidecar: subetha_core::HandshakeHeader,
139    ring_sidecar: Box<subetha_core::ObservationRing>,
140}
141
142unsafe impl Send for SharedTopologyMap {}
143unsafe impl Sync for SharedTopologyMap {}
144
145impl subetha_sidecar::AdaptiveInstance for SharedTopologyMap {
146    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
147    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
148    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
149        Box::new(subetha_sidecar::NoMigrationPolicy)
150    }
151}
152
153impl SharedTopologyMap {
154    pub fn create(
155        path: impl AsRef<Path>,
156        n_nodes: usize,
157    ) -> Result<Self, TopologyError> {
158        Self::create_with_thresholds(
159            path, n_nodes,
160            DEFAULT_FAN_OUT_THRESHOLD, DEFAULT_FAN_IN_THRESHOLD,
161        )
162    }
163
164    /// Obtain the map at `path`, initializing an empty one if the path
165    /// does not yet exist and attaching to it if it does. Attaching
166    /// leaves observed edges and the live recommendation in place;
167    /// `fan_out_threshold` / `fan_in_threshold` are then unused. A
168    /// region built with a different node count is a `LayoutMismatch`.
169    /// [`reset`](Self::reset) reinitializes.
170    pub fn create_with_thresholds(
171        path: impl AsRef<Path>,
172        n_nodes: usize,
173        fan_out_threshold: u32,
174        fan_in_threshold: u32,
175    ) -> Result<Self, TopologyError> {
176        assert!(n_nodes >= 1);
177        assert!(n_nodes <= u32::MAX as usize);
178        let (file, mmap) = crate::mmf_attach::create_or_attach(
179            path.as_ref(),
180            topology_file_size(n_nodes),
181            |ptr| unsafe {
182                Self::init_region(ptr, n_nodes, fan_out_threshold, fan_in_threshold)
183            },
184            |ptr| unsafe { (*(ptr as *const TopologyHeader)).magic == TOPOLOGY_MAGIC },
185        )?;
186        Self::from_region(file, mmap, n_nodes)
187    }
188
189    /// Truncate the map at `path` and initialize an empty one,
190    /// discarding every observed edge live peers share. For a caller
191    /// that knows it owns the path.
192    pub fn reset(
193        path: impl AsRef<Path>,
194        n_nodes: usize,
195        fan_out_threshold: u32,
196        fan_in_threshold: u32,
197    ) -> Result<Self, TopologyError> {
198        assert!(n_nodes >= 1);
199        assert!(n_nodes <= u32::MAX as usize);
200        let (file, mmap) = crate::mmf_attach::reset(
201            path.as_ref(),
202            topology_file_size(n_nodes),
203            |ptr| unsafe {
204                Self::init_region(ptr, n_nodes, fan_out_threshold, fan_in_threshold)
205            },
206        )?;
207        Self::from_region(file, mmap, n_nodes)
208    }
209
210    /// Lay out an empty map: config and the point-to-point
211    /// recommendation first, magic last, because attachers spin on it.
212    /// The zeroed region is already the zero edge matrix.
213    ///
214    /// # Safety
215    /// `ptr` addresses at least `topology_file_size(n_nodes)` writable
216    /// zeroed bytes.
217    unsafe fn init_region(
218        ptr: *mut u8,
219        n_nodes: usize,
220        fan_out_threshold: u32,
221        fan_in_threshold: u32,
222    ) {
223        let hdr = ptr as *mut TopologyHeader;
224        unsafe {
225            (*hdr).n_nodes = n_nodes as u32;
226            std::ptr::write(
227                &raw mut (*hdr).fan_out_threshold,
228                AtomicU32::new(fan_out_threshold),
229            );
230            std::ptr::write(
231                &raw mut (*hdr).fan_in_threshold,
232                AtomicU32::new(fan_in_threshold),
233            );
234            std::ptr::write(
235                &raw mut (*hdr).recommendation,
236                AtomicU32::new(TopologyKind::PointToPoint as u32),
237            );
238            std::ptr::write_volatile(&raw mut (*hdr).magic, TOPOLOGY_MAGIC);
239        }
240    }
241
242    /// Wrap an initialized region, refusing one built with a different
243    /// node count.
244    fn from_region(
245        file: File,
246        mmap: MmapMut,
247        n_nodes: usize,
248    ) -> Result<Self, TopologyError> {
249        let hdr = unsafe { &*(mmap.as_ptr() as *const TopologyHeader) };
250        if hdr.magic != TOPOLOGY_MAGIC || hdr.n_nodes != n_nodes as u32 {
251            return Err(TopologyError::LayoutMismatch);
252        }
253        Ok(Self {
254            _file: file, mmap, n_nodes,
255            header_sidecar: subetha_core::HandshakeHeader::new(),
256            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
257        })
258    }
259
260    pub fn open(
261        path: impl AsRef<Path>,
262        expected_n_nodes: usize,
263    ) -> Result<Self, TopologyError> {
264        let total = topology_file_size(expected_n_nodes);
265        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
266        if file.metadata()?.len() < total as u64 {
267            return Err(TopologyError::LayoutMismatch);
268        }
269        let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
270        Self::from_region(file, mmap, expected_n_nodes)
271    }
272
273    #[inline]
274    pub fn n_nodes(&self) -> usize { self.n_nodes }
275
276    fn header(&self) -> &TopologyHeader {
277        unsafe { &*(self.mmap.as_ptr() as *const TopologyHeader) }
278    }
279
280    fn edge(&self, src: u32, dst: u32) -> &AtomicU64 {
281        let idx = (src as usize) * self.n_nodes + (dst as usize);
282        let base = unsafe { self.mmap.as_ptr().add(size_of::<TopologyHeader>()) };
283        unsafe { &*(base.add(idx * size_of::<AtomicU64>()) as *const AtomicU64) }
284    }
285
286    /// Record one message from `src` to `dst`. Increments the edge
287    /// counter and the total. Returns the new edge count.
288    pub fn record_send(&self, src: u32, dst: u32) -> Result<u64, TopologyError> {
289        if src as usize >= self.n_nodes || dst as usize >= self.n_nodes {
290            self.ring_sidecar
291                .push_op(crate::sidecar_ops::topology::OP_RECORD, 1);
292            return Err(TopologyError::NodeIndexOutOfBounds);
293        }
294        let prev = self.edge(src, dst).fetch_add(1, Ordering::AcqRel);
295        self.header().total_msgs.fetch_add(1, Ordering::AcqRel);
296        self.ring_sidecar
297            .push_op(crate::sidecar_ops::topology::OP_RECORD, 0);
298        Ok(prev + 1)
299    }
300
301    /// Fan-out for `src`: count of destinations with non-zero edge.
302    pub fn fan_out(&self, src: u32) -> u32 {
303        if src as usize >= self.n_nodes {
304            self.ring_sidecar
305                .push_op(crate::sidecar_ops::topology::OP_FAN_OUT, 1);
306            return 0;
307        }
308        let mut count = 0u32;
309        for d in 0..self.n_nodes as u32 {
310            if self.edge(src, d).load(Ordering::Acquire) > 0 {
311                count += 1;
312            }
313        }
314        self.ring_sidecar
315            .push_op(crate::sidecar_ops::topology::OP_FAN_OUT, 0);
316        count
317    }
318
319    /// Fan-in for `dst`: count of sources with non-zero edge.
320    pub fn fan_in(&self, dst: u32) -> u32 {
321        if dst as usize >= self.n_nodes {
322            self.ring_sidecar
323                .push_op(crate::sidecar_ops::topology::OP_FAN_IN, 1);
324            return 0;
325        }
326        let mut count = 0u32;
327        for s in 0..self.n_nodes as u32 {
328            if self.edge(s, dst).load(Ordering::Acquire) > 0 {
329                count += 1;
330            }
331        }
332        self.ring_sidecar
333            .push_op(crate::sidecar_ops::topology::OP_FAN_IN, 0);
334        count
335    }
336
337    /// Max fan-out across all sources, plus the source index.
338    pub fn max_fan_out(&self) -> (u32, u32) {
339        let mut max = 0u32;
340        let mut who = 0u32;
341        for s in 0..self.n_nodes as u32 {
342            let fo = self.fan_out(s);
343            if fo > max { max = fo; who = s; }
344        }
345        (max, who)
346    }
347
348    /// Max fan-in across all destinations, plus the dst index.
349    pub fn max_fan_in(&self) -> (u32, u32) {
350        let mut max = 0u32;
351        let mut who = 0u32;
352        for d in 0..self.n_nodes as u32 {
353            let fi = self.fan_in(d);
354            if fi > max { max = fi; who = d; }
355        }
356        (max, who)
357    }
358
359    /// Compute the recommended topology from observed stats. Pure
360    /// function over the current edge-count snapshot; does NOT
361    /// mutate the published recommendation. Use
362    /// `publish_recommendation` to cache it for O(1) reads.
363    pub fn recommend(&self) -> TopologyKind {
364        let (max_fan_out, _) = self.max_fan_out();
365        let (max_fan_in, _) = self.max_fan_in();
366        let fo_threshold = self.header().fan_out_threshold.load(Ordering::Acquire);
367        let fi_threshold = self.header().fan_in_threshold.load(Ordering::Acquire);
368        let kind = if max_fan_out >= fo_threshold && max_fan_in >= fi_threshold {
369            TopologyKind::AllToAllMesh
370        } else if max_fan_out >= fo_threshold {
371            TopologyKind::BroadcastTree
372        } else {
373            TopologyKind::PointToPoint
374        };
375        self.ring_sidecar
376            .push_op(crate::sidecar_ops::topology::OP_RECOMMEND, 0);
377        kind
378    }
379
380    /// Compute the recommendation AND publish it to the header so
381    /// other processes can read it at O(1) via
382    /// `read_recommendation`. Bumps `recommendation_epoch`.
383    /// Returns the published recommendation.
384    pub fn publish_recommendation(&self) -> TopologyKind {
385        let kind = self.recommend();
386        let hdr = self.header();
387        hdr.recommendation.store(kind as u32, Ordering::Release);
388        hdr.recommendation_epoch.fetch_add(1, Ordering::Release);
389        // If recommending BroadcastTree, also record the broadcast
390        // root (the source with the highest fan-out).
391        if kind == TopologyKind::BroadcastTree {
392            let (_, root) = self.max_fan_out();
393            hdr.broadcast_root.store(root, Ordering::Release);
394        }
395        kind
396    }
397
398    /// Read the most-recently-published recommendation. O(1).
399    pub fn read_recommendation(&self) -> TopologyKind {
400        TopologyKind::from_u32(
401            self.header().recommendation.load(Ordering::Acquire)
402        )
403    }
404
405    /// Read the recommended broadcast root (the highest-fan-out
406    /// source at the most recent `publish_recommendation`). Only
407    /// meaningful when `read_recommendation` == BroadcastTree.
408    pub fn broadcast_root(&self) -> u32 {
409        self.header().broadcast_root.load(Ordering::Acquire)
410    }
411
412    /// Returns the recommendation epoch counter (bumped every
413    /// `publish_recommendation`). Observers can subscribe to
414    /// changes by comparing successive reads.
415    pub fn recommendation_epoch(&self) -> u64 {
416        self.header().recommendation_epoch.load(Ordering::Acquire)
417    }
418
419    /// Read total messages observed across all edges.
420    pub fn total_msgs(&self) -> u64 {
421        self.header().total_msgs.load(Ordering::Acquire)
422    }
423
424    /// Snapshot all stats in one O(N²) pass.
425    pub fn stats(&self) -> TopologyStats {
426        let (max_fan_out, max_fan_out_src) = self.max_fan_out();
427        let (max_fan_in, max_fan_in_dst) = self.max_fan_in();
428        TopologyStats {
429            total_msgs: self.total_msgs(),
430            max_fan_out, max_fan_out_src,
431            max_fan_in, max_fan_in_dst,
432            current_recommendation: self.read_recommendation(),
433            recommendation_epoch: self.recommendation_epoch(),
434        }
435    }
436
437    /// Reset all edge counters to zero (new observation window).
438    /// Total_msgs is also reset. The recommendation cell is left
439    /// untouched (use publish_recommendation to refresh after a new
440    /// observation epoch).
441    pub fn reset_observations(&self) {
442        for s in 0..self.n_nodes as u32 {
443            for d in 0..self.n_nodes as u32 {
444                self.edge(s, d).store(0, Ordering::Release);
445            }
446        }
447        self.header().total_msgs.store(0, Ordering::Release);
448    }
449
450    /// Update the policy thresholds. Useful for tuning per workload
451    /// without re-creating the map.
452    pub fn set_thresholds(&self, fan_out: u32, fan_in: u32) {
453        self.header().fan_out_threshold.store(fan_out, Ordering::Release);
454        self.header().fan_in_threshold.store(fan_in, Ordering::Release);
455    }
456
457    pub fn flush(&self) -> Result<(), TopologyError> {
458        self.mmap.flush()?;
459        Ok(())
460    }
461
462    /// Non-blocking flush: schedules a writeback via the OS.
463    /// Note: Windows is only partially async (sync to page cache,
464    /// not to disk).
465    pub fn flush_async(&self) -> Result<(), TopologyError> {
466        self.mmap.flush_async()?;
467        Ok(())
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use std::sync::Arc;
475    use std::thread;
476
477    fn tmp(name: &str) -> std::path::PathBuf {
478        let mut p = std::env::temp_dir();
479        let pid = std::process::id();
480        p.push(format!("subetha-topology-{name}-{pid}.bin"));
481        p
482    }
483
484    /// A second create attaches with observed edges in place; reset is
485    /// what zeroes them.
486    #[test]
487    fn second_create_attaches_and_keeps_edges() {
488        let p = tmp("attach");
489        std::fs::remove_file(&p).ok();
490        let t = SharedTopologyMap::create(&p, 4).unwrap();
491        t.record_send(0, 1).unwrap();
492
493        let t2 = SharedTopologyMap::create(&p, 4).unwrap();
494        assert_eq!(t2.total_msgs(), 1, "attach zeroed observed edges");
495        assert!(matches!(
496            SharedTopologyMap::create(&p, 2),
497            Err(TopologyError::LayoutMismatch),
498        ));
499
500        // Windows refuses to truncate a mapped file, so every handle goes
501        // before the reset.
502        drop(t);
503        drop(t2);
504        let fresh = SharedTopologyMap::reset(
505            &p, 4, DEFAULT_FAN_OUT_THRESHOLD, DEFAULT_FAN_IN_THRESHOLD,
506        ).unwrap();
507        assert_eq!(fresh.total_msgs(), 0, "reset kept an edge");
508        drop(fresh);
509        std::fs::remove_file(&p).ok();
510    }
511
512    #[test]
513    fn create_initial_state() {
514        let p = tmp("init");
515        let t = SharedTopologyMap::create(&p, 4).unwrap();
516        assert_eq!(t.n_nodes(), 4);
517        assert_eq!(t.total_msgs(), 0);
518        assert_eq!(t.max_fan_out(), (0, 0));
519        assert_eq!(t.max_fan_in(), (0, 0));
520        assert_eq!(t.recommend(), TopologyKind::PointToPoint);
521        std::fs::remove_file(&p).ok();
522    }
523
524    #[test]
525    fn record_send_increments_edge_and_total() {
526        let p = tmp("record");
527        let t = SharedTopologyMap::create(&p, 4).unwrap();
528        let new_count = t.record_send(0, 1).unwrap();
529        assert_eq!(new_count, 1);
530        assert_eq!(t.total_msgs(), 1);
531        t.record_send(0, 1).unwrap();
532        assert_eq!(t.total_msgs(), 2);
533        std::fs::remove_file(&p).ok();
534    }
535
536    #[test]
537    fn fan_out_in_count_distinct_edges() {
538        let p = tmp("fan");
539        let t = SharedTopologyMap::create(&p, 4).unwrap();
540        // Node 0 sends to nodes 1, 2, 3 → fan-out = 3.
541        t.record_send(0, 1).unwrap();
542        t.record_send(0, 2).unwrap();
543        t.record_send(0, 3).unwrap();
544        assert_eq!(t.fan_out(0), 3);
545        assert_eq!(t.fan_in(0), 0);
546        // Multiple sends to the same dst still count as one edge.
547        t.record_send(0, 1).unwrap();
548        t.record_send(0, 1).unwrap();
549        assert_eq!(t.fan_out(0), 3);
550        // Node 2 receives from no one yet... wait, node 0 sent to 2.
551        assert_eq!(t.fan_in(2), 1);
552        std::fs::remove_file(&p).ok();
553    }
554
555    #[test]
556    fn out_of_bounds_record_returns_error() {
557        let p = tmp("oob");
558        let t = SharedTopologyMap::create(&p, 4).unwrap();
559        assert_eq!(t.record_send(4, 0).err(), Some(TopologyError::NodeIndexOutOfBounds));
560        assert_eq!(t.record_send(0, 99).err(), Some(TopologyError::NodeIndexOutOfBounds));
561        std::fs::remove_file(&p).ok();
562    }
563
564    #[test]
565    fn recommend_point_to_point_when_fan_low() {
566        let p = tmp("rec-p2p");
567        let t = SharedTopologyMap::create(&p, 4).unwrap();
568        // 1:1 flow: node 0 → node 1.
569        for _ in 0..100 { t.record_send(0, 1).unwrap(); }
570        assert_eq!(t.recommend(), TopologyKind::PointToPoint);
571        std::fs::remove_file(&p).ok();
572    }
573
574    #[test]
575    fn recommend_broadcast_when_fan_out_high_only() {
576        let p = tmp("rec-bcast");
577        let t = SharedTopologyMap::create(&p, 5).unwrap();
578        // Node 0 broadcasts to 1, 2, 3, 4 - high fan-out, low fan-in.
579        for d in 1..5 { t.record_send(0, d).unwrap(); }
580        assert_eq!(t.fan_out(0), 4);
581        assert_eq!(t.max_fan_in(), (1, 1));  // each receives 1 from one source
582        assert_eq!(t.recommend(), TopologyKind::BroadcastTree);
583        std::fs::remove_file(&p).ok();
584    }
585
586    #[test]
587    fn recommend_all_to_all_when_both_high() {
588        let p = tmp("rec-mesh");
589        let t = SharedTopologyMap::create(&p, 5).unwrap();
590        // Every node sends to every other node.
591        for s in 0..5u32 {
592            for d in 0..5u32 {
593                if s != d { t.record_send(s, d).unwrap(); }
594            }
595        }
596        assert_eq!(t.max_fan_out().0, 4);
597        assert_eq!(t.max_fan_in().0, 4);
598        assert_eq!(t.recommend(), TopologyKind::AllToAllMesh);
599        std::fs::remove_file(&p).ok();
600    }
601
602    #[test]
603    fn publish_recommendation_caches_for_o1_reads() {
604        let p = tmp("publish");
605        let t = SharedTopologyMap::create(&p, 5).unwrap();
606        for d in 1..5 { t.record_send(0, d).unwrap(); }
607        assert_eq!(t.recommendation_epoch(), 0);
608        let published = t.publish_recommendation();
609        assert_eq!(published, TopologyKind::BroadcastTree);
610        assert_eq!(t.read_recommendation(), TopologyKind::BroadcastTree);
611        assert_eq!(t.recommendation_epoch(), 1);
612        // Broadcast root is the highest-fan-out source.
613        assert_eq!(t.broadcast_root(), 0);
614        std::fs::remove_file(&p).ok();
615    }
616
617    #[test]
618    fn cross_handle_observation_visible() {
619        let p = tmp("cross-handle");
620        let writer = SharedTopologyMap::create(&p, 4).unwrap();
621        let observer = SharedTopologyMap::open(&p, 4).unwrap();
622        writer.record_send(0, 1).unwrap();
623        writer.record_send(0, 2).unwrap();
624        writer.record_send(0, 3).unwrap();
625        assert_eq!(observer.fan_out(0), 3);
626        writer.publish_recommendation();
627        assert_eq!(observer.read_recommendation(), TopologyKind::BroadcastTree);
628        std::fs::remove_file(&p).ok();
629    }
630
631    #[test]
632    fn reset_observations_clears_all_edges() {
633        let p = tmp("reset");
634        let t = SharedTopologyMap::create(&p, 4).unwrap();
635        for s in 0..4u32 {
636            for d in 0..4u32 {
637                t.record_send(s, d).unwrap();
638            }
639        }
640        assert_eq!(t.total_msgs(), 16);
641        t.reset_observations();
642        assert_eq!(t.total_msgs(), 0);
643        assert_eq!(t.fan_out(0), 0);
644        assert_eq!(t.fan_in(0), 0);
645        std::fs::remove_file(&p).ok();
646    }
647
648    #[test]
649    fn concurrent_record_sends_count_correctly() {
650        let p = tmp("concurrent");
651        let t = Arc::new(SharedTopologyMap::create(&p, 4).unwrap());
652        let n_threads = 4;
653        let per_thread = 100;
654        let mut handles = vec![];
655        for src in 0..n_threads as u32 {
656            let t = t.clone();
657            handles.push(thread::spawn(move || {
658                for _ in 0..per_thread {
659                    for dst in 0..4u32 {
660                        if src != dst { t.record_send(src, dst).unwrap(); }
661                    }
662                }
663            }));
664        }
665        for h in handles { h.join().unwrap(); }
666        // Each thread sent (4-1) edges * per_thread times.
667        assert_eq!(t.total_msgs(), (n_threads * 3 * per_thread) as u64);
668        // Every source has fan_out = 3 (all dst != self).
669        for s in 0..n_threads as u32 {
670            assert_eq!(t.fan_out(s), 3, "src {s} should have fan_out 3");
671        }
672        // The full N-to-N pattern triggers AllToAllMesh.
673        assert_eq!(t.recommend(), TopologyKind::AllToAllMesh);
674        std::fs::remove_file(&p).ok();
675    }
676
677    #[test]
678    fn set_thresholds_adjusts_recommendation_policy() {
679        let p = tmp("thresholds");
680        let t = SharedTopologyMap::create(&p, 5).unwrap();
681        // Node 0 sends to 1, 2 - fan_out=2 (below default 3, recommends P2P).
682        t.record_send(0, 1).unwrap();
683        t.record_send(0, 2).unwrap();
684        assert_eq!(t.recommend(), TopologyKind::PointToPoint);
685        // Lower fan_out threshold to 2 → now BroadcastTree applies.
686        t.set_thresholds(2, 3);
687        assert_eq!(t.recommend(), TopologyKind::BroadcastTree);
688        std::fs::remove_file(&p).ok();
689    }
690
691    #[test]
692    fn stats_snapshot_returns_full_picture() {
693        let p = tmp("stats");
694        let t = SharedTopologyMap::create(&p, 5).unwrap();
695        for d in 1..5u32 { t.record_send(0, d).unwrap(); }
696        t.publish_recommendation();
697        let s = t.stats();
698        assert_eq!(s.total_msgs, 4);
699        assert_eq!(s.max_fan_out, 4);
700        assert_eq!(s.max_fan_out_src, 0);
701        assert_eq!(s.max_fan_in, 1);
702        assert_eq!(s.current_recommendation, TopologyKind::BroadcastTree);
703        assert_eq!(s.recommendation_epoch, 1);
704        std::fs::remove_file(&p).ok();
705    }
706
707    #[test]
708    fn topology_kind_from_u32_round_trip() {
709        assert_eq!(TopologyKind::from_u32(0), TopologyKind::PointToPoint);
710        assert_eq!(TopologyKind::from_u32(1), TopologyKind::BroadcastTree);
711        assert_eq!(TopologyKind::from_u32(2), TopologyKind::AllToAllMesh);
712        // Unknown values default to PointToPoint (defensive).
713        assert_eq!(TopologyKind::from_u32(999), TopologyKind::PointToPoint);
714    }
715
716    #[test]
717    fn disk_persistence_survives_reopen() {
718        let p = tmp("disk");
719        {
720            let t = SharedTopologyMap::create(&p, 4).unwrap();
721            for d in 1..4u32 { t.record_send(0, d).unwrap(); }
722            t.publish_recommendation();
723            t.flush().unwrap();
724        }
725        let t2 = SharedTopologyMap::open(&p, 4).unwrap();
726        assert_eq!(t2.total_msgs(), 3);
727        assert_eq!(t2.fan_out(0), 3);
728        assert_eq!(t2.read_recommendation(), TopologyKind::BroadcastTree);
729        std::fs::remove_file(&p).ok();
730    }
731
732    #[test]
733    fn recommendation_demotes_when_observations_drop() {
734        let p = tmp("demote");
735        let t = SharedTopologyMap::create(&p, 5).unwrap();
736        // Start with broadcast pattern.
737        for d in 1..5u32 { t.record_send(0, d).unwrap(); }
738        assert_eq!(t.recommend(), TopologyKind::BroadcastTree);
739        // New observation epoch: only 1:1 traffic.
740        t.reset_observations();
741        for _ in 0..10 { t.record_send(0, 1).unwrap(); }
742        assert_eq!(t.recommend(), TopologyKind::PointToPoint);
743        std::fs::remove_file(&p).ok();
744    }
745}