Skip to main content

hyperactor_cast/
cast_actor.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! The CastActor: a system actor that bootstraps and manages casting domains.
10
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use anyhow::Result;
15use async_trait::async_trait;
16use hyperactor::Actor;
17use hyperactor::ActorAddr;
18use hyperactor::ActorRef;
19use hyperactor::Context;
20use hyperactor::Endpoint as _;
21use hyperactor::Handler;
22use hyperactor::Instance;
23use hyperactor::Label;
24use hyperactor::OncePortRefRepr;
25use hyperactor::PortRef;
26use hyperactor::PortRefRepr;
27use hyperactor::RemoteEndpoint as _;
28use hyperactor::Uid;
29use hyperactor::accum::ReducerMode;
30use hyperactor::context;
31use hyperactor::mailbox::MailboxSender;
32use hyperactor::mailbox::MessageEnvelope;
33use hyperactor::mailbox::Undeliverable;
34use hyperactor::mailbox::UndeliverableMailboxSender;
35use hyperactor::mailbox::UndeliverableMessageError;
36use hyperactor::mailbox::monitored_return_handle;
37use hyperactor::ordering::SEQ_INFO;
38use hyperactor::ordering::SeqInfo;
39use hyperactor::port::Port;
40use hyperactor::value_mesh::ValueMesh;
41use hyperactor_config::Flattrs;
42use ndslice::Point;
43use ndslice::Region;
44use ndslice::view::MapIntoExt;
45use ndslice::view::View;
46use serde::Deserialize;
47use serde::Serialize;
48use typeuri::Named;
49use uuid::Uuid;
50
51use crate::tile::MaterializedTile;
52use crate::tile::Tile;
53pub use crate::tile::TilingPolicy;
54
55hyperactor_config::declare_attrs! {
56    /// Header stamped on each locally delivered message with the
57    /// recipient's point within the casting domain.
58    pub attr CAST_POINT: Point;
59
60    /// Header stamped on each locally delivered message with the
61    /// original sender that initiated the cast.
62    pub attr CAST_ORIGINATING_SENDER: ActorAddr;
63
64    /// The multicast phase that attached context to a delivery failure.
65    pub attr CAST_FAILURE_PHASE: String;
66
67    /// The cast actor that attached multicast context to a delivery failure.
68    pub attr CAST_FAILURE_CAST_ACTOR: ActorAddr;
69
70    /// The originating cast sender.
71    pub attr CAST_FAILURE_ORIGIN: ActorAddr;
72
73    /// The return port used to send the undeliverable message to the origin.
74    pub attr CAST_FAILURE_RETURN_PORT: String;
75}
76
77#[cfg(test)]
78hyperactor_config::declare_attrs! {
79    /// Header stamped in tests with the cast tree path used to reach this
80    /// recipient.
81    pub attr CAST_LINEAGE: Vec<usize>;
82}
83
84/// Wire-compatible mirror of `hyperactor_mesh::resource::RankRepr`.
85///
86/// `hyperactor_cast` cannot depend on `hyperactor_mesh` without creating a
87/// crate cycle, but cast delivery still needs to stamp the rank multipart part
88/// for messages whose handlers read rank from the payload. The part is tagged
89/// with `RankRepr`'s typename, so this mirror must match it exactly.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91struct ResourceRankRepr(Option<usize>);
92
93impl Named for ResourceRankRepr {
94    fn typename() -> &'static str {
95        "hyperactor_mesh::resource::RankRepr"
96    }
97}
98
99/// Pure, data-only identifier for a cast domain.
100///
101/// This type contains no runtime references (e.g. `ActorRef`) and can
102/// be freely serialized, cloned, and shared.
103#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
104pub struct CastDomainId {
105    /// Unique identifier for this domain's installed communication plan.
106    domain_id: Uid,
107}
108
109impl CastDomainId {
110    /// Create a new domain id.
111    pub fn new() -> Self {
112        Self {
113            domain_id: Uid::anonymous(),
114        }
115    }
116
117    /// The domain's unique identifier.
118    pub fn domain_id(&self) -> &Uid {
119        &self.domain_id
120    }
121
122    /// Materialize this domain id over concrete members and return an
123    /// addressable domain handle.
124    ///
125    /// `members` maps domain rank to member actor address. `region` describes
126    /// the logical root region of the domain; tiling and communication are
127    /// derived internally.
128    pub fn materialize(
129        self,
130        cx: &impl context::Actor,
131        members: HashMap<usize, ActorAddr>,
132        region: Region,
133        tiling_policy: TilingPolicy,
134        headers: Flattrs,
135    ) -> anyhow::Result<CastDomainRef> {
136        anyhow::ensure!(
137            members.len() == region.num_ranks()
138                && region
139                    .slice()
140                    .iter()
141                    .all(|rank| members.contains_key(&rank)),
142            "members must contain exactly one actor address for every domain rank"
143        );
144
145        let root_rank = region.slice().offset();
146        let entry_point = members
147            .get(&root_rank)
148            .expect("members coverage was checked above")
149            .clone();
150        let member_mesh = Arc::new(ValueMesh::new(
151            region.clone(),
152            region
153                .slice()
154                .iter()
155                .map(|rank| {
156                    members
157                        .get(&rank)
158                        .expect("members coverage was checked above")
159                        .clone()
160                })
161                .collect(),
162        )?);
163
164        let domain_ref = CastDomainRef::from_entry_point(
165            self.clone(),
166            cast_actor_ref_for_member(&entry_point),
167            Arc::clone(&member_mesh),
168        );
169        let root_tile =
170            MaterializedTile::from_value_mesh_with_tile(Tile::from_view(&region), member_mesh);
171
172        domain_ref.entry_point.port().post_with_headers(
173            cx,
174            headers,
175            CreateCastDomain {
176                cast_domain_id: self,
177                region,
178                tiling_policy,
179                tile: root_tile,
180            },
181        );
182        Ok(domain_ref)
183    }
184}
185
186impl std::fmt::Display for CastDomainId {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        write!(f, "{}", self.domain_id)
189    }
190}
191
192/// Opaque local handle for initiating work against a materialized cast domain.
193///
194/// Unlike [`CastDomainId`], this includes an entry-point [`CastActor`] ref so
195/// callers can cast into and otherwise address the domain. Callers obtain this
196/// only by materializing a [`CastDomainId`].
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct CastDomainRef {
199    id: CastDomainId,
200    /// Entry-point [`CastActor`] ref for initiating casts.
201    entry_point: ActorRef<CastActor>,
202    /// Destination actor addresses keyed by this domain's rank space.
203    members: Arc<ValueMesh<ActorAddr>>,
204}
205
206impl CastDomainRef {
207    /// Rebuild a cast-domain handle from a pure id plus its entry-point ref.
208    fn from_entry_point(
209        id: CastDomainId,
210        entry_point: ActorRef<CastActor>,
211        members: Arc<ValueMesh<ActorAddr>>,
212    ) -> Self {
213        Self {
214            id,
215            entry_point,
216            members,
217        }
218    }
219
220    /// The pure identifier for this domain.
221    pub fn id(&self) -> CastDomainId {
222        self.id.clone()
223    }
224
225    /// The domain's unique identifier.
226    pub fn domain_id(&self) -> &Uid {
227        self.id.domain_id()
228    }
229
230    /// The [`CastActor`] entry point for this domain.
231    pub fn entry_point(&self) -> &ActorRef<CastActor> {
232        &self.entry_point
233    }
234
235    /// Destination actor addresses keyed by this domain's rank space.
236    pub fn members(&self) -> &ValueMesh<ActorAddr> {
237        &self.members
238    }
239
240    /// Materialize a new slice domain described relative to this domain.
241    ///
242    /// The returned ref is the handle for the slice. It gets a fresh domain id
243    /// whose entrypoint is the slice's actual root CastActor. The parent ref's
244    /// dense members are used only to derive the slice definition and sender
245    /// side sequence map; the cast path enters directly at the slice root.
246    pub fn materialize_slice(
247        &self,
248        cx: &impl context::Actor,
249        region: Region,
250        tiling_policy: TilingPolicy,
251    ) -> anyhow::Result<CastDomainRef> {
252        let slice_id = CastDomainId::new();
253        let slice_member_mesh = Arc::new(self.members.subset(region.clone())?);
254        let slice_tile = MaterializedTile::from_value_mesh_with_tile(
255            Tile::from_view(&region),
256            Arc::clone(&slice_member_mesh),
257        );
258
259        let slice_entrypoint = cast_actor_ref_for_member(
260            slice_tile
261                .root_item()
262                .ok_or_else(|| anyhow::anyhow!("slice root tile must have at least one member"))?,
263        );
264
265        let slice_ref = CastDomainRef::from_entry_point(
266            slice_id.clone(),
267            slice_entrypoint.clone(),
268            slice_member_mesh,
269        );
270
271        slice_entrypoint.post(
272            cx,
273            CreateCastDomain {
274                cast_domain_id: slice_id.clone(),
275                region,
276                tiling_policy,
277                tile: slice_tile,
278            },
279        );
280        Ok(slice_ref)
281    }
282
283    /// Cast a message to all members of this domain with caller-supplied headers.
284    ///
285    /// `headers` are the destination envelope headers supplied by the caller.
286    /// The cast layer stamps cast-owned fields on top before sending the
287    /// [`CastMessage`] through the domain entry point.
288    pub fn cast<M: Serialize + Named>(
289        &self,
290        cx: &impl context::Actor,
291        headers: Flattrs,
292        message: M,
293    ) -> anyhow::Result<()> {
294        let data = wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&message)?;
295        let sender = cx.mailbox().actor_addr().clone();
296        let dest_port = M::port();
297        let (session_id, seqs) = self.seqs_for_cast(cx, dest_port)?;
298
299        let cast_headers = headers.clone();
300        self.entry_point.port().post_with_headers(
301            cx,
302            headers,
303            CastMessage {
304                cast_domain_id: self.id.clone(),
305                sender,
306                session_id,
307                seqs,
308                #[cfg(test)]
309                lineage: Vec::new(),
310                headers: cast_headers,
311                dest_port,
312                data,
313            },
314        );
315        Ok(())
316    }
317
318    /// Tear down this cast domain.
319    ///
320    /// This is a best-effort, fire-and-forget operation: the request is posted
321    /// to the domain entry point and then forwarded through the installed cast
322    /// tree. Duplicate destroy requests are intentionally ignored by receivers.
323    /// If the mailbox layer bounces a downstream destroy message, the failure is
324    /// returned to the originating actor's handler port, but successful teardown
325    /// is not acknowledged.
326    pub fn destroy(&self, cx: &impl context::Actor) {
327        self.entry_point.post(
328            cx,
329            DestroyCastDomain {
330                domain_id: self.id.clone(),
331                origin: cx.mailbox().actor_addr().clone(),
332            },
333        );
334    }
335
336    /// Allocate one normal sender-side sequence number per destination rank.
337    ///
338    /// This is the same model used by v1 `CommActor`: the cast message carries
339    /// a complete `rank -> seq` snapshot, so forwarding hops do not need
340    /// route-local metadata to derive receiver ordering. `ValueMesh` preserves
341    /// the domain rank space while allowing compact representations when seqs
342    /// happen to be compressible.
343    fn seqs_for_cast(
344        &self,
345        cx: &impl context::Actor,
346        dest_port: u64,
347    ) -> Result<(Uuid, ValueMesh<u64>)> {
348        let sequencer = cx.instance().sequencer();
349
350        let mut seqs: ValueMesh<u64> = self.members.as_ref().map_into(|member| {
351            let port = member.port_addr(Port::handler_id(dest_port, None));
352            let SeqInfo::Session { session_id: _, seq } = sequencer.assign_seq(&port) else {
353                unreachable!("assign_seq always returns SeqInfo::Session");
354            };
355            seq
356        });
357
358        seqs.compress_adjacent_in_place();
359
360        Ok((sequencer.session_id(), seqs))
361    }
362}
363
364/// Return the tiles directly reached from `tile` by the current communication
365/// algorithm.
366///
367/// This asks only for the current tile's outgoing edges without materializing
368/// the full domain tree. The returned [`MaterializedTile`]s are still tiles of
369/// destination actors. Setup/forwarding derives the target [`CastActor`] from
370/// each child tile's root destination actor.
371///
372/// ```text
373/// current MaterializedTile:
374/// T0 [ A0 A1 A2 A3
375///      A4 A5 A6 A7 ]
376///
377/// next_tiles(current), rendered by destination actor rank:
378/// A0
379/// |-- T1 [ A1 ]          -> CastActor on A1's proc
380/// |-- T2 [ A2 ]          -> CastActor on A2's proc
381/// |-- T3 [ A3 ]          -> CastActor on A3's proc
382/// `-- T4 [ A4 A5 A6 A7 ] -> CastActor on A4's proc
383/// ```
384fn next_tiles(
385    tiling_policy: TilingPolicy,
386    tile: &MaterializedTile<ActorAddr>,
387) -> Vec<MaterializedTile<ActorAddr>> {
388    tiling_policy
389        .children(tile.tile())
390        .into_iter()
391        .map(|child| tile.subtile(child))
392        .collect()
393}
394
395/// Well-known actor name for the [`CastActor`] system actor.
396///
397/// One `CastActor` is expected to run on every proc under this name. Internal
398/// setup uses this known address to route setup commands to child tile roots.
399pub const CAST_ACTOR_NAME: &str = "cast";
400
401/// System actor that establishes casting domains.
402///
403/// One CastActor lives on every proc (well-known name [`CAST_ACTOR_NAME`]).
404/// It installs and propagates [`CreateCastDomain`]. After a domain is set up,
405/// the CastActor stores tile-local execution state in [`CastHop`] for
406/// subsequent multicast routing.
407#[derive(Debug, Default)]
408#[hyperactor::export(
409    handlers = [
410        CreateCastDomain,
411        DestroyCastDomain,
412        CastMessage
413    ],
414)]
415#[hyperactor::spawnable]
416pub struct CastActor {
417    /// Per-hop routing state installed on this actor.
418    installed_hops: HashMap<CastDomainId, CastHop>,
419}
420
421/// One tile-local hop in an installed cast tree.
422#[derive(Debug, Clone)]
423struct CastHop {
424    /// Current hop representative's point in the full domain region.
425    point_in_domain: Point,
426    /// Current hop representative's base rank in the full domain region.
427    base_rank_in_domain: usize,
428    /// Precomputed outgoing routes to communication-child tiles.
429    next_hops: Vec<ActorRef<CastActor>>,
430    /// Actor that receives local delivery when this hop is reached.
431    local_actor: ActorAddr,
432}
433
434fn cast_actor_ref_for_member(member: &ActorAddr) -> ActorRef<CastActor> {
435    ActorRef::attest(ActorAddr::root(
436        member.proc_addr(),
437        Label::strip(CAST_ACTOR_NAME),
438    ))
439}
440
441fn annotate_cast_failure(
442    envelope: &mut MessageEnvelope,
443    cast_actor: &ActorAddr,
444    phase: &str,
445    origin: &ActorAddr,
446    return_port: &hyperactor::PortAddr,
447) {
448    if let Some(failure) = envelope.root_delivery_failure_mut() {
449        failure.attrs.set(CAST_FAILURE_PHASE, phase.to_string());
450        failure
451            .attrs
452            .set(CAST_FAILURE_CAST_ACTOR, cast_actor.clone());
453        failure.attrs.set(CAST_FAILURE_ORIGIN, origin.clone());
454        failure
455            .attrs
456            .set(CAST_FAILURE_RETURN_PORT, return_port.to_string());
457    }
458}
459
460#[async_trait]
461impl Actor for CastActor {
462    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
463        this.set_system();
464        Ok(())
465    }
466
467    async fn handle_undeliverable_message(
468        &mut self,
469        cx: &Instance<Self>,
470        _reason: hyperactor::mailbox::UndeliverableReason,
471        undelivered: Undeliverable<MessageEnvelope>,
472    ) -> Result<(), anyhow::Error> {
473        self.return_delivery_failure_to_origin(cx, undelivered)
474            .await
475    }
476
477    async fn handle_invalid_reference(
478        &mut self,
479        cx: &Instance<Self>,
480        _invalid: hyperactor::mailbox::InvalidReference,
481        undelivered: Undeliverable<MessageEnvelope>,
482    ) -> Result<(), anyhow::Error> {
483        self.return_delivery_failure_to_origin(cx, undelivered)
484            .await
485    }
486}
487
488impl CastActor {
489    async fn return_delivery_failure_to_origin(
490        &mut self,
491        cx: &Instance<Self>,
492        undelivered: Undeliverable<MessageEnvelope>,
493    ) -> Result<(), anyhow::Error> {
494        // This is almost 1-1 copied from `hyperactor_mesh::comm::CommActor`.
495        let mut message_envelope = match undelivered {
496            Undeliverable::Returned(message_envelope) => message_envelope,
497            Undeliverable::Report(report) => {
498                anyhow::bail!(UndeliverableMessageError::Report { report });
499            }
500        };
501
502        // 1. Case delivery failure at a "forwarding" step.
503        if let Ok(message) = message_envelope.deserialized::<CastMessage>() {
504            let return_port = PortRef::attest_handler_port(&message.sender);
505            annotate_cast_failure(
506                &mut message_envelope,
507                cx.self_addr(),
508                "forward",
509                &message.sender,
510                return_port.port_addr(),
511            );
512
513            // Needed so that the receiver of the undeliverable message can easily find the
514            // original sender of the cast message.
515            message_envelope.set_header(CAST_ORIGINATING_SENDER, message.sender.clone());
516
517            return_port.post(cx, Undeliverable::Returned(message_envelope.clone()));
518            return Ok(());
519        }
520
521        // 2. Failure while forwarding a destroy request.
522        if let Ok(message) = message_envelope.deserialized::<DestroyCastDomain>() {
523            let return_port = PortRef::attest_handler_port(&message.origin);
524            annotate_cast_failure(
525                &mut message_envelope,
526                cx.self_addr(),
527                "destroy",
528                &message.origin,
529                return_port.port_addr(),
530            );
531            message_envelope.set_header(CAST_ORIGINATING_SENDER, message.origin.clone());
532            return_port.post(cx, Undeliverable::Returned(message_envelope.clone()));
533            return Ok(());
534        }
535
536        // 3. Failure while delivering from this CastActor to the local
537        // destination actor.
538        if let Some(sender) = message_envelope.headers().get(CAST_ORIGINATING_SENDER) {
539            let return_port = PortRef::attest_handler_port(&sender);
540            annotate_cast_failure(
541                &mut message_envelope,
542                cx.self_addr(),
543                "deliver_here",
544                &sender,
545                return_port.port_addr(),
546            );
547            return_port.post(cx, Undeliverable::Returned(message_envelope.clone()));
548            return Ok(());
549        }
550
551        // 4. A return of an undeliverable message was itself returned.
552        UndeliverableMailboxSender
553            .post(message_envelope, /*unused */ monitored_return_handle());
554        Ok(())
555    }
556}
557
558/// Install one hop of a cast domain and propagate setup down the routing tree.
559///
560/// Root materialization sends this to the root tile's [`CastActor`]. Each
561/// receiving [`CastActor`] stores its [`CastHop`], computes outgoing next hops
562/// from its materialized tile, and forwards this same message with the
563/// corresponding communication-child tile.
564#[derive(Debug, Serialize, Deserialize, typeuri::Named)]
565struct CreateCastDomain {
566    cast_domain_id: CastDomainId,
567    region: Region,
568    tiling_policy: TilingPolicy,
569    tile: MaterializedTile<ActorAddr>,
570}
571wirevalue::register_type!(CreateCastDomain);
572
573#[async_trait]
574impl Handler<CreateCastDomain> for CastActor {
575    #[tracing::instrument(
576        level = "debug",
577        skip_all,
578        fields(
579            domain_id = %message.cast_domain_id,
580            rank = message.tile.root_rank(),
581            num_members = message.tile.rank_count(),
582        )
583    )]
584    async fn handle(
585        &mut self,
586        cx: &Context<Self>,
587        message: CreateCastDomain,
588    ) -> Result<(), anyhow::Error> {
589        let CreateCastDomain {
590            cast_domain_id,
591            region,
592            tiling_policy,
593            tile,
594        } = message;
595        if self.installed_hops.contains_key(&cast_domain_id) {
596            return Ok(());
597        }
598
599        let mut next_hops = Vec::new();
600
601        for next_tile in next_tiles(tiling_policy, &tile) {
602            let next_hop_cast_actor = cast_actor_ref_for_member(
603                next_tile
604                    .root_item()
605                    .ok_or_else(|| anyhow::anyhow!("next tile must have at least one member"))?,
606            );
607
608            next_hop_cast_actor.post(
609                cx,
610                CreateCastDomain {
611                    cast_domain_id: cast_domain_id.clone(),
612                    region: region.clone(),
613                    tiling_policy,
614                    tile: next_tile,
615                },
616            );
617
618            next_hops.push(next_hop_cast_actor);
619        }
620
621        let cast_hop = CastHop {
622            point_in_domain: region.point_of_base_rank(tile.root_rank())?,
623            base_rank_in_domain: tile.root_rank(),
624            next_hops,
625            local_actor: tile
626                .root_item()
627                .ok_or_else(|| anyhow::anyhow!("tile must have at least one member"))?
628                .clone(),
629        };
630
631        #[cfg(test)]
632        {
633            tests::capture_installed_domain(cx, cast_domain_id.domain_id(), &cast_hop);
634        }
635
636        self.installed_hops.insert(cast_domain_id, cast_hop);
637
638        Ok(())
639    }
640}
641
642/// Rewrite reply port parts in the serialized message so that downstream
643/// actors reply through local proxy ports on this CastActor instead
644/// of directly to the original sender. Each proxy port reduces
645/// replies from downstream next hops plus the optional local delivery,
646/// forming a reduction tree that mirrors the cast tree.
647///
648/// Ported from `hyperactor_mesh::comm::split_ports`.
649fn split_ports(
650    cx: &Context<'_, CastActor>,
651    data: &mut wirevalue::Any<wirevalue::encoding::Multipart>,
652    num_next_hops: usize,
653    deliver_here: bool,
654) -> Result<()> {
655    data.visit_multipart_parts_mut::<PortRefRepr, anyhow::Error>(|port| {
656        if port.unsplit() {
657            return Ok(());
658        }
659
660        let split = port.port_addr().split(
661            cx,
662            port.reducer_spec().clone(),
663            ReducerMode::Streaming(port.streaming_opts().clone()),
664            port.get_return_undeliverable(),
665        )?;
666
667        #[cfg(test)]
668        {
669            tests::collect_split_port(port.port_addr(), &split, deliver_here);
670        }
671
672        port.update_port_addr(split);
673        Ok(())
674    })?;
675
676    data.visit_multipart_parts_mut::<OncePortRefRepr, anyhow::Error>(|port| {
677        if port.unsplit() || port.reducer_spec().is_none() {
678            // OncePorts without reducers cannot be split. Pass through as-is.
679            // Using the port more than once will cause a delivery error downstream.
680            return Ok(());
681        }
682
683        let peer_count = num_next_hops + if deliver_here { 1 } else { 0 };
684        let split = port.port_addr().split(
685            cx,
686            port.reducer_spec().clone(),
687            ReducerMode::Once(peer_count),
688            true,
689        )?;
690
691        #[cfg(test)]
692        {
693            tests::collect_split_port(port.port_addr(), &split, deliver_here);
694        }
695
696        port.update_port_addr(split);
697        Ok(())
698    })?;
699
700    Ok(())
701}
702
703/// Test-only forwarding path metadata.
704///
705/// In production this is zero-sized and optimized away. In tests, each
706/// forwarded message carries the semantic tile-root ranks already traversed,
707/// and local delivery appends the current tile root.
708#[derive(Debug, Clone, Default)]
709struct ForwardLineage {
710    #[cfg(test)]
711    ranks: Vec<usize>,
712}
713
714impl ForwardLineage {
715    #[cfg(test)]
716    fn from_message(message: &CastMessage) -> Self {
717        Self {
718            ranks: message.lineage.clone(),
719        }
720    }
721
722    #[cfg(not(test))]
723    fn from_message(_message: &CastMessage) -> Self {
724        Self {}
725    }
726
727    fn through(&self, rank: usize) -> Self {
728        #[cfg(test)]
729        {
730            let mut ranks = self.ranks.clone();
731            ranks.push(rank);
732            Self { ranks }
733        }
734        #[cfg(not(test))]
735        {
736            let _ = rank;
737            Self {}
738        }
739    }
740
741    #[cfg(test)]
742    fn ranks(&self) -> Vec<usize> {
743        self.ranks.clone()
744    }
745}
746
747/// Multicast payload routed through a cast domain.
748///
749/// Clients send this to a domain entry point. CastActors forward the same
750/// message type to child hops; internal forwards differ only in test-only
751/// lineage.
752#[derive(Debug, Serialize, Deserialize, typeuri::Named)]
753struct CastMessage {
754    /// The domain to cast into.
755    cast_domain_id: CastDomainId,
756    /// Actor that initiated the cast.
757    sender: ActorAddr,
758    /// Sender-side sequencer session for this cast.
759    session_id: Uuid,
760    /// Per-domain-rank sequence numbers allocated by the sender before routing.
761    seqs: ValueMesh<u64>,
762    /// Test-only semantic path of tile root ranks traversed so far.
763    #[cfg(test)]
764    lineage: Vec<usize>,
765    /// Message headers.
766    headers: Flattrs,
767    /// The target port index on each destination actor.
768    dest_port: u64,
769    /// The serialized message data.
770    data: wirevalue::Any<wirevalue::encoding::Multipart>,
771}
772
773wirevalue::register_type!(CastMessage);
774
775#[async_trait]
776impl Handler<CastMessage> for CastActor {
777    #[tracing::instrument(
778        level = "debug",
779        skip_all,
780        fields(
781            domain_id = %message.cast_domain_id.domain_id(),
782        )
783    )]
784    async fn handle(
785        &mut self,
786        cx: &Context<Self>,
787        message: CastMessage,
788    ) -> Result<(), anyhow::Error> {
789        let lineage = ForwardLineage::from_message(&message);
790        let domain = self
791            .installed_hops
792            .get(&message.cast_domain_id)
793            .ok_or_else(|| anyhow::anyhow!("unknown domain {}", message.cast_domain_id))?;
794
795        // Split reply ports so that downstream next hops reply through this
796        // CastActor's local proxy ports instead of directly to the original
797        // sender.
798        let mut data = message.data.clone();
799        split_ports(cx, &mut data, domain.next_hops.len(), true)?;
800
801        let local_lineage = lineage.through(domain.base_rank_in_domain);
802
803        // Deliver to destination actor.
804        {
805            let mut local_data = data.clone();
806
807            let rank = domain.point_in_domain.rank();
808
809            local_data.visit_multipart_parts_mut::<ResourceRankRepr, anyhow::Error>(
810                |ResourceRankRepr(resource_rank)| {
811                    *resource_rank = Some(rank);
812                    Ok(())
813                },
814            )?;
815
816            let seq = *message
817                .seqs
818                .get_by_base_rank(domain.base_rank_in_domain)
819                .ok_or_else(|| {
820                    anyhow::anyhow!("missing seq for base rank {}", domain.base_rank_in_domain)
821                })?;
822            let mut headers = message.headers.clone();
823            headers.set(CAST_POINT, domain.point_in_domain.clone());
824            headers.set(CAST_ORIGINATING_SENDER, message.sender.clone());
825            let seq_info = SeqInfo::Session {
826                session_id: message.session_id,
827                seq,
828            };
829            headers.set(SEQ_INFO, seq_info.clone());
830
831            #[cfg(not(test))]
832            let _ = &local_lineage;
833
834            #[cfg(test)]
835            headers.set(CAST_LINEAGE, local_lineage.ranks());
836
837            let dest = domain
838                .local_actor
839                .port_addr(Port::handler_id(message.dest_port, None));
840            hyperactor::mailbox::headers::stamp_sender_actor_id(
841                &mut headers,
842                &seq_info,
843                &dest,
844                &message.sender,
845            );
846            cx.post_with_external_seq_info(dest, headers, local_data.erase_encoding());
847        }
848
849        for next_hop in &domain.next_hops {
850            #[cfg(not(test))]
851            let _ = &local_lineage;
852            let forward_headers = message.headers.clone();
853            next_hop.port().post_with_headers(
854                cx,
855                forward_headers,
856                CastMessage {
857                    cast_domain_id: message.cast_domain_id.clone(),
858                    sender: message.sender.clone(),
859                    session_id: message.session_id,
860                    seqs: message.seqs.clone(),
861                    #[cfg(test)]
862                    lineage: local_lineage.ranks(),
863                    headers: message.headers.clone(),
864                    dest_port: message.dest_port,
865                    data: data.clone(),
866                },
867            );
868        }
869
870        Ok(())
871    }
872}
873
874/// Internal command to destroy a casting domain and release its local routing state.
875///
876/// Sent to the entry-point [`CastActor`] by [`CastDomainRef::destroy`]. The
877/// teardown propagates through the tree: each node removes its [`CastHop`] and
878/// forwards [`DestroyCastDomain`] to its next hops. This is intentionally
879/// idempotent and best-effort; unknown domains are ignored, and mailbox-level
880/// undeliverable failures are returned to [`DestroyCastDomain::origin`].
881#[derive(Debug, Serialize, Deserialize, typeuri::Named)]
882struct DestroyCastDomain {
883    /// The domain to tear down.
884    domain_id: CastDomainId,
885    /// Actor that initiated teardown and should receive undeliverable returns.
886    origin: ActorAddr,
887}
888wirevalue::register_type!(DestroyCastDomain);
889
890#[async_trait]
891impl Handler<DestroyCastDomain> for CastActor {
892    #[tracing::instrument(
893        level = "debug",
894        skip_all,
895        fields(domain_id = %message.domain_id)
896    )]
897    async fn handle(
898        &mut self,
899        cx: &Context<Self>,
900        message: DestroyCastDomain,
901    ) -> Result<(), anyhow::Error> {
902        let Some(cast_hop) = self.installed_hops.remove(&message.domain_id) else {
903            return Ok(());
904        };
905
906        #[cfg(test)]
907        {
908            tests::capture_destroyed_domain(cx, message.domain_id.domain_id());
909        }
910
911        for next_hop in &cast_hop.next_hops {
912            next_hop.post(
913                cx,
914                DestroyCastDomain {
915                    domain_id: message.domain_id.clone(),
916                    origin: message.origin.clone(),
917                },
918            );
919        }
920
921        Ok(())
922    }
923}
924
925#[cfg(test)]
926mod tests {
927    use std::collections::BTreeMap;
928    use std::collections::BTreeSet;
929    use std::collections::HashMap;
930    use std::num::NonZeroUsize;
931    use std::sync::Mutex;
932    use std::sync::OnceLock;
933    use std::time::Duration;
934
935    use hyperactor::Client;
936    use hyperactor::PortAddr;
937    use hyperactor::ProcAddr;
938    use hyperactor::channel::ChannelTransport;
939    use hyperactor::proc::Proc;
940    use ndslice::Shape;
941    use ndslice::Slice;
942    use ndslice::ViewExt;
943    use ndslice::shape;
944    use ndslice::view::BuildFromRegionIndexed;
945    use ndslice::view::Ranked;
946    use proptest::prelude::*;
947    use timed_test::async_timed_test;
948    use typeuri::Named;
949
950    use super::*;
951
952    fn small_shape_sizes() -> impl Strategy<Value = Vec<usize>> {
953        prop::collection::vec(1usize..=4, 1..=4).prop_filter("shape must stay small", |sizes| {
954            sizes.iter().product::<usize>() <= 64
955        })
956    }
957
958    fn shape_from_sizes(sizes: &[usize]) -> Shape {
959        Shape::new(
960            (0..sizes.len()).map(|dim| format!("d{dim}")).collect(),
961            Slice::new_row_major(sizes.to_vec()),
962        )
963        .unwrap()
964    }
965
966    fn member(rank: usize) -> ActorAddr {
967        ActorAddr::root(
968            format!("proc{rank}@inproc://{rank}")
969                .parse::<ProcAddr>()
970                .unwrap(),
971            Label::strip("member"),
972        )
973    }
974
975    fn validate_domain_tree(
976        members: &HashMap<usize, ActorAddr>,
977        tile: &MaterializedTile<ActorAddr>,
978        seen_roots: &mut BTreeSet<usize>,
979    ) -> Result<(), TestCaseError> {
980        let expected_members = tile
981            .tile()
982            .space()
983            .iter()
984            .map(|rank| members[&rank].clone())
985            .collect::<Vec<_>>();
986        prop_assert_eq!(tile.items().cloned().collect::<Vec<_>>(), expected_members);
987        prop_assert!(seen_roots.insert(tile.root_rank()));
988
989        for child in next_tiles(TilingPolicy::BlockPartitioning, tile) {
990            validate_domain_tree(members, &child, seen_roots)?;
991        }
992
993        Ok(())
994    }
995
996    #[derive(Clone, Debug, PartialEq, Eq)]
997    pub(crate) struct CastHopSnapshot {
998        point_in_domain: Point,
999        base_rank_in_domain: usize,
1000        next_hop_procs: BTreeSet<String>,
1001        local_actor_proc: String,
1002    }
1003
1004    static INSTALLED_DOMAINS: OnceLock<Mutex<HashMap<Uid, BTreeMap<String, CastHopSnapshot>>>> =
1005        OnceLock::new();
1006    static DESTROYED_DOMAINS: OnceLock<Mutex<HashMap<Uid, BTreeSet<String>>>> = OnceLock::new();
1007
1008    fn installed_domains() -> &'static Mutex<HashMap<Uid, BTreeMap<String, CastHopSnapshot>>> {
1009        INSTALLED_DOMAINS.get_or_init(|| Mutex::new(HashMap::new()))
1010    }
1011
1012    fn destroyed_domains() -> &'static Mutex<HashMap<Uid, BTreeSet<String>>> {
1013        DESTROYED_DOMAINS.get_or_init(|| Mutex::new(HashMap::new()))
1014    }
1015
1016    pub(crate) fn capture_installed_domain(
1017        cx: &Context<'_, CastActor>,
1018        domain_id: &Uid,
1019        cast_hop: &CastHop,
1020    ) {
1021        let proc_name = cx.self_addr().proc_addr().log_name().to_string();
1022        let snapshot = CastHopSnapshot {
1023            point_in_domain: cast_hop.point_in_domain.clone(),
1024            base_rank_in_domain: cast_hop.base_rank_in_domain,
1025            next_hop_procs: cast_hop
1026                .next_hops
1027                .iter()
1028                .map(|next_hop| next_hop.actor_addr().proc_addr().log_name().to_string())
1029                .collect(),
1030            local_actor_proc: cast_hop.local_actor.proc_addr().log_name().to_string(),
1031        };
1032        installed_domains()
1033            .lock()
1034            .unwrap()
1035            .entry(domain_id.clone())
1036            .or_default()
1037            .insert(proc_name, snapshot);
1038    }
1039
1040    pub(crate) fn capture_destroyed_domain(cx: &Context<'_, CastActor>, domain_id: &Uid) {
1041        let proc_name = cx.self_addr().proc_addr().log_name().to_string();
1042        destroyed_domains()
1043            .lock()
1044            .unwrap()
1045            .entry(domain_id.clone())
1046            .or_default()
1047            .insert(proc_name);
1048    }
1049
1050    fn clear_captured_domains() {
1051        installed_domains().lock().unwrap().clear();
1052        destroyed_domains().lock().unwrap().clear();
1053    }
1054
1055    fn captured_domain_snapshots(domain_id: &Uid) -> BTreeMap<String, CastHopSnapshot> {
1056        installed_domains()
1057            .lock()
1058            .unwrap()
1059            .get(domain_id)
1060            .cloned()
1061            .unwrap_or_default()
1062    }
1063
1064    fn destroyed_domain_snapshots(domain_id: &Uid) -> BTreeSet<String> {
1065        destroyed_domains()
1066            .lock()
1067            .unwrap()
1068            .get(domain_id)
1069            .cloned()
1070            .unwrap_or_default()
1071    }
1072
1073    fn members(count: usize) -> Vec<ActorAddr> {
1074        (0..count)
1075            .map(|i| {
1076                ActorAddr::root(
1077                    format!("proc{i}@inproc://{i}").parse::<ProcAddr>().unwrap(),
1078                    Label::strip("member"),
1079                )
1080            })
1081            .collect()
1082    }
1083
1084    #[test]
1085    fn test_subset_members_preserves_selected_member_order() {
1086        // parent local ranks / members:
1087        //
1088        // row=0: 0  1      members[0] members[1]
1089        // row=1: 2  3      members[2] members[3]
1090        // row=2: 4  5      members[4] members[5]
1091        // row=3: 6  7      members[6] members[7]
1092        let parent_view = Region::from(shape!(row = 4, col = 2));
1093
1094        // selected region = row 1..3
1095        let child_view = parent_view
1096            .range("row", ndslice::Range(1, Some(3), 1))
1097            .unwrap();
1098        let members = members(8);
1099        let parent_members = ValueMesh::new(parent_view.clone(), members.clone()).unwrap();
1100
1101        let child_members = parent_members.subset(child_view.clone()).unwrap();
1102        let child_members = (0..Ranked::region(&child_members).num_ranks())
1103            .map(|rank| Ranked::get(&child_members, rank).unwrap().clone())
1104            .collect::<Vec<_>>();
1105
1106        // child local ranks -> parent/base ranks:
1107        // row=0: 0->2  1->3
1108        // row=1: 2->4  3->5
1109        assert_eq!(child_members, &members[2..6]);
1110    }
1111
1112    // -- Integration test infrastructure --
1113
1114    /// A simple message type for testing cast delivery.
1115    #[derive(Debug, Clone, Serialize, Deserialize, typeuri::Named)]
1116    struct TestDelivery {
1117        payload: String,
1118    }
1119    wirevalue::register_type!(TestDelivery);
1120
1121    /// Delivery record kept by test receivers in handler execution order.
1122    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, typeuri::Named)]
1123    struct TestDeliveryRecord {
1124        payload: String,
1125        lineage: Vec<usize>,
1126        operation_endpoint: Option<String>,
1127    }
1128    wirevalue::register_type!(TestDeliveryRecord);
1129
1130    #[derive(
1131        Debug,
1132        Clone,
1133        Default,
1134        PartialEq,
1135        Eq,
1136        Serialize,
1137        Deserialize,
1138        typeuri::Named
1139    )]
1140    struct TestDeliveryHistories {
1141        by_proc: BTreeMap<String, Vec<TestDeliveryRecord>>,
1142    }
1143    wirevalue::register_type!(TestDeliveryHistories);
1144
1145    impl TestDeliveryHistories {
1146        fn single(proc_name: String, deliveries: Vec<TestDeliveryRecord>) -> Self {
1147            Self {
1148                by_proc: [(proc_name, deliveries)].into_iter().collect(),
1149            }
1150        }
1151
1152        fn merge(&mut self, other: Self) -> anyhow::Result<()> {
1153            for (proc_name, history) in other.by_proc {
1154                anyhow::ensure!(
1155                    self.by_proc.insert(proc_name.clone(), history).is_none(),
1156                    "duplicate history reply from {proc_name}",
1157                );
1158            }
1159            Ok(())
1160        }
1161    }
1162
1163    #[derive(Debug, Serialize, Deserialize, typeuri::Named)]
1164    struct GetHistory {
1165        reply_to: hyperactor::OncePortRef<TestDeliveryHistories>,
1166    }
1167    wirevalue::register_type!(GetHistory);
1168
1169    /// An actor that records delivered messages in local handler order.
1170    #[derive(Debug, Default)]
1171    #[hyperactor::export(
1172        handlers = [
1173            TestDelivery,
1174            GetHistory,
1175        ],
1176    )]
1177    struct TestReceiver {
1178        deliveries: Vec<TestDeliveryRecord>,
1179    }
1180
1181    #[async_trait]
1182    impl Actor for TestReceiver {
1183        async fn init(&mut self, _this: &Instance<Self>) -> Result<(), anyhow::Error> {
1184            Ok(())
1185        }
1186    }
1187
1188    #[async_trait]
1189    impl Handler<TestDelivery> for TestReceiver {
1190        async fn handle(
1191            &mut self,
1192            cx: &Context<Self>,
1193            msg: TestDelivery,
1194        ) -> Result<(), anyhow::Error> {
1195            let _seq_info = cx
1196                .headers()
1197                .get(SEQ_INFO)
1198                .expect("cast delivery should stamp SEQ_INFO");
1199            let lineage = cx.headers().get(CAST_LINEAGE).unwrap_or_default();
1200            let operation_endpoint = cx
1201                .headers()
1202                .get(hyperactor::mailbox::headers::OPERATION_ENDPOINT);
1203            self.deliveries.push(TestDeliveryRecord {
1204                payload: msg.payload,
1205                lineage,
1206                operation_endpoint,
1207            });
1208            Ok(())
1209        }
1210    }
1211
1212    #[async_trait]
1213    impl Handler<GetHistory> for TestReceiver {
1214        async fn handle(
1215            &mut self,
1216            cx: &Context<Self>,
1217            msg: GetHistory,
1218        ) -> Result<(), anyhow::Error> {
1219            msg.reply_to.post(
1220                cx,
1221                TestDeliveryHistories::single(
1222                    cx.self_addr().proc_addr().log_name().to_string(),
1223                    self.deliveries.clone(),
1224                ),
1225            );
1226            Ok(())
1227        }
1228    }
1229
1230    #[derive(typeuri::Named)]
1231    struct TestDeliveryHistoriesReducer;
1232
1233    impl hyperactor::accum::CommReducer for TestDeliveryHistoriesReducer {
1234        type Update = TestDeliveryHistories;
1235
1236        fn reduce(
1237            &self,
1238            mut left: Self::Update,
1239            right: Self::Update,
1240        ) -> anyhow::Result<Self::Update> {
1241            left.merge(right)?;
1242            Ok(left)
1243        }
1244    }
1245
1246    inventory::submit! {
1247        hyperactor::accum::ReducerFactory {
1248            typehash_f: <TestDeliveryHistoriesReducer as Named>::typehash,
1249            builder_f: |_| Ok(Box::new(TestDeliveryHistoriesReducer)),
1250        }
1251    }
1252
1253    struct TestDeliveryHistoriesAccumulator;
1254
1255    impl hyperactor::accum::Accumulator for TestDeliveryHistoriesAccumulator {
1256        type State = TestDeliveryHistories;
1257        type Update = TestDeliveryHistories;
1258
1259        fn accumulate(&self, state: &mut Self::State, update: Self::Update) -> anyhow::Result<()> {
1260            state.merge(update)
1261        }
1262
1263        fn reducer_spec(&self) -> Option<hyperactor::accum::ReducerSpec> {
1264            Some(hyperactor::accum::ReducerSpec {
1265                typehash: <TestDeliveryHistoriesReducer as Named>::typehash(),
1266                builder_params: None,
1267            })
1268        }
1269    }
1270
1271    struct CastTestMesh {
1272        _client_proc: Proc,
1273        client: Client,
1274        _procs: Vec<Proc>,
1275        member_ids: HashMap<usize, ActorAddr>,
1276        receiver_ids: Vec<ActorAddr>,
1277    }
1278
1279    impl CastTestMesh {
1280        fn new(n: usize) -> Self {
1281            let client_proc =
1282                Proc::direct(ChannelTransport::Unix.any(), "client_proc".into()).unwrap();
1283            let client = client_proc.client("client");
1284
1285            let procs: Vec<Proc> = (0..n)
1286                .map(|i| {
1287                    let proc =
1288                        Proc::direct(ChannelTransport::Unix.any(), format!("proc_{i}")).unwrap();
1289                    let cast_handle = proc
1290                        .spawn_with_uid(
1291                            Uid::singleton(Label::strip(CAST_ACTOR_NAME)),
1292                            CastActor::default(),
1293                        )
1294                        .unwrap();
1295                    let _: ActorRef<CastActor> = cast_handle.bind::<CastActor>();
1296                    proc
1297                })
1298                .collect();
1299            let member_ids = procs
1300                .iter()
1301                .enumerate()
1302                .map(|(rank, proc)| {
1303                    (
1304                        rank,
1305                        ActorAddr::root(proc.proc_addr().clone(), Label::strip("member")),
1306                    )
1307                })
1308                .collect();
1309
1310            Self {
1311                _client_proc: client_proc,
1312                client,
1313                _procs: procs,
1314                member_ids,
1315                receiver_ids: Vec::new(),
1316            }
1317        }
1318
1319        fn spawn_delivery_receivers(&mut self) {
1320            self.receiver_ids = self
1321                ._procs
1322                .iter()
1323                .map(|proc| {
1324                    let recv_handle = proc
1325                        .spawn_with_uid(
1326                            Uid::singleton(Label::strip("receiver")),
1327                            TestReceiver::default(),
1328                        )
1329                        .unwrap();
1330                    let _: ActorRef<TestReceiver> = recv_handle.bind::<TestReceiver>();
1331                    ActorAddr::root(proc.proc_addr().clone(), Label::strip("receiver"))
1332                })
1333                .collect();
1334        }
1335
1336        fn spawn_split_port_receivers(&mut self) {
1337            self.receiver_ids = self
1338                ._procs
1339                .iter()
1340                .map(|proc| {
1341                    let recv_handle = proc
1342                        .spawn_with_uid(Uid::singleton(Label::strip("receiver")), SplitPortReceiver)
1343                        .unwrap();
1344                    let _: ActorRef<SplitPortReceiver> = recv_handle.bind::<SplitPortReceiver>();
1345                    ActorAddr::root(proc.proc_addr().clone(), Label::strip("receiver"))
1346                })
1347                .collect();
1348        }
1349
1350        fn domain_members(&self) -> HashMap<usize, ActorAddr> {
1351            if self.receiver_ids.is_empty() {
1352                self.member_ids.clone()
1353            } else {
1354                self.receiver_ids
1355                    .iter()
1356                    .cloned()
1357                    .enumerate()
1358                    .collect::<HashMap<_, _>>()
1359            }
1360        }
1361
1362        fn root_domain(&self, region: Region) -> CastDomainRef {
1363            CastDomainId::new()
1364                .materialize(
1365                    &self.client,
1366                    self.domain_members(),
1367                    region,
1368                    TilingPolicy::BlockPartitioning,
1369                    Flattrs::new(),
1370                )
1371                .unwrap()
1372        }
1373
1374        fn proc_names(&self) -> Vec<String> {
1375            (0..self.domain_members().len())
1376                .map(|i| format!("proc_{i}"))
1377                .collect()
1378        }
1379
1380        fn root_domain_with_policy(&self, region: Region, policy: TilingPolicy) -> CastDomainRef {
1381            CastDomainId::new()
1382                .materialize(
1383                    &self.client,
1384                    self.member_ids.clone(),
1385                    region,
1386                    policy,
1387                    Flattrs::new(),
1388                )
1389                .unwrap()
1390        }
1391
1392        async fn wait_for_domain_snapshots(
1393            &self,
1394            domain_id: &Uid,
1395            expected_count: usize,
1396        ) -> BTreeMap<String, CastHopSnapshot> {
1397            tokio::time::timeout(Duration::from_secs(5), async {
1398                loop {
1399                    let snapshots = captured_domain_snapshots(domain_id);
1400                    if snapshots.len() == expected_count {
1401                        return snapshots;
1402                    }
1403                    tokio::time::sleep(Duration::from_millis(10)).await;
1404                }
1405            })
1406            .await
1407            .unwrap_or_else(|_| {
1408                panic!(
1409                    "timed out waiting for {expected_count} installed domain snapshots; saw {:?}",
1410                    captured_domain_snapshots(domain_id).keys()
1411                )
1412            })
1413        }
1414
1415        async fn wait_for_destroyed_domain_snapshots(
1416            &self,
1417            domain_id: &Uid,
1418            expected_count: usize,
1419        ) -> BTreeSet<String> {
1420            tokio::time::timeout(Duration::from_secs(5), async {
1421                loop {
1422                    let snapshots = destroyed_domain_snapshots(domain_id);
1423                    if snapshots.len() == expected_count {
1424                        return snapshots;
1425                    }
1426                    tokio::time::sleep(Duration::from_millis(10)).await;
1427                }
1428            })
1429            .await
1430            .unwrap_or_else(|_| {
1431                panic!(
1432                    "timed out waiting for {expected_count} destroyed domain snapshots; saw {:?}",
1433                    destroyed_domain_snapshots(domain_id)
1434                )
1435            })
1436        }
1437    }
1438
1439    proptest! {
1440        #[test]
1441        fn prop_domain_tree_materialization_covers_each_rank_once(sizes in small_shape_sizes()) {
1442            let shape = shape_from_sizes(&sizes);
1443            let region = Region::from(shape);
1444            let members = region
1445                .slice()
1446                .iter()
1447                .map(|rank| (rank, member(rank)))
1448                .collect::<HashMap<_, _>>();
1449            let root_tile = MaterializedTile::from_value_mesh_with_tile(
1450                Tile::from_view(&region),
1451                Arc::new(ValueMesh::build_indexed(region.clone(), members.clone()).unwrap()),
1452            );
1453
1454            let mut seen_roots = BTreeSet::new();
1455            validate_domain_tree(&members, &root_tile, &mut seen_roots)?;
1456
1457            let expected_roots = region.slice().iter().collect::<BTreeSet<_>>();
1458            prop_assert_eq!(seen_roots, expected_roots);
1459        }
1460
1461        #[test]
1462        fn prop_domain_destinations_preserve_member_mapping(sizes in small_shape_sizes()) {
1463            let shape = shape_from_sizes(&sizes);
1464            let region = Region::from(shape);
1465            let members = region
1466                .slice()
1467                .iter()
1468                .map(|rank| (rank, member(rank)))
1469                .collect::<HashMap<_, _>>();
1470            let root = MaterializedTile::from_value_mesh_with_tile(
1471                Tile::from_view(&region),
1472                Arc::new(ValueMesh::build_indexed(region.clone(), members.clone()).unwrap()),
1473            );
1474            let mut seen_roots = BTreeSet::new();
1475
1476            validate_domain_tree(&members, &root, &mut seen_roots)?;
1477
1478            prop_assert_eq!(
1479                seen_roots.into_iter().collect::<Vec<_>>(),
1480                region.slice().iter().collect::<Vec<_>>(),
1481            );
1482        }
1483    }
1484
1485    async fn cast_and_collect_histories(
1486        test_mesh: &CastTestMesh,
1487        cast_domain: &CastDomainRef,
1488    ) -> BTreeMap<String, Vec<TestDeliveryRecord>> {
1489        let (reply_handle, reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
1490            .open_reduce_port(TestDeliveryHistoriesAccumulator);
1491        let reply_ref = reply_handle.bind();
1492
1493        cast_domain
1494            .cast(
1495                &test_mesh.client,
1496                Flattrs::new(),
1497                GetHistory {
1498                    reply_to: reply_ref,
1499                },
1500            )
1501            .unwrap();
1502
1503        match tokio::time::timeout(Duration::from_secs(5), reply_rx.recv()).await {
1504            Ok(Ok(histories)) => histories.by_proc,
1505            Ok(Err(e)) => panic!("history recv error: {e}"),
1506            Err(_) => panic!("timed out waiting for reduced histories"),
1507        }
1508    }
1509
1510    #[async_timed_test(timeout_secs = 30)]
1511    async fn test_create_cast_domain_installs_expected_hops() {
1512        clear_captured_domains();
1513
1514        let test_mesh = CastTestMesh::new(8);
1515        let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
1516        let snapshots = test_mesh
1517            .wait_for_domain_snapshots(root_domain.domain_id(), 8)
1518            .await;
1519
1520        let region = Region::from(shape!(a = 2, b = 2, c = 2));
1521        let expected_next_hops: BTreeMap<String, BTreeSet<String>> = [
1522            ("proc_0", vec!["proc_1", "proc_2", "proc_4"]),
1523            ("proc_1", vec![]),
1524            ("proc_2", vec!["proc_3"]),
1525            ("proc_3", vec![]),
1526            ("proc_4", vec!["proc_5", "proc_6"]),
1527            ("proc_5", vec![]),
1528            ("proc_6", vec!["proc_7"]),
1529            ("proc_7", vec![]),
1530        ]
1531        .into_iter()
1532        .map(|(proc_name, next_hops)| {
1533            (
1534                proc_name.to_string(),
1535                next_hops
1536                    .into_iter()
1537                    .map(str::to_string)
1538                    .collect::<BTreeSet<_>>(),
1539            )
1540        })
1541        .collect();
1542
1543        assert_eq!(
1544            snapshots.keys().cloned().collect::<BTreeSet<_>>(),
1545            (0..8).map(|rank| format!("proc_{rank}")).collect()
1546        );
1547
1548        for rank in 0..8 {
1549            let proc_name = format!("proc_{rank}");
1550            let snapshot = snapshots
1551                .get(&proc_name)
1552                .unwrap_or_else(|| panic!("missing snapshot for {proc_name}"));
1553
1554            assert_eq!(snapshot.base_rank_in_domain, rank);
1555            assert_eq!(
1556                snapshot.point_in_domain,
1557                region.point_of_base_rank(rank).unwrap()
1558            );
1559            assert_eq!(snapshot.local_actor_proc, proc_name);
1560            assert_eq!(snapshot.next_hop_procs, expected_next_hops[&proc_name]);
1561        }
1562    }
1563
1564    fn expected_reply_counts(proc_names: &[&str]) -> BTreeMap<String, u64> {
1565        proc_names
1566            .iter()
1567            .map(|proc_name| (proc_name.to_string(), 1))
1568            .collect()
1569    }
1570
1571    async fn cast_and_collect_reply_counts(
1572        test_mesh: &CastTestMesh,
1573        cast_domain: &CastDomainRef,
1574        payload: &str,
1575    ) -> BTreeMap<String, u64> {
1576        let (reply_handle, reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
1577            .open_reduce_port(TestReplyCountsAccumulator);
1578        let reply_ref = reply_handle.bind();
1579
1580        cast_domain
1581            .cast(
1582                &test_mesh.client,
1583                Flattrs::new(),
1584                TestRequestWithReply {
1585                    payload: payload.to_string(),
1586                    reply_to: reply_ref,
1587                },
1588            )
1589            .unwrap();
1590
1591        match tokio::time::timeout(Duration::from_secs(5), reply_rx.recv()).await {
1592            Ok(Ok(reply_counts)) => reply_counts.counts_by_proc,
1593            Ok(Err(e)) => panic!("reply recv error: {e}"),
1594            Err(_) => panic!("timed out waiting for reduced replies"),
1595        }
1596    }
1597
1598    #[async_timed_test(timeout_secs = 30)]
1599    async fn test_cast_message_delivery_8_procs() {
1600        let config = hyperactor_config::global::lock();
1601        let _guard = config.override_key(
1602            hyperactor::config::ENABLE_DEST_ACTOR_REORDERING_BUFFER,
1603            true,
1604        );
1605
1606        let n = 8;
1607        let mut test_mesh = CastTestMesh::new(n);
1608        test_mesh.spawn_delivery_receivers();
1609        let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
1610
1611        let expected_payloads = vec![
1612            "hello-0".to_string(),
1613            "hello-1".to_string(),
1614            "hello-2".to_string(),
1615        ];
1616        for payload in &expected_payloads {
1617            root_domain
1618                .cast(
1619                    &test_mesh.client,
1620                    Flattrs::new(),
1621                    TestDelivery {
1622                        payload: payload.clone(),
1623                    },
1624                )
1625                .unwrap();
1626        }
1627
1628        let expected_histories: BTreeMap<String, Vec<String>> = test_mesh
1629            .proc_names()
1630            .into_iter()
1631            .map(|proc_name| (proc_name, expected_payloads.clone()))
1632            .collect();
1633        let histories = cast_and_collect_histories(&test_mesh, &root_domain).await;
1634
1635        let observed_payloads: BTreeMap<String, Vec<String>> = histories
1636            .iter()
1637            .map(|(proc_name, history)| {
1638                (
1639                    proc_name.clone(),
1640                    history
1641                        .iter()
1642                        .map(|delivery| delivery.payload.clone())
1643                        .collect(),
1644                )
1645            })
1646            .collect();
1647        assert_eq!(observed_payloads, expected_histories);
1648
1649        let mut lineage_by_proc = BTreeMap::new();
1650        for (proc_name, history) in histories {
1651            assert_eq!(
1652                history.len(),
1653                expected_payloads.len(),
1654                "proc {proc_name} received the wrong number of deliveries"
1655            );
1656            let first_lineage = history[0].lineage.clone();
1657            for delivery in &history {
1658                assert_eq!(
1659                    delivery.lineage, first_lineage,
1660                    "proc {proc_name} changed lineage across root casts"
1661                );
1662            }
1663            lineage_by_proc.insert(proc_name, first_lineage);
1664        }
1665
1666        let expected_lineage: BTreeMap<String, Vec<usize>> = [
1667            ("proc_0".to_string(), vec![0]),
1668            ("proc_1".to_string(), vec![0, 1]),
1669            ("proc_2".to_string(), vec![0, 2]),
1670            ("proc_3".to_string(), vec![0, 2, 3]),
1671            ("proc_4".to_string(), vec![0, 4]),
1672            ("proc_5".to_string(), vec![0, 4, 5]),
1673            ("proc_6".to_string(), vec![0, 4, 6]),
1674            ("proc_7".to_string(), vec![0, 4, 6, 7]),
1675        ]
1676        .into_iter()
1677        .collect();
1678        assert_eq!(lineage_by_proc, expected_lineage);
1679    }
1680
1681    #[async_timed_test(timeout_secs = 30)]
1682    async fn test_cast_preserves_supplied_operation_context_headers() {
1683        let n = 2;
1684        let mut test_mesh = CastTestMesh::new(n);
1685        test_mesh.spawn_delivery_receivers();
1686        let root_domain = test_mesh.root_domain(shape!(rank = 2).into());
1687
1688        let mut headers = Flattrs::new();
1689        headers.set(
1690            hyperactor::mailbox::headers::OPERATION_ENDPOINT,
1691            "endpoint.call()".to_string(),
1692        );
1693        root_domain
1694            .cast(
1695                &test_mesh.client,
1696                headers,
1697                TestDelivery {
1698                    payload: "with-operation-context".to_string(),
1699                },
1700            )
1701            .unwrap();
1702
1703        let histories = cast_and_collect_histories(&test_mesh, &root_domain).await;
1704        for history in histories.values() {
1705            assert_eq!(
1706                history[0].operation_endpoint.as_deref(),
1707                Some("endpoint.call()")
1708            );
1709        }
1710    }
1711
1712    #[async_timed_test(timeout_secs = 30)]
1713    async fn test_destroy_domain_rejects_later_casts() {
1714        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client_proc".into()).unwrap();
1715        let client = client_proc.client("client");
1716        let cast_proc = Proc::direct(ChannelTransport::Unix.any(), "cast_proc".into()).unwrap();
1717        let actor_instance = cast_proc.actor_instance::<CastActor>("cast").unwrap();
1718        let mut cast_actor = CastActor::default();
1719        let cx = Context::new(&actor_instance.instance, Flattrs::new());
1720
1721        let receiver_id = ActorAddr::root(cast_proc.proc_addr().clone(), Label::strip("receiver"));
1722        let cast_domain_id = CastDomainId::new();
1723        let region = Region::from(Shape::unity());
1724        let root_tile = MaterializedTile::from_value_mesh_with_tile(
1725            Tile::from_view(&region),
1726            Arc::new(ValueMesh::new(region.clone(), vec![receiver_id]).unwrap()),
1727        );
1728
1729        Handler::<CreateCastDomain>::handle(
1730            &mut cast_actor,
1731            &cx,
1732            CreateCastDomain {
1733                cast_domain_id: cast_domain_id.clone(),
1734                region,
1735                tiling_policy: TilingPolicy::BlockPartitioning,
1736                tile: root_tile,
1737            },
1738        )
1739        .await
1740        .unwrap();
1741
1742        Handler::<DestroyCastDomain>::handle(
1743            &mut cast_actor,
1744            &cx,
1745            DestroyCastDomain {
1746                domain_id: cast_domain_id.clone(),
1747                origin: client.self_addr().clone(),
1748            },
1749        )
1750        .await
1751        .unwrap();
1752
1753        // Destroy is idempotent: a repeated teardown is a benign no-op.
1754        Handler::<DestroyCastDomain>::handle(
1755            &mut cast_actor,
1756            &cx,
1757            DestroyCastDomain {
1758                domain_id: cast_domain_id.clone(),
1759                origin: client.self_addr().clone(),
1760            },
1761        )
1762        .await
1763        .unwrap();
1764
1765        let err = Handler::<CastMessage>::handle(
1766            &mut cast_actor,
1767            &cx,
1768            CastMessage {
1769                cast_domain_id,
1770                sender: client.self_addr().clone(),
1771                session_id: client.sequencer().session_id(),
1772                seqs: ValueMesh::new(Region::from(Shape::unity()), vec![1]).unwrap(),
1773                lineage: Vec::new(),
1774                headers: Flattrs::new(),
1775                dest_port: TestDelivery::port(),
1776                data: wirevalue::Any::<wirevalue::encoding::Multipart>::serialize(&TestDelivery {
1777                    payload: "hello".to_string(),
1778                })
1779                .unwrap(),
1780            },
1781        )
1782        .await
1783        .unwrap_err();
1784
1785        let err = err.to_string();
1786        assert!(err.contains("unknown domain"), "unexpected error: {err}");
1787    }
1788
1789    #[async_timed_test(timeout_secs = 30)]
1790    async fn test_destroy_domain_propagates_to_all_hops() {
1791        clear_captured_domains();
1792
1793        let test_mesh = CastTestMesh::new(8);
1794        let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
1795        let domain_id = root_domain.domain_id().clone();
1796        test_mesh.wait_for_domain_snapshots(&domain_id, 8).await;
1797
1798        root_domain.destroy(&test_mesh.client);
1799
1800        let destroyed = test_mesh
1801            .wait_for_destroyed_domain_snapshots(&domain_id, 8)
1802            .await;
1803        assert_eq!(
1804            destroyed,
1805            (0..8).map(|rank| format!("proc_{rank}")).collect()
1806        );
1807    }
1808
1809    #[async_timed_test(timeout_secs = 30)]
1810    async fn test_slice_cast_reaches_exactly_selected_members() {
1811        let n = 8;
1812        let mut test_mesh = CastTestMesh::new(n);
1813        test_mesh.spawn_split_port_receivers();
1814        let root_cast_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
1815
1816        for (case_name, range, expected_procs) in [
1817            (
1818                "a_0_to_1",
1819                ndslice::Range(0, Some(1), 1),
1820                ["proc_0", "proc_1", "proc_2", "proc_3"],
1821            ),
1822            (
1823                "a_1_to_2",
1824                ndslice::Range(1, Some(2), 1),
1825                ["proc_4", "proc_5", "proc_6", "proc_7"],
1826            ),
1827        ] {
1828            let payload = format!("slice-{case_name}");
1829            let slice_region = Region::from(shape!(a = 2, b = 2, c = 2))
1830                .range("a", range)
1831                .unwrap();
1832            let slice_cast_domain = root_cast_domain
1833                .materialize_slice(
1834                    &test_mesh.client,
1835                    slice_region,
1836                    TilingPolicy::BlockPartitioning,
1837                )
1838                .unwrap();
1839
1840            assert_eq!(
1841                cast_and_collect_reply_counts(&test_mesh, &slice_cast_domain, &payload,).await,
1842                expected_reply_counts(&expected_procs)
1843            );
1844        }
1845    }
1846
1847    #[async_timed_test(timeout_secs = 30)]
1848    async fn test_sender_sequenced_casts_are_observed_in_projection_order() {
1849        let config = hyperactor_config::global::lock();
1850        let _guard = config.override_key(
1851            hyperactor::config::ENABLE_DEST_ACTOR_REORDERING_BUFFER,
1852            true,
1853        );
1854
1855        let n = 4;
1856        let mut test_mesh = CastTestMesh::new(n);
1857        test_mesh.spawn_delivery_receivers();
1858        let root = test_mesh.root_domain(shape!(rank = 4).into());
1859
1860        let rank_0_to_2 = root
1861            .materialize_slice(
1862                &test_mesh.client,
1863                Region::from(shape!(rank = 4))
1864                    .range("rank", ndslice::Range(0, Some(2), 1))
1865                    .unwrap(),
1866                TilingPolicy::BlockPartitioning,
1867            )
1868            .unwrap();
1869
1870        let rank_1_to_3 = root
1871            .materialize_slice(
1872                &test_mesh.client,
1873                Region::from(shape!(rank = 4))
1874                    .range("rank", ndslice::Range(1, Some(3), 1))
1875                    .unwrap(),
1876                TilingPolicy::BlockPartitioning,
1877            )
1878            .unwrap();
1879
1880        let rank_2_to_4 = root
1881            .materialize_slice(
1882                &test_mesh.client,
1883                Region::from(shape!(rank = 4))
1884                    .range("rank", ndslice::Range(2, Some(4), 1))
1885                    .unwrap(),
1886                TilingPolicy::BlockPartitioning,
1887            )
1888            .unwrap();
1889
1890        let casts = [
1891            (
1892                &root,
1893                "root-0",
1894                vec!["proc_0", "proc_1", "proc_2", "proc_3"],
1895            ),
1896            (&rank_0_to_2, "rank_0_to_2-1", vec!["proc_0", "proc_1"]),
1897            (&rank_1_to_3, "rank_1_to_3-2", vec!["proc_1", "proc_2"]),
1898            (&rank_2_to_4, "rank_2_to_4-3", vec!["proc_2", "proc_3"]),
1899            (
1900                &root,
1901                "root-4",
1902                vec!["proc_0", "proc_1", "proc_2", "proc_3"],
1903            ),
1904        ];
1905
1906        let mut expected_histories: BTreeMap<String, Vec<String>> = test_mesh
1907            .proc_names()
1908            .into_iter()
1909            .map(|proc_name| (proc_name, Vec::new()))
1910            .collect();
1911
1912        for (domain, payload, expected_receivers) in &casts {
1913            domain
1914                .cast(
1915                    &test_mesh.client,
1916                    Flattrs::new(),
1917                    TestDelivery {
1918                        payload: payload.to_string(),
1919                    },
1920                )
1921                .unwrap();
1922
1923            for proc_name in expected_receivers {
1924                expected_histories
1925                    .get_mut(*proc_name)
1926                    .unwrap()
1927                    .push(payload.to_string());
1928            }
1929        }
1930
1931        let observed_histories: BTreeMap<String, Vec<String>> =
1932            cast_and_collect_histories(&test_mesh, &root)
1933                .await
1934                .into_iter()
1935                .map(|(proc_name, history)| {
1936                    (
1937                        proc_name,
1938                        history
1939                            .into_iter()
1940                            .map(|delivery| delivery.payload)
1941                            .collect(),
1942                    )
1943                })
1944                .collect();
1945
1946        assert_eq!(observed_histories, expected_histories);
1947    }
1948
1949    // -- Port splitting test infrastructure --
1950    //
1951    // Ported from `hyperactor_mesh::comm::tests`.  The `split_ports`
1952    // function records (original, split, deliver_here) edges into a
1953    // global vec under `#[cfg(test)]`.  After a cast we reconstruct
1954    // the split-port tree and verify it mirrors the cast tree.
1955
1956    #[derive(Debug, Clone)]
1957    struct SplitEdge {
1958        from: PortAddr,
1959        to: PortAddr,
1960        is_leaf: bool,
1961    }
1962
1963    struct SplitPortRecording {
1964        root: PortAddr,
1965        edges: Vec<SplitEdge>,
1966    }
1967
1968    struct SplitPortRecordingGuard;
1969
1970    static SPLIT_PORT_TREE: OnceLock<Mutex<Option<SplitPortRecording>>> = OnceLock::new();
1971
1972    fn split_port_tree() -> &'static Mutex<Option<SplitPortRecording>> {
1973        SPLIT_PORT_TREE.get_or_init(|| Mutex::new(None))
1974    }
1975
1976    pub(crate) fn collect_split_port(original: &PortAddr, split: &PortAddr, deliver_here: bool) {
1977        let mut guard = split_port_tree().lock().unwrap();
1978        let Some(recording) = guard.as_mut() else {
1979            return;
1980        };
1981        if original != &recording.root && !recording.edges.iter().any(|edge| &edge.to == original) {
1982            return;
1983        }
1984        recording.edges.push(SplitEdge {
1985            from: original.clone(),
1986            to: split.clone(),
1987            is_leaf: deliver_here,
1988        });
1989    }
1990
1991    fn record_split_port_tree(root: PortAddr) -> SplitPortRecordingGuard {
1992        *split_port_tree().lock().unwrap() = Some(SplitPortRecording {
1993            root,
1994            edges: Vec::new(),
1995        });
1996        SplitPortRecordingGuard
1997    }
1998
1999    impl SplitPortRecordingGuard {
2000        fn edges(&self) -> Vec<SplitEdge> {
2001            split_port_tree()
2002                .lock()
2003                .unwrap()
2004                .as_ref()
2005                .map(|recording| recording.edges.clone())
2006                .unwrap_or_default()
2007        }
2008    }
2009
2010    impl Drop for SplitPortRecordingGuard {
2011        fn drop(&mut self) {
2012            *split_port_tree().lock().unwrap() = None;
2013        }
2014    }
2015
2016    /// Reconstruct split-port paths from leaf to root.
2017    /// Returns a map from leaf `PortAddr` to the root-first split path.
2018    fn build_split_paths(edges: &[SplitEdge]) -> BTreeMap<PortAddr, Vec<PortAddr>> {
2019        let mut child_to_parent: HashMap<PortAddr, PortAddr> = HashMap::new();
2020        let mut leaves = Vec::new();
2021
2022        for edge in edges {
2023            child_to_parent.insert(edge.to.clone(), edge.from.clone());
2024            if edge.is_leaf {
2025                leaves.push(edge.to.clone());
2026            }
2027        }
2028
2029        let mut result = BTreeMap::new();
2030        for leaf in leaves {
2031            let mut path = vec![leaf.clone()];
2032            let mut current = leaf.clone();
2033            while let Some(parent) = child_to_parent.get(&current) {
2034                path.push(parent.clone());
2035                current = parent.clone();
2036            }
2037            path.reverse();
2038            result.insert(leaf, path);
2039        }
2040        result
2041    }
2042
2043    /// Extract the proc name (rank) from each `PortAddr` in a split-port
2044    /// path, stripping the root (client) entry.
2045    fn split_path_ranks(
2046        paths: &BTreeMap<PortAddr, Vec<PortAddr>>,
2047        rank_lookup: &HashMap<String, usize>,
2048    ) -> BTreeMap<usize, Vec<usize>> {
2049        paths
2050            .iter()
2051            .map(|(leaf, path)| {
2052                // First entry is the client's port — skip it.
2053                let ranks: Vec<usize> = path[1..]
2054                    .iter()
2055                    .map(|pid| {
2056                        let proc_name = pid.actor_addr().proc_addr().log_name().to_string();
2057                        *rank_lookup
2058                            .get(&proc_name)
2059                            .unwrap_or_else(|| panic!("unknown proc {proc_name} in split path"))
2060                    })
2061                    .collect();
2062                let leaf_proc = leaf.actor_addr().proc_addr().log_name().to_string();
2063                let leaf_rank = rank_lookup[&leaf_proc];
2064                (leaf_rank, ranks)
2065            })
2066            .collect()
2067    }
2068
2069    /// A reply type for the port splitting test.
2070    #[derive(
2071        Debug,
2072        Clone,
2073        Default,
2074        PartialEq,
2075        Eq,
2076        Serialize,
2077        Deserialize,
2078        typeuri::Named
2079    )]
2080    struct TestReplyCounts {
2081        counts_by_proc: BTreeMap<String, u64>,
2082    }
2083    wirevalue::register_type!(TestReplyCounts);
2084
2085    impl TestReplyCounts {
2086        fn single(proc_name: String) -> Self {
2087            Self {
2088                counts_by_proc: [(proc_name, 1)].into_iter().collect(),
2089            }
2090        }
2091
2092        fn merge(&mut self, other: Self) {
2093            for (proc_name, count) in other.counts_by_proc {
2094                *self.counts_by_proc.entry(proc_name).or_default() += count;
2095            }
2096        }
2097    }
2098
2099    #[derive(typeuri::Named)]
2100    struct TestReplyCountsReducer;
2101
2102    impl hyperactor::accum::CommReducer for TestReplyCountsReducer {
2103        type Update = TestReplyCounts;
2104
2105        fn reduce(
2106            &self,
2107            mut left: Self::Update,
2108            right: Self::Update,
2109        ) -> anyhow::Result<Self::Update> {
2110            left.merge(right);
2111            Ok(left)
2112        }
2113    }
2114
2115    inventory::submit! {
2116        hyperactor::accum::ReducerFactory {
2117            typehash_f: <TestReplyCountsReducer as Named>::typehash,
2118            builder_f: |_| Ok(Box::new(TestReplyCountsReducer)),
2119        }
2120    }
2121
2122    struct TestReplyCountsAccumulator;
2123
2124    impl hyperactor::accum::Accumulator for TestReplyCountsAccumulator {
2125        type State = TestReplyCounts;
2126        type Update = TestReplyCounts;
2127
2128        fn accumulate(&self, state: &mut Self::State, update: Self::Update) -> anyhow::Result<()> {
2129            state.merge(update);
2130            Ok(())
2131        }
2132
2133        fn reducer_spec(&self) -> Option<hyperactor::accum::ReducerSpec> {
2134            Some(hyperactor::accum::ReducerSpec {
2135                typehash: <TestReplyCountsReducer as Named>::typehash(),
2136                builder_params: None,
2137            })
2138        }
2139    }
2140
2141    /// A message with a reply port for testing port splitting.
2142    #[derive(Debug, Clone, Serialize, Deserialize, typeuri::Named)]
2143    struct TestRequestWithReply {
2144        payload: String,
2145        reply_to: hyperactor::OncePortRef<TestReplyCounts>,
2146    }
2147    wirevalue::register_type!(TestRequestWithReply);
2148
2149    /// An actor that receives a cast message and sends a reply back
2150    /// through the (potentially split) reply port.
2151    #[derive(Debug, Default)]
2152    #[hyperactor::export(
2153        handlers = [TestRequestWithReply],
2154    )]
2155    struct SplitPortReceiver;
2156
2157    #[async_trait]
2158    impl Actor for SplitPortReceiver {
2159        async fn init(&mut self, _this: &Instance<Self>) -> Result<(), anyhow::Error> {
2160            Ok(())
2161        }
2162    }
2163
2164    #[async_trait]
2165    impl Handler<TestRequestWithReply> for SplitPortReceiver {
2166        async fn handle(
2167            &mut self,
2168            cx: &Context<Self>,
2169            msg: TestRequestWithReply,
2170        ) -> Result<(), anyhow::Error> {
2171            msg.reply_to.post(
2172                cx,
2173                TestReplyCounts::single(cx.self_addr().proc_addr().log_name().to_string()),
2174            );
2175            Ok(())
2176        }
2177    }
2178
2179    /// Verify that port splitting rewrites reply ports to mirror the
2180    /// cast tree, and that replies flow back through the split ports
2181    /// to the original sender.
2182    #[async_timed_test(timeout_secs = 30)]
2183    async fn test_port_splitting_replies_and_tree() {
2184        let n = 8;
2185        let mut test_mesh = CastTestMesh::new(n);
2186        test_mesh.spawn_split_port_receivers();
2187        let root_domain = test_mesh.root_domain(shape!(a = 2, b = 2, c = 2).into());
2188
2189        let (reply_handle, reply_rx) = context::Mailbox::mailbox(&test_mesh.client)
2190            .open_reduce_port(TestReplyCountsAccumulator);
2191        let reply_ref = reply_handle.bind();
2192        let split_port_recording = record_split_port_tree(reply_ref.port_addr().clone());
2193
2194        // Cast a message with the reply port.
2195        root_domain
2196            .cast(
2197                &test_mesh.client,
2198                Flattrs::new(),
2199                TestRequestWithReply {
2200                    payload: "split_test".to_string(),
2201                    reply_to: reply_ref,
2202                },
2203            )
2204            .unwrap();
2205
2206        let reply_counts = match tokio::time::timeout(Duration::from_secs(5), reply_rx.recv()).await
2207        {
2208            Ok(Ok(reply_counts)) => reply_counts.counts_by_proc,
2209            Ok(Err(e)) => panic!("reply recv error: {e}"),
2210            Err(_) => panic!("timed out waiting for reduced replies"),
2211        };
2212
2213        // Every proc should have sent exactly one reply. Since counts are
2214        // preserved, duplicate deliveries show up as counts greater than one.
2215        let expected_counts: BTreeMap<String, u64> =
2216            (0..n).map(|i| (format!("proc_{i}"), 1)).collect();
2217        assert_eq!(reply_counts, expected_counts);
2218
2219        // Verify the split-port tree mirrors the cast tree.
2220        let edges = split_port_recording.edges();
2221        let paths = build_split_paths(&edges);
2222
2223        let rank_lookup: HashMap<String, usize> =
2224            (0..n).map(|i| (format!("proc_{i}"), i)).collect();
2225        let rank_paths = split_path_ranks(&paths, &rank_lookup);
2226
2227        let expected: BTreeMap<usize, Vec<usize>> = [
2228            (0, vec![0]),
2229            (1, vec![0, 1]),
2230            (2, vec![0, 2]),
2231            (3, vec![0, 2, 3]),
2232            (4, vec![0, 4]),
2233            (5, vec![0, 4, 5]),
2234            (6, vec![0, 4, 6]),
2235            (7, vec![0, 4, 6, 7]),
2236        ]
2237        .into_iter()
2238        .collect();
2239
2240        assert_eq!(
2241            rank_paths, expected,
2242            "split-port tree doesn't mirror cast tree"
2243        );
2244    }
2245
2246    // Tests that a serialized BoundedFanout policy drives cast-domain setup
2247    // end to end. Each CastActor installs one CastHop; the expected_next_hops
2248    // map below is the adjacency-list representation of the send tree that
2249    // BoundedFanout should produce for an 8-rank 1D domain with fanout 2.
2250    #[async_timed_test(timeout_secs = 30)]
2251    async fn test_create_cast_domain_with_bounded_fanout_installs_expected_hops() {
2252        clear_captured_domains();
2253
2254        let test_mesh = CastTestMesh::new(8);
2255        let root_domain = test_mesh.root_domain_with_policy(
2256            shape!(a = 8).into(),
2257            TilingPolicy::BoundedFanout {
2258                fanout: NonZeroUsize::new(2).unwrap(),
2259            },
2260        );
2261        let snapshots = test_mesh
2262            .wait_for_domain_snapshots(root_domain.domain_id(), 8)
2263            .await;
2264
2265        let region = Region::from(shape!(a = 8));
2266        // Hand-derived from a 1D extent-8 root with fanout=2: away-from-root
2267        // indices [1..8) split into two left-heavy groups [1..5) and [5..8);
2268        // each group recurses with the same policy.
2269        let expected_next_hops: BTreeMap<String, BTreeSet<String>> = [
2270            ("proc_0", vec!["proc_1", "proc_5"]),
2271            ("proc_1", vec!["proc_2", "proc_4"]),
2272            ("proc_2", vec!["proc_3"]),
2273            ("proc_3", vec![]),
2274            ("proc_4", vec![]),
2275            ("proc_5", vec!["proc_6", "proc_7"]),
2276            ("proc_6", vec![]),
2277            ("proc_7", vec![]),
2278        ]
2279        .into_iter()
2280        .map(|(proc_name, next_hops)| {
2281            (
2282                proc_name.to_string(),
2283                next_hops
2284                    .into_iter()
2285                    .map(str::to_string)
2286                    .collect::<BTreeSet<_>>(),
2287            )
2288        })
2289        .collect();
2290
2291        assert_eq!(
2292            snapshots.keys().cloned().collect::<BTreeSet<_>>(),
2293            (0..8).map(|rank| format!("proc_{rank}")).collect()
2294        );
2295
2296        for rank in 0..8 {
2297            let proc_name = format!("proc_{rank}");
2298            let snapshot = snapshots
2299                .get(&proc_name)
2300                .unwrap_or_else(|| panic!("missing snapshot for {proc_name}"));
2301
2302            assert_eq!(snapshot.base_rank_in_domain, rank);
2303            assert_eq!(
2304                snapshot.point_in_domain,
2305                region.point_of_base_rank(rank).unwrap()
2306            );
2307            assert_eq!(snapshot.local_actor_proc, proc_name);
2308            assert_eq!(snapshot.next_hop_procs, expected_next_hops[&proc_name]);
2309        }
2310    }
2311
2312    // Tests that a serialized Bisection policy drives cast-domain setup end
2313    // to end. Each CastActor installs one CastHop; the expected_next_hops
2314    // map below is the adjacency-list representation of the send tree that
2315    // Bisection should produce for an 8-rank 1D domain.
2316    #[async_timed_test(timeout_secs = 30)]
2317    async fn test_create_cast_domain_with_bisection_installs_expected_hops() {
2318        clear_captured_domains();
2319
2320        let test_mesh = CastTestMesh::new(8);
2321        let root_domain =
2322            test_mesh.root_domain_with_policy(shape!(a = 8).into(), TilingPolicy::Bisection);
2323        let snapshots = test_mesh
2324            .wait_for_domain_snapshots(root_domain.domain_id(), 8)
2325            .await;
2326
2327        let region = Region::from(shape!(a = 8));
2328        // Hand-derived from recursive halving on a 1D extent-8 root: each
2329        // step emits an upper sibling and a lower anchor; anchor contraction
2330        // promotes sibling descendants from the lower-half spine.
2331        let expected_next_hops: BTreeMap<String, BTreeSet<String>> = [
2332            ("proc_0", vec!["proc_1", "proc_2", "proc_4"]),
2333            ("proc_1", vec![]),
2334            ("proc_2", vec!["proc_3"]),
2335            ("proc_3", vec![]),
2336            ("proc_4", vec!["proc_5", "proc_6"]),
2337            ("proc_5", vec![]),
2338            ("proc_6", vec!["proc_7"]),
2339            ("proc_7", vec![]),
2340        ]
2341        .into_iter()
2342        .map(|(proc_name, next_hops)| {
2343            (
2344                proc_name.to_string(),
2345                next_hops
2346                    .into_iter()
2347                    .map(str::to_string)
2348                    .collect::<BTreeSet<_>>(),
2349            )
2350        })
2351        .collect();
2352
2353        assert_eq!(
2354            snapshots.keys().cloned().collect::<BTreeSet<_>>(),
2355            (0..8).map(|rank| format!("proc_{rank}")).collect()
2356        );
2357
2358        for rank in 0..8 {
2359            let proc_name = format!("proc_{rank}");
2360            let snapshot = snapshots
2361                .get(&proc_name)
2362                .unwrap_or_else(|| panic!("missing snapshot for {proc_name}"));
2363
2364            assert_eq!(snapshot.base_rank_in_domain, rank);
2365            assert_eq!(
2366                snapshot.point_in_domain,
2367                region.point_of_base_rank(rank).unwrap()
2368            );
2369            assert_eq!(snapshot.local_actor_proc, proc_name);
2370            assert_eq!(snapshot.next_hop_procs, expected_next_hops[&proc_name]);
2371        }
2372    }
2373}