Skip to main content

switchyard_libsy/algorithms/util/
affinity.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Model affinity as a single SDK component.
5//!
6//! [`AffinityRouter`] retains the first model chosen for a request's stable identity and
7//! forces that model on later requests sharing the identity. It is one object that plays
8//! both SDK roles, so registering it as a processor and a classifier cannot drift apart:
9//!
10//! - As a [`Processor`] it *writes* the assignment: [`Event::Decision`] carries the request
11//!   whose chosen model is retained, with the first assignment for an identity winning.
12//! - As a [`Classifier`] it *reads* the assignment: it scores the retained model with
13//!   confidence `1.0`, or returns no scores to abstain.
14//!
15//! Identity is derived from correlation metadata: by default, a request is keyed by its
16//! session, and a sub-agent is keyed more finely by `session + agent` — a subset of its
17//! session. [`AffinityRouter::for_subagents`] narrows affinity to explicitly identified
18//! child agents, leaving root traffic to later classifiers on every turn.
19
20use std::collections::{HashMap, HashSet, hash_map::DefaultHasher};
21use std::hash::{Hash, Hasher};
22use std::sync::atomic::{AtomicBool, Ordering};
23
24use async_trait::async_trait;
25use parking_lot::Mutex;
26use switchyard_protocol::{ModelId, Request, Role};
27
28use crate::core::algorithm::{Driver, RoutingIdentity};
29use crate::core::classifier::{Classification, Classifier, Score};
30use crate::core::processor::{Event, Processor};
31
32/// Upper bound on retained assignments, keeping the process-local map from growing
33/// without limit; the oldest entry is evicted once the bound is reached.
34const MAX_ASSIGNMENTS: usize = 4096;
35
36/// Retains a model per request identity and forces it on later matching requests.
37///
38/// Register the same instance as both a processor and a classifier; the two roles share
39/// the retained assignments through this instance's interior storage. Decision events carry
40/// their originating request, so concurrent turns cannot bind one request's identity to
41/// another request's selected model.
42///
43/// [`with_latch_only`](Self::with_latch_only) narrows *which* models are retained — a
44/// decision for any other model routes normally but is not latched (the escalation latch:
45/// retain only the strong tier, never the weak one).
46#[derive(Default)]
47pub struct AffinityRouter {
48    /// When set, only these models are retained; a decision for any other model is not latched.
49    latch_only: Option<HashSet<ModelId>>,
50    /// Whether root-session requests should abstain instead of being retained.
51    subagents_only: bool,
52    /// In absence of headers, use the message hash based fallback key to do task based routing
53    message_hash_fallback: bool,
54    /// Retained assignments, shared across this router's processor and classifier roles.
55    ///
56    /// Held on the instance so the two roles share one process-local map through a
57    /// single registered [`Arc`](std::sync::Arc); bounded by [`MAX_ASSIGNMENTS`].
58    assignments: Mutex<HashMap<RoutingIdentity, ModelId>>,
59    /// Whether the "no identity to key on" warning has already been emitted.
60    unkeyed_warning_emitted: AtomicBool,
61}
62
63impl AffinityRouter {
64    /// Creates a router that latches every decision.
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Creates a router that retains assignments only for explicitly identified child agents.
70    ///
71    /// Root-agent requests always abstain, so a later classifier selects them on every turn.
72    pub fn for_subagents() -> Self {
73        Self {
74            subagents_only: true,
75            ..Self::default()
76        }
77    }
78
79    /// Uses the first user-message text as a fallback key when metadata has no session.
80    pub fn with_message_hash_fallback(mut self) -> Self {
81        self.message_hash_fallback = true;
82        self
83    }
84
85    /// Restricts latching to `models`; a decision for any other model routes but is not
86    /// retained.
87    pub fn with_latch_only(mut self, models: impl IntoIterator<Item = impl Into<ModelId>>) -> Self {
88        self.latch_only = Some(models.into_iter().map(Into::into).collect());
89        self
90    }
91
92    /// Whether a decision for `model` should be retained.
93    fn should_latch(&self, model: &str) -> bool {
94        self.latch_only
95            .as_ref()
96            .is_none_or(|set| set.contains(model))
97    }
98
99    /// Derives the stable identity this router should retain for `request`.
100    fn affinity_key(&self, request: &Request) -> Option<RoutingIdentity> {
101        let metadata = request.metadata.as_ref();
102        let is_subagent = metadata.is_some_and(|metadata| metadata.is_subagent);
103        // This mode handles only subagent requests; root requests intentionally fall through.
104        if self.subagents_only && !is_subagent {
105            return None;
106        }
107
108        let key = match (RoutingIdentity::from_request(request), is_subagent) {
109            (Some(identity), _) => Some(identity),
110            // Child requests require their explicit session + agent identity and never fall
111            // back to task text.
112            (None, true) => None,
113            (None, false) if self.message_hash_fallback => {
114                first_user_message_hash(request).map(|hash| {
115                    tracing::debug!(affinity_key = %hash, "affinity using message hash fallback");
116                    RoutingIdentity::Session(hash)
117                })
118            }
119            (None, false) => None,
120        };
121        // Affinity that never keys anything is silent otherwise: the route reports itself as
122        // configured while every turn is classified afresh. Say so once rather than per turn.
123        if key.is_none() && self.should_warn_unkeyed() {
124            tracing::warn!(
125                target: "libsy",
126                is_subagent,
127                message_hash_fallback = self.message_hash_fallback,
128                "affinity is enabled but this request carries no usable identity, so no \
129                 affinity is applied; root requests need a session id or message-hash \
130                 fallback with usable first-user text, and child requests need both session \
131                 and agent ids"
132            );
133        }
134        key
135    }
136
137    /// Reports whether this call owns the one-time warning for an unkeyable request.
138    fn should_warn_unkeyed(&self) -> bool {
139        !self.unkeyed_warning_emitted.swap(true, Ordering::Relaxed)
140    }
141}
142
143#[async_trait]
144impl<S> Processor<S> for AffinityRouter
145where
146    S: Send + 'static,
147{
148    async fn process(&self, _state: &mut S, event: Event<'_>) -> crate::Result<()> {
149        if let Event::Decision { request, decision } = event
150            && let Some(key) = self.affinity_key(request)
151        {
152            let model = decision.selected_model_id();
153            let mut assignments = self.assignments.lock();
154            if self.should_latch(model) && !assignments.contains_key(&key) {
155                evict_if_full(&mut assignments);
156                assignments.insert(key, model.clone());
157            }
158        }
159        Ok(())
160    }
161}
162
163/// Hashes the first user message so later turns retain the initial task's affinity.
164/// For benchmarking purpose with harnesses, task instructions are added as a user prompt to the request so we hash the initial user message.
165/// TODO: Have not considered multi-modal payloads yet. That needs to be handled separately.
166fn first_user_message_hash(request: &Request) -> Option<String> {
167    let message = request
168        .llm_request
169        .messages
170        .iter()
171        .find(|message| message.role == Role::User)?;
172    let mut hasher = DefaultHasher::new();
173    message.text_content("")?.hash(&mut hasher);
174    Some(format!("{:016x}", hasher.finish()))
175}
176
177#[async_trait]
178impl<S> Classifier<S> for AffinityRouter
179where
180    S: Send + 'static,
181{
182    async fn score(
183        &self,
184        _state: &mut S,
185        request: &mut Request,
186        _driver: Option<&Driver>,
187    ) -> crate::Result<(Classification, Option<switchyard_protocol::Response>)> {
188        let Some(key) = self.affinity_key(request) else {
189            return Ok((Classification::Scores(Vec::new()), None));
190        };
191        let assigned = self.assignments.lock().get(&key).cloned();
192        Ok((
193            Classification::Scores(match assigned {
194                Some(target) => vec![Score {
195                    confidence: 1.0,
196                    target,
197                }],
198                None => Vec::new(),
199            }),
200            None,
201        ))
202    }
203}
204
205/// Evicts one arbitrary assignment when the map has reached [`MAX_ASSIGNMENTS`].
206fn evict_if_full(assignments: &mut HashMap<RoutingIdentity, ModelId>) {
207    if assignments.len() >= MAX_ASSIGNMENTS
208        && let Some(evicted) = assignments.keys().next().cloned()
209    {
210        assignments.remove(&evicted);
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    use std::sync::Arc;
219
220    use switchyard_protocol::{
221        ContentBlock, Decision, LlmRequest, Message, Metadata, text_request,
222    };
223
224    /// Boxed, thread-safe error type keeping the test helpers ergonomic.
225    type BoxErr = Box<dyn std::error::Error + Send + Sync>;
226
227    fn fixed_decision(target: &str) -> Decision {
228        Decision::new(target, true)
229    }
230
231    fn request(metadata: Metadata) -> Request {
232        Request {
233            llm_request: text_request(Some("auto".to_string()), "hi"),
234            raw_request: None,
235            metadata: Some(metadata),
236        }
237    }
238
239    fn task_request(
240        metadata: Option<Metadata>,
241        first_user: &str,
242        follow_up: Option<&str>,
243    ) -> Request {
244        let mut messages = vec![
245            Message::text(Role::System, "follow repository instructions"),
246            Message::text(Role::User, first_user),
247        ];
248        if let Some(follow_up) = follow_up {
249            messages.push(Message::text(Role::Assistant, "I will inspect the code."));
250            messages.push(Message::text(Role::User, follow_up));
251        }
252        Request {
253            llm_request: LlmRequest {
254                model: Some("auto".to_string()),
255                messages,
256                ..LlmRequest::default()
257            },
258            raw_request: None,
259            metadata,
260        }
261    }
262
263    fn session(session_id: &str, agent_id: &str) -> Metadata {
264        Metadata {
265            session_id: Some(session_id.to_string()),
266            agent_id: Some(agent_id.to_string()),
267            ..Metadata::default()
268        }
269    }
270
271    fn subagent(agent_id: &str, task_id: &str) -> Metadata {
272        Metadata {
273            session_id: Some("session-1".to_string()),
274            agent_id: Some(agent_id.to_string()),
275            task_id: Some(task_id.to_string()),
276            is_subagent: true,
277            ..Metadata::default()
278        }
279    }
280
281    /// Folds a request and its decision through the router, retaining `model`.
282    async fn retain(
283        router: &AffinityRouter,
284        state: &mut (),
285        request: &mut Request,
286        model: &'static str,
287    ) -> Result<(), BoxErr> {
288        let decision = fixed_decision(model);
289        router
290            .process(
291                state,
292                Event::Decision {
293                    request,
294                    decision: &decision,
295                },
296            )
297            .await?;
298        Ok(())
299    }
300
301    /// Scores through the definitive classification variant used by affinity.
302    async fn scores(
303        classifier: &dyn Classifier,
304        state: &mut (),
305        request: &mut Request,
306    ) -> Result<Vec<Score>, BoxErr> {
307        match classifier.score(state, request, None).await?.0 {
308            Classification::Scores(scores) => Ok(scores),
309            Classification::Ambiguous(_) => Err("affinity never returns ambiguous scores".into()),
310        }
311    }
312
313    #[tokio::test]
314    async fn session_retains_first_model_across_requests() -> Result<(), BoxErr> {
315        let router = AffinityRouter::new();
316        let mut state = ();
317
318        let mut first = request(session("session-1", "agent-a"));
319        retain(&router, &mut state, &mut first, "model-a").await?;
320
321        // A different agent in the same session is scored onto the retained model.
322        let mut second = request(session("session-1", "agent-b"));
323        let scores = scores(&router, &mut state, &mut second).await?;
324        assert_eq!(scores.len(), 1);
325        assert_eq!(scores[0].confidence, 1.0);
326        assert_eq!(scores[0].target, "model-a");
327        Ok(())
328    }
329
330    #[tokio::test]
331    async fn subagent_only_retains_children_without_latching_root_traffic() -> Result<(), BoxErr> {
332        let router = AffinityRouter::for_subagents();
333        let mut state = ();
334
335        let mut root = request(session("session-1", "root-agent"));
336        retain(&router, &mut state, &mut root, "model-a").await?;
337        assert!(scores(&router, &mut state, &mut root).await?.is_empty());
338
339        let mut first_child_turn = request(subagent("child-1", "task-1"));
340        retain(&router, &mut state, &mut first_child_turn, "model-b").await?;
341        let mut later_child_turn = request(subagent("child-1", "task-2"));
342        let scores = scores(&router, &mut state, &mut later_child_turn).await?;
343        assert_eq!(
344            scores.first().map(|score| score.target.as_str()),
345            Some("model-b")
346        );
347        Ok(())
348    }
349
350    #[tokio::test]
351    async fn first_decision_wins() -> Result<(), BoxErr> {
352        let router = AffinityRouter::new();
353        let mut state = ();
354
355        let mut req = request(session("session-1", "agent-a"));
356        retain(&router, &mut state, &mut req, "model-a").await?;
357        // A later decision for the same identity must not overwrite the first.
358        retain(&router, &mut state, &mut req, "model-b").await?;
359
360        let scores = scores(&router, &mut state, &mut req).await?;
361        assert_eq!(
362            scores.first().map(|score| score.target.as_str()),
363            Some("model-a")
364        );
365        Ok(())
366    }
367
368    #[tokio::test]
369    async fn subagent_is_keyed_by_agent_not_task() -> Result<(), BoxErr> {
370        let router = AffinityRouter::new();
371        let mut state = ();
372
373        let mut first = request(subagent("child-1", "task-1"));
374        retain(&router, &mut state, &mut first, "model-a").await?;
375
376        // Same child, different task: still scored onto the retained model.
377        let mut second = request(subagent("child-1", "task-2"));
378        let scores = scores(&router, &mut state, &mut second).await?;
379        assert_eq!(
380            scores.first().map(|score| score.target.as_str()),
381            Some("model-a")
382        );
383        Ok(())
384    }
385
386    #[tokio::test]
387    async fn distinct_subagents_are_assigned_independently() -> Result<(), BoxErr> {
388        let router = AffinityRouter::new();
389        let mut state = ();
390
391        // One child in the session is pinned...
392        retain(
393            &router,
394            &mut state,
395            &mut request(subagent("child-1", "task-1")),
396            "model-a",
397        )
398        .await?;
399
400        // ...a sibling child in the same session has no assignment of its own yet.
401        let mut sibling = request(subagent("child-2", "task-1"));
402        assert!(scores(&router, &mut state, &mut sibling).await?.is_empty());
403        Ok(())
404    }
405
406    #[tokio::test]
407    async fn subagent_does_not_inherit_session_assignment() -> Result<(), BoxErr> {
408        let router = AffinityRouter::new();
409        let mut state = ();
410
411        // The session root is pinned, but a sub-agent is keyed separately...
412        retain(
413            &router,
414            &mut state,
415            &mut request(session("session-1", "root-1")),
416            "model-a",
417        )
418        .await?;
419
420        // ...so the sub-agent abstains until it is assigned in its own right.
421        let mut child = request(subagent("child-1", "task-1"));
422        assert!(scores(&router, &mut state, &mut child).await?.is_empty());
423        Ok(())
424    }
425
426    #[tokio::test]
427    async fn classifier_abstains_without_a_session() -> Result<(), BoxErr> {
428        let router = AffinityRouter::new();
429        let mut state = ();
430
431        // No session id at all: nothing to key on.
432        let mut req = request(Metadata::default());
433        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
434        Ok(())
435    }
436
437    #[tokio::test]
438    async fn message_hash_fallback_uses_the_first_user_message() -> Result<(), BoxErr> {
439        let router = AffinityRouter::new().with_message_hash_fallback();
440        let mut state = ();
441
442        let mut first = task_request(
443            None,
444            "Add a unit test for this function.",
445            Some("Now run the test suite."),
446        );
447        retain(&router, &mut state, &mut first, "weak").await?;
448
449        let mut follow_up = task_request(
450            None,
451            "Add a unit test for this function.",
452            Some("Now file a pull request."),
453        );
454        assert_eq!(
455            scores(&router, &mut state, &mut follow_up)
456                .await?
457                .first()
458                .map(|score| score.target.as_str()),
459            Some("weak")
460        );
461
462        let mut other_task = task_request(
463            None,
464            "Reimplement this binary from two input/output pairs.",
465            Some("Now run the test suite."),
466        );
467        assert!(
468            scores(&router, &mut state, &mut other_task)
469                .await?
470                .is_empty()
471        );
472        Ok(())
473    }
474
475    #[tokio::test]
476    async fn subagents_only_root_traffic_does_not_warn() -> Result<(), BoxErr> {
477        // Abstaining on root traffic is this mode's contract, so it must not warn.
478        let router = AffinityRouter::for_subagents();
479        let mut state = ();
480
481        let mut root = request(session("session-1", "agent-1"));
482        assert!(scores(&router, &mut state, &mut root).await?.is_empty());
483        assert!(
484            router.should_warn_unkeyed(),
485            "an intentional abstention should leave the warning unconsumed"
486        );
487        Ok(())
488    }
489
490    #[test]
491    fn user_message_hash_ignores_non_text_provider_payloads() {
492        let request = |user_message| Request {
493            llm_request: LlmRequest {
494                messages: vec![user_message],
495                ..LlmRequest::default()
496            },
497            raw_request: None,
498            metadata: None,
499        };
500        let text_only = request(Message::text(Role::User, "Implement the parser."));
501        let text_with_reasoning = request(Message {
502            role: Role::User,
503            content: vec![
504                ContentBlock::Text {
505                    text: "Implement the parser.".to_string(),
506                },
507                ContentBlock::Reasoning {
508                    text: "Internal provider reasoning.".to_string(),
509                    signature: Some("provider-signature".to_string()),
510                },
511            ],
512        });
513
514        assert_eq!(
515            first_user_message_hash(&text_only),
516            first_user_message_hash(&text_with_reasoning)
517        );
518    }
519
520    #[tokio::test]
521    async fn metadata_session_takes_precedence_over_message_hash() -> Result<(), BoxErr> {
522        let router = AffinityRouter::new().with_message_hash_fallback();
523        let mut state = ();
524
525        let mut first = task_request(
526            Some(session("session-1", "agent-a")),
527            "Implement the parser.",
528            None,
529        );
530        retain(&router, &mut state, &mut first, "strong").await?;
531
532        let mut other_session = task_request(
533            Some(session("session-2", "agent-a")),
534            "Implement the parser.",
535            None,
536        );
537        assert!(
538            scores(&router, &mut state, &mut other_session)
539                .await?
540                .is_empty()
541        );
542        Ok(())
543    }
544
545    #[tokio::test]
546    async fn subagent_without_a_session_abstains_and_warns() -> Result<(), BoxErr> {
547        for session_id in [None, Some(String::new())] {
548            let router = AffinityRouter::new().with_message_hash_fallback();
549            let mut state = ();
550            let mut subagent = task_request(
551                Some(Metadata {
552                    session_id,
553                    agent_id: Some("agent-1".to_string()),
554                    is_subagent: true,
555                    ..Metadata::default()
556                }),
557                "Implement the parser.",
558                None,
559            );
560
561            retain(&router, &mut state, &mut subagent, "model-a").await?;
562            assert!(scores(&router, &mut state, &mut subagent).await?.is_empty());
563            assert!(
564                !router.should_warn_unkeyed(),
565                "an unidentifiable subagent should consume the warning"
566            );
567        }
568        Ok(())
569    }
570
571    #[tokio::test]
572    async fn one_router_serves_both_roles() -> Result<(), BoxErr> {
573        // The same instance is registered under both SDK roles; a decision folded in via
574        // the processor handle is read back via the classifier handle.
575        let router = Arc::new(AffinityRouter::new());
576        let processor: Arc<dyn Processor> = router.clone();
577        let classifier: Arc<dyn Classifier> = router;
578        let mut state = ();
579
580        let mut first = request(session("session-1", "agent-a"));
581        processor
582            .process(
583                &mut state,
584                Event::Decision {
585                    request: &mut first,
586                    decision: &fixed_decision("model-a"),
587                },
588            )
589            .await?;
590
591        let mut second = request(session("session-1", "agent-b"));
592        let scores = scores(classifier.as_ref(), &mut state, &mut second).await?;
593        assert_eq!(
594            scores.first().map(|score| score.target.as_str()),
595            Some("model-a")
596        );
597        Ok(())
598    }
599
600    #[tokio::test]
601    async fn decision_without_an_affinity_identity_is_ignored() -> Result<(), BoxErr> {
602        let router = AffinityRouter::new();
603        let mut state = ();
604        let mut unkeyed = request(Metadata::default());
605
606        router
607            .process(
608                &mut state,
609                Event::Decision {
610                    request: &mut unkeyed,
611                    decision: &fixed_decision("model-a"),
612                },
613            )
614            .await?;
615
616        let mut req = request(session("session-1", "agent-a"));
617        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
618        Ok(())
619    }
620
621    #[tokio::test]
622    async fn decisions_retain_their_originating_request_identity() -> Result<(), BoxErr> {
623        let router = AffinityRouter::new();
624        let mut state = ();
625        let mut first = request(session("session-1", "agent-a"));
626        let mut second = request(session("session-2", "agent-b"));
627
628        // Replay decisions in the opposite order. Each decision carries its request,
629        // so the assignments cannot cross.
630        router
631            .process(
632                &mut state,
633                Event::Decision {
634                    request: &mut second,
635                    decision: &fixed_decision("model-b"),
636                },
637            )
638            .await?;
639        router
640            .process(
641                &mut state,
642                Event::Decision {
643                    request: &mut first,
644                    decision: &fixed_decision("model-a"),
645                },
646            )
647            .await?;
648
649        let first_scores = scores(&router, &mut state, &mut first).await?;
650        let second_scores = scores(&router, &mut state, &mut second).await?;
651        assert_eq!(
652            first_scores.first().map(|score| score.target.as_str()),
653            Some("model-a")
654        );
655        assert_eq!(
656            second_scores.first().map(|score| score.target.as_str()),
657            Some("model-b")
658        );
659        Ok(())
660    }
661
662    #[tokio::test]
663    async fn distinct_sessions_are_assigned_independently() -> Result<(), BoxErr> {
664        let router = AffinityRouter::new();
665        let mut state = ();
666
667        retain(
668            &router,
669            &mut state,
670            &mut request(session("session-1", "agent-a")),
671            "model-a",
672        )
673        .await?;
674        retain(
675            &router,
676            &mut state,
677            &mut request(session("session-2", "agent-a")),
678            "model-b",
679        )
680        .await?;
681
682        let first = scores(
683            &router,
684            &mut state,
685            &mut request(session("session-1", "other")),
686        )
687        .await?;
688        let second = scores(
689            &router,
690            &mut state,
691            &mut request(session("session-2", "other")),
692        )
693        .await?;
694        assert_eq!(
695            first.first().map(|score| score.target.as_str()),
696            Some("model-a")
697        );
698        assert_eq!(
699            second.first().map(|score| score.target.as_str()),
700            Some("model-b")
701        );
702        Ok(())
703    }
704
705    #[tokio::test]
706    async fn subagent_without_an_agent_id_abstains_and_warns() -> Result<(), BoxErr> {
707        let router = AffinityRouter::new();
708        let mut state = ();
709
710        // The sub-agent flag is set but no agent id is present, so no key can be formed;
711        // the request is neither retained nor scored.
712        let metadata = Metadata {
713            session_id: Some("session-1".to_string()),
714            is_subagent: true,
715            ..Metadata::default()
716        };
717        let mut req = request(metadata);
718        retain(&router, &mut state, &mut req, "model-a").await?;
719        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
720        assert!(
721            !router.should_warn_unkeyed(),
722            "an unidentifiable subagent should consume the warning"
723        );
724        Ok(())
725    }
726
727    #[tokio::test]
728    async fn assignments_are_bounded_by_the_cap() -> Result<(), BoxErr> {
729        let router = AffinityRouter::new();
730        let mut state = ();
731
732        // One distinct session past the cap forces exactly one eviction.
733        for index in 0..=MAX_ASSIGNMENTS {
734            let session_id = format!("session-{index}");
735            retain(
736                &router,
737                &mut state,
738                &mut request(session(&session_id, "agent-a")),
739                "model-a",
740            )
741            .await?;
742        }
743
744        let len = router.assignments.lock().len();
745        assert_eq!(len, MAX_ASSIGNMENTS);
746        Ok(())
747    }
748
749    #[tokio::test]
750    async fn latch_only_retains_matching_models() -> Result<(), BoxErr> {
751        let router = AffinityRouter::new().with_latch_only(["strong"]);
752        let mut state = ();
753        let mut req = request(session("session-1", "agent-a"));
754
755        // A "weak" decision is not retained — a later turn is not latched.
756        retain(&router, &mut state, &mut req, "weak").await?;
757        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
758
759        // A "strong" decision is retained — later turns latch onto it.
760        retain(&router, &mut state, &mut req, "strong").await?;
761        assert_eq!(
762            scores(&router, &mut state, &mut req)
763                .await?
764                .first()
765                .map(|s| s.target.as_str()),
766            Some("strong")
767        );
768        Ok(())
769    }
770}