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 {
150            request,
151            selected_model_id,
152        } = event
153            && let Some(key) = self.affinity_key(request)
154        {
155            let mut assignments = self.assignments.lock();
156            if self.should_latch(selected_model_id) && !assignments.contains_key(&key) {
157                evict_if_full(&mut assignments);
158                assignments.insert(key, selected_model_id.clone());
159            }
160        }
161        Ok(())
162    }
163}
164
165/// Hashes the first user message so later turns retain the initial task's affinity.
166/// For benchmarking purpose with harnesses, task instructions are added as a user prompt to the request so we hash the initial user message.
167/// TODO: Have not considered multi-modal payloads yet. That needs to be handled separately.
168fn first_user_message_hash(request: &Request) -> Option<String> {
169    let message = request
170        .llm_request
171        .messages
172        .iter()
173        .find(|message| message.role == Role::User)?;
174    let mut hasher = DefaultHasher::new();
175    message.text_content("")?.hash(&mut hasher);
176    Some(format!("{:016x}", hasher.finish()))
177}
178
179#[async_trait]
180impl<S> Classifier<S> for AffinityRouter
181where
182    S: Send + 'static,
183{
184    async fn score(
185        &self,
186        _state: &mut S,
187        request: &mut Request,
188        _driver: Option<&Driver>,
189    ) -> crate::Result<(Classification, Option<switchyard_protocol::Response>)> {
190        let Some(key) = self.affinity_key(request) else {
191            return Ok((Classification::Scores(Vec::new()), None));
192        };
193        let assigned = self.assignments.lock().get(&key).cloned();
194        Ok((
195            Classification::Scores(match assigned {
196                Some(target) => vec![Score {
197                    confidence: 1.0,
198                    target,
199                }],
200                None => Vec::new(),
201            }),
202            None,
203        ))
204    }
205}
206
207/// Evicts one arbitrary assignment when the map has reached [`MAX_ASSIGNMENTS`].
208fn evict_if_full(assignments: &mut HashMap<RoutingIdentity, ModelId>) {
209    if assignments.len() >= MAX_ASSIGNMENTS
210        && let Some(evicted) = assignments.keys().next().cloned()
211    {
212        assignments.remove(&evicted);
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    use std::sync::Arc;
221
222    use switchyard_protocol::{ContentBlock, LlmRequest, Message, Metadata, text_request};
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_model(target: &str) -> ModelId {
228        ModelId::from(target)
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 selected_model_id = ModelId::from(model);
289        router
290            .process(
291                state,
292                Event::Decision {
293                    request,
294                    selected_model_id: &selected_model_id,
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                    details: Vec::new(),
511                },
512            ],
513        });
514
515        assert_eq!(
516            first_user_message_hash(&text_only),
517            first_user_message_hash(&text_with_reasoning)
518        );
519    }
520
521    #[tokio::test]
522    async fn metadata_session_takes_precedence_over_message_hash() -> Result<(), BoxErr> {
523        let router = AffinityRouter::new().with_message_hash_fallback();
524        let mut state = ();
525
526        let mut first = task_request(
527            Some(session("session-1", "agent-a")),
528            "Implement the parser.",
529            None,
530        );
531        retain(&router, &mut state, &mut first, "strong").await?;
532
533        let mut other_session = task_request(
534            Some(session("session-2", "agent-a")),
535            "Implement the parser.",
536            None,
537        );
538        assert!(
539            scores(&router, &mut state, &mut other_session)
540                .await?
541                .is_empty()
542        );
543        Ok(())
544    }
545
546    #[tokio::test]
547    async fn subagent_without_a_session_abstains_and_warns() -> Result<(), BoxErr> {
548        for session_id in [None, Some(String::new())] {
549            let router = AffinityRouter::new().with_message_hash_fallback();
550            let mut state = ();
551            let mut subagent = task_request(
552                Some(Metadata {
553                    session_id,
554                    agent_id: Some("agent-1".to_string()),
555                    is_subagent: true,
556                    ..Metadata::default()
557                }),
558                "Implement the parser.",
559                None,
560            );
561
562            retain(&router, &mut state, &mut subagent, "model-a").await?;
563            assert!(scores(&router, &mut state, &mut subagent).await?.is_empty());
564            assert!(
565                !router.should_warn_unkeyed(),
566                "an unidentifiable subagent should consume the warning"
567            );
568        }
569        Ok(())
570    }
571
572    #[tokio::test]
573    async fn one_router_serves_both_roles() -> Result<(), BoxErr> {
574        // The same instance is registered under both SDK roles; a decision folded in via
575        // the processor handle is read back via the classifier handle.
576        let router = Arc::new(AffinityRouter::new());
577        let processor: Arc<dyn Processor> = router.clone();
578        let classifier: Arc<dyn Classifier> = router;
579        let mut state = ();
580
581        let mut first = request(session("session-1", "agent-a"));
582        processor
583            .process(
584                &mut state,
585                Event::Decision {
586                    request: &mut first,
587                    selected_model_id: &fixed_model("model-a"),
588                },
589            )
590            .await?;
591
592        let mut second = request(session("session-1", "agent-b"));
593        let scores = scores(classifier.as_ref(), &mut state, &mut second).await?;
594        assert_eq!(
595            scores.first().map(|score| score.target.as_str()),
596            Some("model-a")
597        );
598        Ok(())
599    }
600
601    #[tokio::test]
602    async fn decision_without_an_affinity_identity_is_ignored() -> Result<(), BoxErr> {
603        let router = AffinityRouter::new();
604        let mut state = ();
605        let mut unkeyed = request(Metadata::default());
606
607        router
608            .process(
609                &mut state,
610                Event::Decision {
611                    request: &mut unkeyed,
612                    selected_model_id: &fixed_model("model-a"),
613                },
614            )
615            .await?;
616
617        let mut req = request(session("session-1", "agent-a"));
618        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
619        Ok(())
620    }
621
622    #[tokio::test]
623    async fn decisions_retain_their_originating_request_identity() -> Result<(), BoxErr> {
624        let router = AffinityRouter::new();
625        let mut state = ();
626        let mut first = request(session("session-1", "agent-a"));
627        let mut second = request(session("session-2", "agent-b"));
628
629        // Replay decisions in the opposite order. Each decision carries its request,
630        // so the assignments cannot cross.
631        router
632            .process(
633                &mut state,
634                Event::Decision {
635                    request: &mut second,
636                    selected_model_id: &fixed_model("model-b"),
637                },
638            )
639            .await?;
640        router
641            .process(
642                &mut state,
643                Event::Decision {
644                    request: &mut first,
645                    selected_model_id: &fixed_model("model-a"),
646                },
647            )
648            .await?;
649
650        let first_scores = scores(&router, &mut state, &mut first).await?;
651        let second_scores = scores(&router, &mut state, &mut second).await?;
652        assert_eq!(
653            first_scores.first().map(|score| score.target.as_str()),
654            Some("model-a")
655        );
656        assert_eq!(
657            second_scores.first().map(|score| score.target.as_str()),
658            Some("model-b")
659        );
660        Ok(())
661    }
662
663    #[tokio::test]
664    async fn distinct_sessions_are_assigned_independently() -> Result<(), BoxErr> {
665        let router = AffinityRouter::new();
666        let mut state = ();
667
668        retain(
669            &router,
670            &mut state,
671            &mut request(session("session-1", "agent-a")),
672            "model-a",
673        )
674        .await?;
675        retain(
676            &router,
677            &mut state,
678            &mut request(session("session-2", "agent-a")),
679            "model-b",
680        )
681        .await?;
682
683        let first = scores(
684            &router,
685            &mut state,
686            &mut request(session("session-1", "other")),
687        )
688        .await?;
689        let second = scores(
690            &router,
691            &mut state,
692            &mut request(session("session-2", "other")),
693        )
694        .await?;
695        assert_eq!(
696            first.first().map(|score| score.target.as_str()),
697            Some("model-a")
698        );
699        assert_eq!(
700            second.first().map(|score| score.target.as_str()),
701            Some("model-b")
702        );
703        Ok(())
704    }
705
706    #[tokio::test]
707    async fn subagent_without_an_agent_id_abstains_and_warns() -> Result<(), BoxErr> {
708        let router = AffinityRouter::new();
709        let mut state = ();
710
711        // The sub-agent flag is set but no agent id is present, so no key can be formed;
712        // the request is neither retained nor scored.
713        let metadata = Metadata {
714            session_id: Some("session-1".to_string()),
715            is_subagent: true,
716            ..Metadata::default()
717        };
718        let mut req = request(metadata);
719        retain(&router, &mut state, &mut req, "model-a").await?;
720        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
721        assert!(
722            !router.should_warn_unkeyed(),
723            "an unidentifiable subagent should consume the warning"
724        );
725        Ok(())
726    }
727
728    #[tokio::test]
729    async fn assignments_are_bounded_by_the_cap() -> Result<(), BoxErr> {
730        let router = AffinityRouter::new();
731        let mut state = ();
732
733        // One distinct session past the cap forces exactly one eviction.
734        for index in 0..=MAX_ASSIGNMENTS {
735            let session_id = format!("session-{index}");
736            retain(
737                &router,
738                &mut state,
739                &mut request(session(&session_id, "agent-a")),
740                "model-a",
741            )
742            .await?;
743        }
744
745        let len = router.assignments.lock().len();
746        assert_eq!(len, MAX_ASSIGNMENTS);
747        Ok(())
748    }
749
750    #[tokio::test]
751    async fn latch_only_retains_matching_models() -> Result<(), BoxErr> {
752        let router = AffinityRouter::new().with_latch_only(["strong"]);
753        let mut state = ();
754        let mut req = request(session("session-1", "agent-a"));
755
756        // A "weak" decision is not retained — a later turn is not latched.
757        retain(&router, &mut state, &mut req, "weak").await?;
758        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
759
760        // A "strong" decision is retained — later turns latch onto it.
761        retain(&router, &mut state, &mut req, "strong").await?;
762        assert_eq!(
763            scores(&router, &mut state, &mut req)
764                .await?
765                .first()
766                .map(|s| s.target.as_str()),
767            Some("strong")
768        );
769        Ok(())
770    }
771}