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::{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<String>>,
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, String>>,
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<String>>) -> 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.to_string());
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    fn target_unavailable(&self, request: &Request, target: &str) {
183        let Some(key) = self.affinity_key(request) else {
184            return;
185        };
186        let mut assignments = self.assignments.lock();
187        if assignments
188            .get(&key)
189            .is_some_and(|assigned| assigned == target)
190        {
191            assignments.remove(&key);
192        }
193    }
194
195    async fn score(
196        &self,
197        _state: &mut S,
198        request: &mut Request,
199        _driver: Option<&Driver>,
200    ) -> crate::Result<(Classification, Option<switchyard_protocol::Response>)> {
201        let Some(key) = self.affinity_key(request) else {
202            return Ok((Classification::Scores(Vec::new()), None));
203        };
204        let assigned = self.assignments.lock().get(&key).cloned();
205        Ok((
206            Classification::Scores(match assigned {
207                Some(target) => vec![Score {
208                    confidence: 1.0,
209                    target,
210                }],
211                None => Vec::new(),
212            }),
213            None,
214        ))
215    }
216}
217
218/// Evicts one arbitrary assignment when the map has reached [`MAX_ASSIGNMENTS`].
219fn evict_if_full(assignments: &mut HashMap<RoutingIdentity, String>) {
220    if assignments.len() >= MAX_ASSIGNMENTS
221        && let Some(evicted) = assignments.keys().next().cloned()
222    {
223        assignments.remove(&evicted);
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    use std::sync::Arc;
232
233    use switchyard_protocol::{
234        ContentBlock, Decision, LlmRequest, Message, Metadata, text_request,
235    };
236
237    /// Boxed, thread-safe error type keeping the test helpers ergonomic.
238    type BoxErr = Box<dyn std::error::Error + Send + Sync>;
239
240    fn fixed_decision(target: &str) -> Decision {
241        Decision::new(target, None, true)
242    }
243
244    fn request(metadata: Metadata) -> Request {
245        Request {
246            llm_request: text_request(Some("auto".to_string()), "hi"),
247            raw_request: None,
248            metadata: Some(metadata),
249        }
250    }
251
252    fn task_request(
253        metadata: Option<Metadata>,
254        first_user: &str,
255        follow_up: Option<&str>,
256    ) -> Request {
257        let mut messages = vec![
258            Message::text(Role::System, "follow repository instructions"),
259            Message::text(Role::User, first_user),
260        ];
261        if let Some(follow_up) = follow_up {
262            messages.push(Message::text(Role::Assistant, "I will inspect the code."));
263            messages.push(Message::text(Role::User, follow_up));
264        }
265        Request {
266            llm_request: LlmRequest {
267                model: Some("auto".to_string()),
268                messages,
269                ..LlmRequest::default()
270            },
271            raw_request: None,
272            metadata,
273        }
274    }
275
276    fn session(session_id: &str, agent_id: &str) -> Metadata {
277        Metadata {
278            session_id: Some(session_id.to_string()),
279            agent_id: Some(agent_id.to_string()),
280            ..Metadata::default()
281        }
282    }
283
284    fn subagent(agent_id: &str, task_id: &str) -> Metadata {
285        Metadata {
286            session_id: Some("session-1".to_string()),
287            agent_id: Some(agent_id.to_string()),
288            task_id: Some(task_id.to_string()),
289            is_subagent: true,
290            ..Metadata::default()
291        }
292    }
293
294    /// Folds a request and its decision through the router, retaining `model`.
295    async fn retain(
296        router: &AffinityRouter,
297        state: &mut (),
298        request: &mut Request,
299        model: &'static str,
300    ) -> Result<(), BoxErr> {
301        let decision = fixed_decision(model);
302        router
303            .process(
304                state,
305                Event::Decision {
306                    request,
307                    decision: &decision,
308                },
309            )
310            .await?;
311        Ok(())
312    }
313
314    /// Scores through the definitive classification variant used by affinity.
315    async fn scores(
316        classifier: &dyn Classifier,
317        state: &mut (),
318        request: &mut Request,
319    ) -> Result<Vec<Score>, BoxErr> {
320        match classifier.score(state, request, None).await?.0 {
321            Classification::Scores(scores) => Ok(scores),
322            Classification::Ambiguous(_) => Err("affinity never returns ambiguous scores".into()),
323        }
324    }
325
326    #[tokio::test]
327    async fn session_retains_first_model_across_requests() -> Result<(), BoxErr> {
328        let router = AffinityRouter::new();
329        let mut state = ();
330
331        let mut first = request(session("session-1", "agent-a"));
332        retain(&router, &mut state, &mut first, "model-a").await?;
333
334        // A different agent in the same session is scored onto the retained model.
335        let mut second = request(session("session-1", "agent-b"));
336        let scores = scores(&router, &mut state, &mut second).await?;
337        assert_eq!(scores.len(), 1);
338        assert_eq!(scores[0].confidence, 1.0);
339        assert_eq!(scores[0].target, "model-a");
340        Ok(())
341    }
342
343    #[tokio::test]
344    async fn subagent_only_retains_children_without_latching_root_traffic() -> Result<(), BoxErr> {
345        let router = AffinityRouter::for_subagents();
346        let mut state = ();
347
348        let mut root = request(session("session-1", "root-agent"));
349        retain(&router, &mut state, &mut root, "model-a").await?;
350        assert!(scores(&router, &mut state, &mut root).await?.is_empty());
351
352        let mut first_child_turn = request(subagent("child-1", "task-1"));
353        retain(&router, &mut state, &mut first_child_turn, "model-b").await?;
354        let mut later_child_turn = request(subagent("child-1", "task-2"));
355        let scores = scores(&router, &mut state, &mut later_child_turn).await?;
356        assert_eq!(
357            scores.first().map(|score| score.target.as_str()),
358            Some("model-b")
359        );
360        Ok(())
361    }
362
363    #[tokio::test]
364    async fn first_decision_wins() -> Result<(), BoxErr> {
365        let router = AffinityRouter::new();
366        let mut state = ();
367
368        let mut req = request(session("session-1", "agent-a"));
369        retain(&router, &mut state, &mut req, "model-a").await?;
370        // A later decision for the same identity must not overwrite the first.
371        retain(&router, &mut state, &mut req, "model-b").await?;
372
373        let scores = scores(&router, &mut state, &mut req).await?;
374        assert_eq!(
375            scores.first().map(|score| score.target.as_str()),
376            Some("model-a")
377        );
378        Ok(())
379    }
380
381    #[tokio::test]
382    async fn subagent_is_keyed_by_agent_not_task() -> Result<(), BoxErr> {
383        let router = AffinityRouter::new();
384        let mut state = ();
385
386        let mut first = request(subagent("child-1", "task-1"));
387        retain(&router, &mut state, &mut first, "model-a").await?;
388
389        // Same child, different task: still scored onto the retained model.
390        let mut second = request(subagent("child-1", "task-2"));
391        let scores = scores(&router, &mut state, &mut second).await?;
392        assert_eq!(
393            scores.first().map(|score| score.target.as_str()),
394            Some("model-a")
395        );
396        Ok(())
397    }
398
399    #[tokio::test]
400    async fn distinct_subagents_are_assigned_independently() -> Result<(), BoxErr> {
401        let router = AffinityRouter::new();
402        let mut state = ();
403
404        // One child in the session is pinned...
405        retain(
406            &router,
407            &mut state,
408            &mut request(subagent("child-1", "task-1")),
409            "model-a",
410        )
411        .await?;
412
413        // ...a sibling child in the same session has no assignment of its own yet.
414        let mut sibling = request(subagent("child-2", "task-1"));
415        assert!(scores(&router, &mut state, &mut sibling).await?.is_empty());
416        Ok(())
417    }
418
419    #[tokio::test]
420    async fn subagent_does_not_inherit_session_assignment() -> Result<(), BoxErr> {
421        let router = AffinityRouter::new();
422        let mut state = ();
423
424        // The session root is pinned, but a sub-agent is keyed separately...
425        retain(
426            &router,
427            &mut state,
428            &mut request(session("session-1", "root-1")),
429            "model-a",
430        )
431        .await?;
432
433        // ...so the sub-agent abstains until it is assigned in its own right.
434        let mut child = request(subagent("child-1", "task-1"));
435        assert!(scores(&router, &mut state, &mut child).await?.is_empty());
436        Ok(())
437    }
438
439    #[tokio::test]
440    async fn classifier_abstains_without_a_session() -> Result<(), BoxErr> {
441        let router = AffinityRouter::new();
442        let mut state = ();
443
444        // No session id at all: nothing to key on.
445        let mut req = request(Metadata::default());
446        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
447        Ok(())
448    }
449
450    #[tokio::test]
451    async fn message_hash_fallback_uses_the_first_user_message() -> Result<(), BoxErr> {
452        let router = AffinityRouter::new().with_message_hash_fallback();
453        let mut state = ();
454
455        let mut first = task_request(
456            None,
457            "Add a unit test for this function.",
458            Some("Now run the test suite."),
459        );
460        retain(&router, &mut state, &mut first, "weak").await?;
461
462        let mut follow_up = task_request(
463            None,
464            "Add a unit test for this function.",
465            Some("Now file a pull request."),
466        );
467        assert_eq!(
468            scores(&router, &mut state, &mut follow_up)
469                .await?
470                .first()
471                .map(|score| score.target.as_str()),
472            Some("weak")
473        );
474
475        let mut other_task = task_request(
476            None,
477            "Reimplement this binary from two input/output pairs.",
478            Some("Now run the test suite."),
479        );
480        assert!(
481            scores(&router, &mut state, &mut other_task)
482                .await?
483                .is_empty()
484        );
485        Ok(())
486    }
487
488    #[tokio::test]
489    async fn subagents_only_root_traffic_does_not_warn() -> Result<(), BoxErr> {
490        // Abstaining on root traffic is this mode's contract, so it must not warn.
491        let router = AffinityRouter::for_subagents();
492        let mut state = ();
493
494        let mut root = request(session("session-1", "agent-1"));
495        assert!(scores(&router, &mut state, &mut root).await?.is_empty());
496        assert!(
497            router.should_warn_unkeyed(),
498            "an intentional abstention should leave the warning unconsumed"
499        );
500        Ok(())
501    }
502
503    #[test]
504    fn user_message_hash_ignores_non_text_provider_payloads() {
505        let request = |user_message| Request {
506            llm_request: LlmRequest {
507                messages: vec![user_message],
508                ..LlmRequest::default()
509            },
510            raw_request: None,
511            metadata: None,
512        };
513        let text_only = request(Message::text(Role::User, "Implement the parser."));
514        let text_with_reasoning = request(Message {
515            role: Role::User,
516            content: vec![
517                ContentBlock::Text {
518                    text: "Implement the parser.".to_string(),
519                },
520                ContentBlock::Reasoning {
521                    text: "Internal provider reasoning.".to_string(),
522                    signature: Some("provider-signature".to_string()),
523                },
524            ],
525        });
526
527        assert_eq!(
528            first_user_message_hash(&text_only),
529            first_user_message_hash(&text_with_reasoning)
530        );
531    }
532
533    #[tokio::test]
534    async fn metadata_session_takes_precedence_over_message_hash() -> Result<(), BoxErr> {
535        let router = AffinityRouter::new().with_message_hash_fallback();
536        let mut state = ();
537
538        let mut first = task_request(
539            Some(session("session-1", "agent-a")),
540            "Implement the parser.",
541            None,
542        );
543        retain(&router, &mut state, &mut first, "strong").await?;
544
545        let mut other_session = task_request(
546            Some(session("session-2", "agent-a")),
547            "Implement the parser.",
548            None,
549        );
550        assert!(
551            scores(&router, &mut state, &mut other_session)
552                .await?
553                .is_empty()
554        );
555        Ok(())
556    }
557
558    #[tokio::test]
559    async fn subagent_without_a_session_abstains_and_warns() -> Result<(), BoxErr> {
560        for session_id in [None, Some(String::new())] {
561            let router = AffinityRouter::new().with_message_hash_fallback();
562            let mut state = ();
563            let mut subagent = task_request(
564                Some(Metadata {
565                    session_id,
566                    agent_id: Some("agent-1".to_string()),
567                    is_subagent: true,
568                    ..Metadata::default()
569                }),
570                "Implement the parser.",
571                None,
572            );
573
574            retain(&router, &mut state, &mut subagent, "model-a").await?;
575            assert!(scores(&router, &mut state, &mut subagent).await?.is_empty());
576            assert!(
577                !router.should_warn_unkeyed(),
578                "an unidentifiable subagent should consume the warning"
579            );
580        }
581        Ok(())
582    }
583
584    #[tokio::test]
585    async fn one_router_serves_both_roles() -> Result<(), BoxErr> {
586        // The same instance is registered under both SDK roles; a decision folded in via
587        // the processor handle is read back via the classifier handle.
588        let router = Arc::new(AffinityRouter::new());
589        let processor: Arc<dyn Processor> = router.clone();
590        let classifier: Arc<dyn Classifier> = router;
591        let mut state = ();
592
593        let mut first = request(session("session-1", "agent-a"));
594        processor
595            .process(
596                &mut state,
597                Event::Decision {
598                    request: &mut first,
599                    decision: &fixed_decision("model-a"),
600                },
601            )
602            .await?;
603
604        let mut second = request(session("session-1", "agent-b"));
605        let scores = scores(classifier.as_ref(), &mut state, &mut second).await?;
606        assert_eq!(
607            scores.first().map(|score| score.target.as_str()),
608            Some("model-a")
609        );
610        Ok(())
611    }
612
613    #[tokio::test]
614    async fn decision_without_an_affinity_identity_is_ignored() -> Result<(), BoxErr> {
615        let router = AffinityRouter::new();
616        let mut state = ();
617        let mut unkeyed = request(Metadata::default());
618
619        router
620            .process(
621                &mut state,
622                Event::Decision {
623                    request: &mut unkeyed,
624                    decision: &fixed_decision("model-a"),
625                },
626            )
627            .await?;
628
629        let mut req = request(session("session-1", "agent-a"));
630        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
631        Ok(())
632    }
633
634    #[tokio::test]
635    async fn decisions_retain_their_originating_request_identity() -> Result<(), BoxErr> {
636        let router = AffinityRouter::new();
637        let mut state = ();
638        let mut first = request(session("session-1", "agent-a"));
639        let mut second = request(session("session-2", "agent-b"));
640
641        // Replay decisions in the opposite order. Each decision carries its request,
642        // so the assignments cannot cross.
643        router
644            .process(
645                &mut state,
646                Event::Decision {
647                    request: &mut second,
648                    decision: &fixed_decision("model-b"),
649                },
650            )
651            .await?;
652        router
653            .process(
654                &mut state,
655                Event::Decision {
656                    request: &mut first,
657                    decision: &fixed_decision("model-a"),
658                },
659            )
660            .await?;
661
662        let first_scores = scores(&router, &mut state, &mut first).await?;
663        let second_scores = scores(&router, &mut state, &mut second).await?;
664        assert_eq!(
665            first_scores.first().map(|score| score.target.as_str()),
666            Some("model-a")
667        );
668        assert_eq!(
669            second_scores.first().map(|score| score.target.as_str()),
670            Some("model-b")
671        );
672        Ok(())
673    }
674
675    #[tokio::test]
676    async fn distinct_sessions_are_assigned_independently() -> Result<(), BoxErr> {
677        let router = AffinityRouter::new();
678        let mut state = ();
679
680        retain(
681            &router,
682            &mut state,
683            &mut request(session("session-1", "agent-a")),
684            "model-a",
685        )
686        .await?;
687        retain(
688            &router,
689            &mut state,
690            &mut request(session("session-2", "agent-a")),
691            "model-b",
692        )
693        .await?;
694
695        let first = scores(
696            &router,
697            &mut state,
698            &mut request(session("session-1", "other")),
699        )
700        .await?;
701        let second = scores(
702            &router,
703            &mut state,
704            &mut request(session("session-2", "other")),
705        )
706        .await?;
707        assert_eq!(
708            first.first().map(|score| score.target.as_str()),
709            Some("model-a")
710        );
711        assert_eq!(
712            second.first().map(|score| score.target.as_str()),
713            Some("model-b")
714        );
715        Ok(())
716    }
717
718    #[tokio::test]
719    async fn subagent_without_an_agent_id_abstains_and_warns() -> Result<(), BoxErr> {
720        let router = AffinityRouter::new();
721        let mut state = ();
722
723        // The sub-agent flag is set but no agent id is present, so no key can be formed;
724        // the request is neither retained nor scored.
725        let metadata = Metadata {
726            session_id: Some("session-1".to_string()),
727            is_subagent: true,
728            ..Metadata::default()
729        };
730        let mut req = request(metadata);
731        retain(&router, &mut state, &mut req, "model-a").await?;
732        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
733        assert!(
734            !router.should_warn_unkeyed(),
735            "an unidentifiable subagent should consume the warning"
736        );
737        Ok(())
738    }
739
740    #[tokio::test]
741    async fn assignments_are_bounded_by_the_cap() -> Result<(), BoxErr> {
742        let router = AffinityRouter::new();
743        let mut state = ();
744
745        // One distinct session past the cap forces exactly one eviction.
746        for index in 0..=MAX_ASSIGNMENTS {
747            let session_id = format!("session-{index}");
748            retain(
749                &router,
750                &mut state,
751                &mut request(session(&session_id, "agent-a")),
752                "model-a",
753            )
754            .await?;
755        }
756
757        let len = router.assignments.lock().len();
758        assert_eq!(len, MAX_ASSIGNMENTS);
759        Ok(())
760    }
761
762    #[tokio::test]
763    async fn latch_only_retains_matching_models() -> Result<(), BoxErr> {
764        let router = AffinityRouter::new().with_latch_only(["strong"]);
765        let mut state = ();
766        let mut req = request(session("session-1", "agent-a"));
767
768        // A "weak" decision is not retained — a later turn is not latched.
769        retain(&router, &mut state, &mut req, "weak").await?;
770        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
771
772        // A "strong" decision is retained — later turns latch onto it.
773        retain(&router, &mut state, &mut req, "strong").await?;
774        assert_eq!(
775            scores(&router, &mut state, &mut req)
776                .await?
777                .first()
778                .map(|s| s.target.as_str()),
779            Some("strong")
780        );
781        Ok(())
782    }
783}