Skip to main content

meerkat_mobkit/memory/
selector.rs

1//! LLM Selector — recall judgment without a horizon
2//! (docs/design/agent-memory-architecture.md §8.3).
3//!
4//! A bounded one-shot structured call: manifest + incoming turn text +
5//! suppression list in, `{selected_ids, coverage}` out, under a versioned
6//! calibration profile (§11). The selector is a side-query over the store's
7//! manifest tiers, never over the injected index, and it is the only
8//! judgment stage allowed on the turn path — everything else in this module
9//! is deterministic plumbing around that single call.
10//!
11//! The model client comes host-side through meerkat's factory seam
12//! `AgentFactory::build_llm_client_for_identity` (§8.1) via
13//! [`FactorySelectorHandle`]; the coordinator reaches the stage through the
14//! process-wide install ([`install`]/[`installed`]) because the memory
15//! surfaces in `identity_first::agent_memory` construct their coordinators
16//! internally and stay untouched by this stage.
17
18use std::collections::{HashMap, HashSet};
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, LazyLock, Mutex, RwLock};
21
22use async_trait::async_trait;
23use futures::StreamExt;
24use rand_core::{OsRng, RngCore};
25use serde::Deserialize;
26
27use meerkat_client::{FactoryError, LlmClient, LlmDoneOutcome, LlmError, LlmEvent, LlmRequest};
28use meerkat_core::{Message, Provider, SessionLlmIdentity, UserMessage};
29
30use crate::identity_first::agent_memory::{
31    AgentMemoryError, AgentMemoryRecord, compact_whitespace, truncate_utf8_boundary,
32};
33use crate::memory::records::{MemoryScope, RecordMeta, TrustTier};
34
35/// Operator switch for the selector stage. Off by default: the model/auth
36/// binding choice belongs to the operator (§8.1 open question 3); flipping
37/// the default is a calibration-scorecard decision.
38pub const SELECTOR_ENV_VAR: &str = "MOBKIT_AGENT_MEMORY_SELECTOR";
39
40/// Embedded prompt bundle (crate-local copy of
41/// `memory-evals/prompts/selector-v0.md`; a unit test enforces byte
42/// equality so the calibration artifact and the shipped default cannot
43/// drift).
44pub const EMBEDDED_PROMPT_V0: &str = include_str!("selector_prompt_v0.md");
45
46const MANIFEST_PLACEHOLDER: &str = "{{manifest}}";
47const TURN_TEXT_PLACEHOLDER: &str = "{{turn_text}}";
48const SUPPRESSION_PLACEHOLDER: &str = "{{suppression_list}}";
49
50/// Bound the turn text rendered into the selector prompt; the manifest is
51/// the judgment surface, the turn is context.
52const MAX_PROMPT_TURN_BYTES: usize = 8 * 1024;
53/// Output budget for the structured selection object.
54const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 1024;
55/// §8.3 scale posture: Full-tier sweeps chunk the description manifest into
56/// side-model calls of roughly this many rendered bytes.
57pub const FULL_SWEEP_CHUNK_BYTES: usize = 100 * 1024;
58/// §8.3 scale-posture ceilings. Above the soft ceiling, Full-tier selection
59/// is chunked — correct but slower and costlier, and it says so loudly. At
60/// the hard ceiling (4× soft), the manifest truncates oldest-least-used
61/// with a loud event naming what was dropped, and the scope needs steward
62/// retention pressure. Defaults per §8.3; final numbers are a §16 question.
63pub const FULL_SWEEP_SOFT_CEILING_RECORDS: usize = 4_000;
64pub const FULL_SWEEP_HARD_CEILING_RECORDS: usize = 4 * FULL_SWEEP_SOFT_CEILING_RECORDS;
65
66// ---------------------------------------------------------------------------
67// Errors
68// ---------------------------------------------------------------------------
69
70#[derive(Debug)]
71pub enum SelectorError {
72    /// Calibration profile failed to load or validate (fail-loud).
73    Profile(String),
74    /// Client construction / auth resolution failed.
75    Auth(String),
76    /// The provider call itself failed.
77    Client(String),
78    /// The model's output never became valid structured JSON.
79    Parse(String),
80}
81
82impl std::fmt::Display for SelectorError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            Self::Profile(msg) => write!(f, "selector profile error: {msg}"),
86            Self::Auth(msg) => write!(f, "selector auth error: {msg}"),
87            Self::Client(msg) => write!(f, "selector client error: {msg}"),
88            Self::Parse(msg) => write!(f, "selector parse error: {msg}"),
89        }
90    }
91}
92
93impl std::error::Error for SelectorError {}
94
95// ---------------------------------------------------------------------------
96// Calibration profile (§11)
97// ---------------------------------------------------------------------------
98
99/// Params table of a selector calibration profile. Field set mirrors
100/// `memory-evals/profiles/selector-v0.toml`; keep the two in sync.
101#[derive(Debug, Clone, Deserialize)]
102pub struct SelectorParams {
103    #[serde(default = "default_temperature")]
104    pub temperature: f32,
105    #[serde(default = "default_selection_bar")]
106    pub selection_bar: String,
107    #[serde(default = "default_shuffle_manifest")]
108    pub shuffle_manifest: bool,
109    #[serde(default = "default_recall_timeout_ms")]
110    pub recall_timeout_ms: u64,
111    /// K for the WorkingSet manifest tier the per-turn side-query reads.
112    #[serde(default = "default_working_set_k")]
113    pub working_set_k: usize,
114    #[serde(default = "default_max_output_tokens")]
115    pub max_output_tokens: u32,
116}
117
118fn default_temperature() -> f32 {
119    0.0
120}
121fn default_selection_bar() -> String {
122    "certain-to-be-helpful".to_string()
123}
124fn default_shuffle_manifest() -> bool {
125    true
126}
127fn default_recall_timeout_ms() -> u64 {
128    500
129}
130fn default_working_set_k() -> usize {
131    200
132}
133fn default_max_output_tokens() -> u32 {
134    DEFAULT_MAX_OUTPUT_TOKENS
135}
136
137impl Default for SelectorParams {
138    fn default() -> Self {
139        Self {
140            temperature: default_temperature(),
141            selection_bar: default_selection_bar(),
142            shuffle_manifest: default_shuffle_manifest(),
143            recall_timeout_ms: default_recall_timeout_ms(),
144            working_set_k: default_working_set_k(),
145            max_output_tokens: default_max_output_tokens(),
146        }
147    }
148}
149
150/// A loaded selector calibration profile: `{stage, version, model, prompt
151/// bundle, params}` (§11), with the prompt template resolved to text.
152#[derive(Debug, Clone)]
153pub struct SelectorProfile {
154    pub stage: String,
155    pub version: String,
156    pub model: String,
157    pub provider: Provider,
158    /// Repo-relative bundle path for provenance (`CalibrationRef`).
159    pub prompt_bundle: String,
160    pub prompt_template: String,
161    pub params: SelectorParams,
162}
163
164#[derive(Debug, Deserialize)]
165struct RawProfile {
166    stage: String,
167    version: String,
168    model: String,
169    /// Optional explicit provider; defaults to catalog inference on `model`.
170    #[serde(default)]
171    provider: Option<String>,
172    prompt_bundle: String,
173    #[serde(default)]
174    params: Option<SelectorParams>,
175}
176
177impl SelectorProfile {
178    /// The embedded default profile: `memory-evals/profiles/selector-v0.toml`
179    /// with the prompt bundle compiled in, so the gateway needs no
180    /// filesystem coupling.
181    pub fn embedded_default() -> Self {
182        Self {
183            stage: "selector".to_string(),
184            version: "0".to_string(),
185            model: "claude-haiku-4-5".to_string(),
186            provider: Provider::Anthropic,
187            prompt_bundle: "prompts/selector-v0.md".to_string(),
188            prompt_template: EMBEDDED_PROMPT_V0.to_string(),
189            params: SelectorParams::default(),
190        }
191    }
192
193    /// Load an external calibration profile (fail-loud): TOML in the §11
194    /// format, prompt bundle resolved relative to the profile's directory
195    /// first, then to its parent (the `memory-evals/` layout, where bundles
196    /// are referenced relative to the evals root rather than `profiles/`).
197    pub fn load(path: &Path) -> Result<Self, SelectorError> {
198        let text = std::fs::read_to_string(path).map_err(|err| {
199            SelectorError::Profile(format!("cannot read profile '{}': {err}", path.display()))
200        })?;
201        let raw: RawProfile = toml::from_str(&text).map_err(|err| {
202            SelectorError::Profile(format!("invalid profile '{}': {err}", path.display()))
203        })?;
204        if raw.stage != "selector" {
205            return Err(SelectorError::Profile(format!(
206                "profile '{}' is for stage '{}', not 'selector'",
207                path.display(),
208                raw.stage
209            )));
210        }
211        if raw.model.trim().is_empty() || raw.model == "PLACEHOLDER" {
212            return Err(SelectorError::Profile(format!(
213                "profile '{}' does not name a model",
214                path.display()
215            )));
216        }
217        let provider = match raw.provider.as_deref() {
218            Some(name) => Provider::parse_strict(name).ok_or_else(|| {
219                SelectorError::Profile(format!(
220                    "profile '{}': unknown provider '{name}'",
221                    path.display()
222                ))
223            })?,
224            None => meerkat_models::infer_provider(&raw.model).ok_or_else(|| {
225                SelectorError::Profile(format!(
226                    "profile '{}': model '{}' is not in the catalog; set `provider` explicitly",
227                    path.display(),
228                    raw.model
229                ))
230            })?,
231        };
232        let base = path.parent().unwrap_or_else(|| Path::new("."));
233        let candidates = [
234            base.join(&raw.prompt_bundle),
235            base.parent()
236                .unwrap_or_else(|| Path::new("."))
237                .join(&raw.prompt_bundle),
238        ];
239        let bundle_path = candidates.iter().find(|p| p.is_file()).ok_or_else(|| {
240            SelectorError::Profile(format!(
241                "profile '{}': prompt_bundle '{}' does not resolve",
242                path.display(),
243                raw.prompt_bundle
244            ))
245        })?;
246        let prompt_template = std::fs::read_to_string(bundle_path).map_err(|err| {
247            SelectorError::Profile(format!(
248                "cannot read prompt bundle '{}': {err}",
249                bundle_path.display()
250            ))
251        })?;
252        let profile = Self {
253            stage: raw.stage,
254            version: raw.version,
255            model: raw.model,
256            provider,
257            prompt_bundle: raw.prompt_bundle,
258            prompt_template,
259            params: raw.params.unwrap_or_default(),
260        };
261        profile.validate()?;
262        Ok(profile)
263    }
264
265    fn validate(&self) -> Result<(), SelectorError> {
266        for placeholder in [
267            MANIFEST_PLACEHOLDER,
268            TURN_TEXT_PLACEHOLDER,
269            SUPPRESSION_PLACEHOLDER,
270        ] {
271            if !self.prompt_template.contains(placeholder) {
272                return Err(SelectorError::Profile(format!(
273                    "prompt bundle '{}' is missing placeholder `{placeholder}`",
274                    self.prompt_bundle
275                )));
276            }
277        }
278        Ok(())
279    }
280}
281
282// ---------------------------------------------------------------------------
283// Selection output
284// ---------------------------------------------------------------------------
285
286/// Coverage verdict from the selector's structured output (§8.3): whether
287/// the manifest slice it judged was enough, or a deeper full-store sweep
288/// should run off the blocking path.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum Coverage {
291    Sufficient,
292    NeedDeeperSweep,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct Selection {
297    pub selected_ids: Vec<String>,
298    pub coverage: Coverage,
299}
300
301#[derive(Deserialize)]
302struct RawSelection {
303    selected_ids: Vec<String>,
304    coverage: RawCoverage,
305}
306
307#[derive(Deserialize)]
308#[serde(rename_all = "snake_case")]
309enum RawCoverage {
310    Sufficient,
311    NeedDeeperSweep,
312}
313
314// ---------------------------------------------------------------------------
315// The selection call
316// ---------------------------------------------------------------------------
317
318/// One selector invocation: render the profile's prompt bundle over the
319/// manifest (position-shuffled per call — §8.3 bias guard), the incoming
320/// turn text, and the suppression list; parse the structured
321/// `{selected_ids, coverage}` reply. Unknown ids are dropped with a warning;
322/// suppressed ids are never returned; a single JSON-repair round-trip runs
323/// on parse failure, then the call errors.
324pub async fn select<S: std::hash::BuildHasher>(
325    manifest: &[RecordMeta],
326    turn_text: &str,
327    suppressed_ids: &HashSet<String, S>,
328    profile: &SelectorProfile,
329    client: &dyn LlmClient,
330) -> Result<Selection, SelectorError> {
331    let prompt = render_prompt(profile, manifest, turn_text, suppressed_ids);
332    let reply = complete_text(client, profile, prompt).await?;
333    let raw = match parse_selection(&reply) {
334        Ok(raw) => raw,
335        Err(first_err) => {
336            // One repair round-trip: hand the malformed reply back and ask
337            // for exactly the JSON object, nothing else.
338            let repair_prompt = format!(
339                "The following reply was supposed to be exactly one JSON object of the form \
340                 {{\"selected_ids\": [\"...\"], \"coverage\": \"sufficient\" | \"need_deeper_sweep\"}} \
341                 but did not parse ({first_err}). Reply with ONLY the corrected JSON object, \
342                 no other text.\n\n{reply}"
343            );
344            let repaired = complete_text(client, profile, repair_prompt).await?;
345            parse_selection(&repaired).map_err(SelectorError::Parse)?
346        }
347    };
348    let known: HashSet<&str> = manifest.iter().map(|meta| meta.id.as_str()).collect();
349    let mut seen = HashSet::new();
350    let mut selected_ids = Vec::new();
351    for id in raw.selected_ids {
352        if !known.contains(id.as_str()) {
353            tracing::warn!(id = %id, "selector returned an id not present in the manifest; dropped");
354            continue;
355        }
356        if suppressed_ids.contains(&id) {
357            tracing::warn!(id = %id, "selector returned a suppressed id; dropped");
358            continue;
359        }
360        if seen.insert(id.clone()) {
361            selected_ids.push(id);
362        }
363    }
364    Ok(Selection {
365        selected_ids,
366        coverage: match raw.coverage {
367            RawCoverage::Sufficient => Coverage::Sufficient,
368            RawCoverage::NeedDeeperSweep => Coverage::NeedDeeperSweep,
369        },
370    })
371}
372
373async fn complete_text(
374    client: &dyn LlmClient,
375    profile: &SelectorProfile,
376    prompt: String,
377) -> Result<String, SelectorError> {
378    let request = LlmRequest::new(
379        &profile.model,
380        vec![Message::User(UserMessage::text(prompt))],
381    )
382    .with_max_tokens(profile.params.max_output_tokens)
383    .with_temperature(profile.params.temperature);
384    let mut stream = client.stream(&request);
385    let mut text = String::new();
386    while let Some(event) = stream.next().await {
387        match event.map_err(classify_llm_error)? {
388            LlmEvent::TextDelta { delta, .. } => text.push_str(&delta),
389            LlmEvent::Done { outcome } => match outcome {
390                LlmDoneOutcome::Success { .. } => break,
391                LlmDoneOutcome::Error { error } => return Err(classify_llm_error(error)),
392            },
393            _ => {}
394        }
395    }
396    Ok(text)
397}
398
399fn classify_llm_error(error: LlmError) -> SelectorError {
400    match error {
401        LlmError::AuthenticationFailed { .. } | LlmError::InvalidApiKey => {
402            SelectorError::Auth(error.to_string())
403        }
404        other => SelectorError::Client(other.to_string()),
405    }
406}
407
408fn parse_selection(reply: &str) -> Result<RawSelection, String> {
409    let trimmed = reply.trim();
410    if let Ok(raw) = serde_json::from_str::<RawSelection>(trimmed) {
411        return Ok(raw);
412    }
413    // Tolerate fenced or prefixed output: parse the outermost object.
414    let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) else {
415        return Err("no JSON object in reply".to_string());
416    };
417    if start >= end {
418        return Err("no JSON object in reply".to_string());
419    }
420    serde_json::from_str::<RawSelection>(&trimmed[start..=end]).map_err(|err| err.to_string())
421}
422
423// ---------------------------------------------------------------------------
424// Prompt rendering
425// ---------------------------------------------------------------------------
426
427fn render_prompt<S: std::hash::BuildHasher>(
428    profile: &SelectorProfile,
429    manifest: &[RecordMeta],
430    turn_text: &str,
431    suppressed_ids: &HashSet<String, S>,
432) -> String {
433    let rows: Vec<&RecordMeta> = if profile.params.shuffle_manifest {
434        shuffled(manifest)
435    } else {
436        manifest.iter().collect()
437    };
438    let manifest_text = if rows.is_empty() {
439        "(no records)".to_string()
440    } else {
441        rows.iter()
442            .map(|meta| render_manifest_row(meta))
443            .collect::<Vec<_>>()
444            .join("\n")
445    };
446    let mut suppressed: Vec<&str> = suppressed_ids.iter().map(String::as_str).collect();
447    suppressed.sort_unstable();
448    let suppression_text = if suppressed.is_empty() {
449        "(none)".to_string()
450    } else {
451        suppressed
452            .iter()
453            .map(|id| format!("- {id}"))
454            .collect::<Vec<_>>()
455            .join("\n")
456    };
457    let turn = truncate_utf8_boundary(&compact_whitespace(turn_text), MAX_PROMPT_TURN_BYTES);
458    profile
459        .prompt_template
460        .replace(MANIFEST_PLACEHOLDER, &manifest_text)
461        .replace(SUPPRESSION_PLACEHOLDER, &suppression_text)
462        .replace(TURN_TEXT_PLACEHOLDER, &turn)
463}
464
465/// One manifest row: id, kind, age, rank, title — description (the fields
466/// the prompt bundle promises, in fixture `RecordMeta` shape).
467pub(crate) fn render_manifest_row(meta: &RecordMeta) -> String {
468    let rank = match meta.rank {
469        Some(rank) => format!("rank {rank}"),
470        None => "unranked".to_string(),
471    };
472    let mut row = format!(
473        "- {} [{}, {}, {}] {}",
474        meta.id,
475        meta.kind.as_str(),
476        age_phrase(meta.age_days),
477        rank,
478        compact_whitespace(&meta.title),
479    );
480    let description = compact_whitespace(&meta.description);
481    if !description.is_empty() {
482        row.push_str(" — ");
483        row.push_str(&description);
484    }
485    row
486}
487
488fn age_phrase(age_days: u64) -> String {
489    match age_days {
490        0 => "saved today".to_string(),
491        1 => "saved 1 day ago".to_string(),
492        n => format!("saved {n} days ago"),
493    }
494}
495
496/// Entropy-seeded Fisher–Yates (§8.3 bias guard). SplitMix64 over an OsRng
497/// seed: no `rand` dependency, uniform enough for position shuffling.
498fn shuffled(manifest: &[RecordMeta]) -> Vec<&RecordMeta> {
499    let mut seed = {
500        let mut bytes = [0u8; 8];
501        OsRng.fill_bytes(&mut bytes);
502        u64::from_le_bytes(bytes)
503    };
504    let mut next = move || {
505        seed = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
506        let mut z = seed;
507        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
508        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
509        z ^ (z >> 31)
510    };
511    let mut rows: Vec<&RecordMeta> = manifest.iter().collect();
512    for i in (1..rows.len()).rev() {
513        let j = (next() % (i as u64 + 1)) as usize;
514        rows.swap(i, j);
515    }
516    rows
517}
518
519/// Chunk a Full-tier manifest for the §8.3 scale posture: consecutive rows
520/// grouped so each chunk's rendered description text stays under
521/// [`FULL_SWEEP_CHUNK_BYTES`] per side-model call.
522pub fn chunk_manifest(manifest: &[RecordMeta]) -> Vec<&[RecordMeta]> {
523    let mut chunks = Vec::new();
524    let mut start = 0;
525    let mut bytes = 0usize;
526    for (index, meta) in manifest.iter().enumerate() {
527        let row_bytes = render_manifest_row(meta).len() + 1;
528        if index > start && bytes + row_bytes > FULL_SWEEP_CHUNK_BYTES {
529            chunks.push(&manifest[start..index]);
530            start = index;
531            bytes = 0;
532        }
533        bytes += row_bytes;
534    }
535    if start < manifest.len() {
536        chunks.push(&manifest[start..]);
537    }
538    chunks
539}
540
541/// §8.3 hard-ceiling truncation for Full-tier sweeps: keep steward-ranked
542/// records (rank ascending — the rank IS the steward's usage-informed
543/// ordering, so rank-then-recency is the faithful "oldest-least-used"
544/// proxy over `RecordMeta`), then unranked records newest-first, cut at
545/// `hard_ceiling`. Returns the kept manifest and the dropped ids so the
546/// caller can emit the loud truncation event naming what was dropped.
547pub fn truncate_full_manifest(
548    mut manifest: Vec<RecordMeta>,
549    hard_ceiling: usize,
550) -> (Vec<RecordMeta>, Vec<String>) {
551    if manifest.len() <= hard_ceiling {
552        return (manifest, Vec::new());
553    }
554    manifest.sort_by(|a, b| match (a.rank, b.rank) {
555        (Some(a_rank), Some(b_rank)) => a_rank.cmp(&b_rank),
556        (Some(_), None) => std::cmp::Ordering::Less,
557        (None, Some(_)) => std::cmp::Ordering::Greater,
558        (None, None) => a.age_days.cmp(&b.age_days),
559    });
560    let dropped = manifest
561        .split_off(hard_ceiling)
562        .into_iter()
563        .map(|meta| meta.id)
564        .collect();
565    (manifest, dropped)
566}
567
568// ---------------------------------------------------------------------------
569// Client acquisition (§8.1 invocation seam)
570// ---------------------------------------------------------------------------
571
572/// How the stage obtains (and re-obtains) its model client. The real
573/// implementation wraps meerkat's factory seam; tests supply a mock.
574#[async_trait]
575pub trait SelectorHandle: Send + Sync {
576    async fn client(&self) -> Result<Arc<dyn LlmClient>, SelectorError>;
577    /// Drop any cached client so the next `client()` re-resolves auth.
578    fn invalidate(&self);
579}
580
581/// Real handle over `AgentFactory::build_llm_client_for_identity`
582/// (meerkat 0.7.9 `factory.rs`): realm auth binding + model catalog
583/// resolution, the same seam session model hot-swap uses. Clients are
584/// cached per `(realm, model)`; [`SelectorHandle::invalidate`] clears the
585/// cache so an auth failure re-enters resolution.
586pub struct FactorySelectorHandle {
587    factory: meerkat::AgentFactory,
588    config: meerkat::Config,
589    realm: String,
590    identity: SessionLlmIdentity,
591    cache: Mutex<HashMap<(String, String), Arc<dyn LlmClient>>>,
592}
593
594impl FactorySelectorHandle {
595    pub fn new(
596        store_path: impl Into<PathBuf>,
597        config: meerkat::Config,
598        realm: impl Into<String>,
599        profile: &SelectorProfile,
600    ) -> Self {
601        Self::for_model(store_path, config, realm, &profile.model, profile.provider)
602    }
603
604    /// Same seam, keyed by raw model/provider — the Distiller (§8.4) and any
605    /// future off-turn stage obtain their clients through this exact factory
606    /// path rather than growing a parallel one (§8.1 dogma rule 7).
607    pub fn for_model(
608        store_path: impl Into<PathBuf>,
609        config: meerkat::Config,
610        realm: impl Into<String>,
611        model: &str,
612        provider: Provider,
613    ) -> Self {
614        Self {
615            factory: meerkat::AgentFactory::new(store_path.into()),
616            config,
617            realm: realm.into(),
618            identity: SessionLlmIdentity {
619                model: model.to_string(),
620                provider,
621                self_hosted_server_id: None,
622                provider_params: None,
623                // None = the realm's default binding for the provider; the
624                // explicit-binding choice is §8.1 open question 3.
625                auth_binding: None,
626            },
627            cache: Mutex::new(HashMap::new()),
628        }
629    }
630}
631
632#[async_trait]
633impl SelectorHandle for FactorySelectorHandle {
634    async fn client(&self) -> Result<Arc<dyn LlmClient>, SelectorError> {
635        let key = (self.realm.clone(), self.identity.model.clone());
636        if let Some(client) = self
637            .cache
638            .lock()
639            .unwrap_or_else(std::sync::PoisonError::into_inner)
640            .get(&key)
641        {
642            return Ok(client.clone());
643        }
644        let client = self
645            .factory
646            .build_llm_client_for_identity(&self.config, &self.identity)
647            .await
648            .map_err(|err| match err {
649                FactoryError::ProviderAuth(_) | FactoryError::ConnectionTarget(_) => {
650                    SelectorError::Auth(err.to_string())
651                }
652                other => SelectorError::Client(other.to_string()),
653            })?;
654        self.cache
655            .lock()
656            .unwrap_or_else(std::sync::PoisonError::into_inner)
657            .insert(key, client.clone());
658        Ok(client)
659    }
660
661    fn invalidate(&self) {
662        self.cache
663            .lock()
664            .unwrap_or_else(std::sync::PoisonError::into_inner)
665            .clear();
666    }
667}
668
669// ---------------------------------------------------------------------------
670// The stage: profile + handle, with auth re-resolve
671// ---------------------------------------------------------------------------
672
673/// A configured selector stage. On an auth failure the cached client is
674/// invalidated and the call retried once against a freshly resolved client.
675pub struct SelectorStage {
676    profile: SelectorProfile,
677    handle: Arc<dyn SelectorHandle>,
678}
679
680impl SelectorStage {
681    pub fn new(profile: SelectorProfile, handle: Arc<dyn SelectorHandle>) -> Self {
682        Self { profile, handle }
683    }
684
685    pub fn profile(&self) -> &SelectorProfile {
686        &self.profile
687    }
688
689    pub async fn select(
690        &self,
691        manifest: &[RecordMeta],
692        turn_text: &str,
693        suppressed_ids: &HashSet<String>,
694    ) -> Result<Selection, SelectorError> {
695        let client = self.handle.client().await?;
696        match select(manifest, turn_text, suppressed_ids, &self.profile, &*client).await {
697            Err(SelectorError::Auth(first)) => {
698                tracing::warn!(error = %first, "selector auth failure; re-resolving client");
699                self.handle.invalidate();
700                let client = self.handle.client().await?;
701                select(manifest, turn_text, suppressed_ids, &self.profile, &*client).await
702            }
703            other => other,
704        }
705    }
706}
707
708// ---------------------------------------------------------------------------
709// Body fetch for selector-chosen ids
710// ---------------------------------------------------------------------------
711
712/// §7.2/§9.1 per-record provenance for rendered bodies: the scope the
713/// record was read from and its trust tier, so the injection envelope can
714/// label each quoted observation instead of co-rendering scopes
715/// indistinguishably.
716#[derive(Debug, Clone, PartialEq, Eq)]
717pub struct RecordProvenance {
718    pub scope: MemoryScope,
719    pub trust: TrustTier,
720}
721
722/// A fetched record body plus its provenance, when the store can supply
723/// it. `provenance: None` renders the body without scope/trust labels
724/// (age still renders from the record's own timestamps).
725#[derive(Debug, Clone, PartialEq, Eq)]
726pub struct AnnotatedRecord {
727    pub record: AgentMemoryRecord,
728    pub provenance: Option<RecordProvenance>,
729}
730
731/// Fetch active record bodies by id across the composed scopes, in the
732/// order the ids were selected. Deliberately a standalone trait rather
733/// than an `AgentMemoryProvider` method: the v2 provider trait is owned by
734/// the recorder/taint cluster and stays untouched; manifest-capable stores
735/// opt in here so the coordinator can render selector-chosen bodies.
736#[async_trait]
737pub trait SelectedRecordFetch: Send + Sync {
738    async fn fetch_records(
739        &self,
740        scopes: &[MemoryScope],
741        ids: &[String],
742    ) -> Result<Vec<AgentMemoryRecord>, AgentMemoryError>;
743
744    /// Bodies plus §7.2 provenance labels. The default delegates to
745    /// [`Self::fetch_records`] with no provenance so existing stores keep
746    /// compiling; stores that know each record's scope and trust tier
747    /// should override so injected bodies carry their labels.
748    async fn fetch_records_annotated(
749        &self,
750        scopes: &[MemoryScope],
751        ids: &[String],
752    ) -> Result<Vec<AnnotatedRecord>, AgentMemoryError> {
753        Ok(self
754            .fetch_records(scopes, ids)
755            .await?
756            .into_iter()
757            .map(|record| AnnotatedRecord {
758                record,
759                provenance: None,
760            })
761            .collect())
762    }
763}
764
765// ---------------------------------------------------------------------------
766// Process-wide install
767// ---------------------------------------------------------------------------
768
769/// Everything the coordinator needs when a selector is configured.
770pub struct SelectorRuntime {
771    pub stage: Arc<SelectorStage>,
772    pub fetch: Arc<dyn SelectedRecordFetch>,
773}
774
775static INSTALLED: LazyLock<RwLock<Option<Arc<SelectorRuntime>>>> =
776    LazyLock::new(|| RwLock::new(None));
777
778/// Install the selector process-wide. Coordinators constructed afterwards
779/// pick it up ([`crate::memory::RecallCoordinator`] snapshots at
780/// construction); coordinators built for tests inject via
781/// `with_selector` and never touch this global.
782pub fn install(runtime: Arc<SelectorRuntime>) {
783    let mut guard = INSTALLED
784        .write()
785        .unwrap_or_else(std::sync::PoisonError::into_inner);
786    if guard.is_some() {
787        tracing::warn!("agent-memory selector re-installed; replacing the existing stage");
788    }
789    *guard = Some(runtime);
790}
791
792pub fn installed() -> Option<Arc<SelectorRuntime>> {
793    INSTALLED
794        .read()
795        .unwrap_or_else(std::sync::PoisonError::into_inner)
796        .clone()
797}
798
799/// Parsed operator spec for [`SELECTOR_ENV_VAR`].
800#[derive(Debug, Clone, PartialEq, Eq)]
801pub enum SelectorSpec {
802    Off,
803    Default,
804    Profile(PathBuf),
805}
806
807/// Fail-loud parse of the operator switch: unset or `off` disables the
808/// stage; `default` uses the embedded profile; `profile:<path>` loads an
809/// external calibration profile.
810pub fn spec_from_env() -> Result<SelectorSpec, SelectorError> {
811    match std::env::var(SELECTOR_ENV_VAR) {
812        Err(std::env::VarError::NotPresent) => Ok(SelectorSpec::Off),
813        Err(err) => Err(SelectorError::Profile(format!(
814            "{SELECTOR_ENV_VAR} is not valid unicode: {err}"
815        ))),
816        Ok(value) => parse_spec(&value),
817    }
818}
819
820fn parse_spec(value: &str) -> Result<SelectorSpec, SelectorError> {
821    let value = value.trim();
822    match value {
823        "" | "off" => Ok(SelectorSpec::Off),
824        "default" => Ok(SelectorSpec::Default),
825        other => match other.strip_prefix("profile:") {
826            Some(path) if !path.trim().is_empty() => {
827                Ok(SelectorSpec::Profile(PathBuf::from(path.trim())))
828            }
829            _ => Err(SelectorError::Profile(format!(
830                "invalid {SELECTOR_ENV_VAR} value '{other}' \
831                 (expected off | default | profile:<path>)"
832            ))),
833        },
834    }
835}
836
837/// Resolve a spec to a loaded profile (`Off` → `None`).
838pub fn profile_for_spec(spec: &SelectorSpec) -> Result<Option<SelectorProfile>, SelectorError> {
839    match spec {
840        SelectorSpec::Off => Ok(None),
841        SelectorSpec::Default => Ok(Some(SelectorProfile::embedded_default())),
842        SelectorSpec::Profile(path) => SelectorProfile::load(path).map(Some),
843    }
844}
845
846#[cfg(test)]
847#[allow(clippy::expect_used, clippy::redundant_clone, clippy::unwrap_used)]
848mod tests {
849    use super::*;
850    use crate::memory::records::MemoryKind;
851    use futures::stream;
852    use std::sync::Mutex as StdMutex;
853
854    fn meta(id: &str, title: &str, description: &str) -> RecordMeta {
855        RecordMeta {
856            id: id.to_string(),
857            kind: MemoryKind::Gotcha,
858            title: title.to_string(),
859            description: description.to_string(),
860            age_days: 3,
861            rank: Some(1),
862        }
863    }
864
865    /// Scripted mock: returns canned replies in order and captures every
866    /// prompt it was sent.
867    struct ScriptedLlm {
868        replies: StdMutex<Vec<String>>,
869        prompts: StdMutex<Vec<String>>,
870        provider: Provider,
871    }
872
873    impl ScriptedLlm {
874        fn new(replies: Vec<&str>) -> Self {
875            Self {
876                replies: StdMutex::new(replies.into_iter().map(str::to_string).collect()),
877                prompts: StdMutex::new(Vec::new()),
878                provider: Provider::Anthropic,
879            }
880        }
881
882        fn prompts(&self) -> Vec<String> {
883            self.prompts
884                .lock()
885                .unwrap_or_else(std::sync::PoisonError::into_inner)
886                .clone()
887        }
888    }
889
890    #[async_trait]
891    impl LlmClient for ScriptedLlm {
892        fn stream<'a>(&'a self, request: &'a LlmRequest) -> meerkat_client::types::LlmStream<'a> {
893            let prompt = request
894                .messages
895                .iter()
896                .map(|message| match message {
897                    Message::User(user) => user.text_content(),
898                    _ => String::new(),
899                })
900                .collect::<Vec<_>>()
901                .join("\n");
902            self.prompts
903                .lock()
904                .unwrap_or_else(std::sync::PoisonError::into_inner)
905                .push(prompt);
906            let reply = {
907                let mut replies = self
908                    .replies
909                    .lock()
910                    .unwrap_or_else(std::sync::PoisonError::into_inner);
911                if replies.is_empty() {
912                    String::new()
913                } else {
914                    replies.remove(0)
915                }
916            };
917            Box::pin(stream::iter(vec![
918                Ok(LlmEvent::TextDelta {
919                    delta: reply,
920                    meta: None,
921                }),
922                Ok(LlmEvent::Done {
923                    outcome: LlmDoneOutcome::Success {
924                        stop_reason: meerkat_core::StopReason::EndTurn,
925                    },
926                }),
927            ]))
928        }
929
930        fn provider(&self) -> Provider {
931            self.provider
932        }
933
934        async fn health_check(&self) -> Result<(), LlmError> {
935            Ok(())
936        }
937    }
938
939    fn manifest() -> Vec<RecordMeta> {
940        vec![
941            meta("mem-1", "Cargo wrapper", "When running cargo commands"),
942            meta("mem-2", "Deploy freeze", "When deploying on Fridays"),
943            meta(
944                "mem-3",
945                "Passport location",
946                "When travel documents come up",
947            ),
948        ]
949    }
950
951    #[tokio::test]
952    async fn select_parses_and_orders_selection() -> Result<(), Box<dyn std::error::Error>> {
953        let client = ScriptedLlm::new(vec![
954            r#"{"selected_ids": ["mem-2", "mem-1"], "coverage": "sufficient"}"#,
955        ]);
956        let profile = SelectorProfile::embedded_default();
957        let selection = select(
958            &manifest(),
959            "deploying the gateway",
960            &HashSet::new(),
961            &profile,
962            &client,
963        )
964        .await?;
965        assert_eq!(selection.selected_ids, vec!["mem-2", "mem-1"]);
966        assert_eq!(selection.coverage, Coverage::Sufficient);
967        Ok(())
968    }
969
970    #[tokio::test]
971    async fn select_drops_unknown_and_suppressed_ids() -> Result<(), Box<dyn std::error::Error>> {
972        let client = ScriptedLlm::new(vec![
973            r#"{"selected_ids": ["mem-9", "mem-1", "mem-2", "mem-1"], "coverage": "need_deeper_sweep"}"#,
974        ]);
975        let profile = SelectorProfile::embedded_default();
976        let suppressed: HashSet<String> = ["mem-2".to_string()].into();
977        let selection = select(&manifest(), "cargo build", &suppressed, &profile, &client).await?;
978        assert_eq!(selection.selected_ids, vec!["mem-1"]);
979        assert_eq!(selection.coverage, Coverage::NeedDeeperSweep);
980        Ok(())
981    }
982
983    #[tokio::test]
984    async fn select_repairs_malformed_json_once() -> Result<(), Box<dyn std::error::Error>> {
985        let client = ScriptedLlm::new(vec![
986            "Sure! Here is my selection, hope it helps",
987            r#"{"selected_ids": ["mem-3"], "coverage": "sufficient"}"#,
988        ]);
989        let profile = SelectorProfile::embedded_default();
990        let selection = select(
991            &manifest(),
992            "where is my passport",
993            &HashSet::new(),
994            &profile,
995            &client,
996        )
997        .await?;
998        assert_eq!(selection.selected_ids, vec!["mem-3"]);
999        let prompts = client.prompts();
1000        assert_eq!(prompts.len(), 2, "exactly one repair round-trip");
1001        assert!(prompts[1].contains("ONLY the corrected JSON object"));
1002        Ok(())
1003    }
1004
1005    #[tokio::test]
1006    async fn select_errors_after_failed_repair() {
1007        let client = ScriptedLlm::new(vec!["not json", "still not json"]);
1008        let profile = SelectorProfile::embedded_default();
1009        let result = select(&manifest(), "turn", &HashSet::new(), &profile, &client).await;
1010        assert!(matches!(result, Err(SelectorError::Parse(_))), "{result:?}");
1011        assert_eq!(client.prompts().len(), 2);
1012    }
1013
1014    #[tokio::test]
1015    async fn select_tolerates_fenced_json() -> Result<(), Box<dyn std::error::Error>> {
1016        let client = ScriptedLlm::new(vec![
1017            "```json\n{\"selected_ids\": [\"mem-1\"], \"coverage\": \"sufficient\"}\n```",
1018        ]);
1019        let profile = SelectorProfile::embedded_default();
1020        let selection = select(
1021            &manifest(),
1022            "cargo check",
1023            &HashSet::new(),
1024            &profile,
1025            &client,
1026        )
1027        .await?;
1028        assert_eq!(selection.selected_ids, vec!["mem-1"]);
1029        assert_eq!(client.prompts().len(), 1, "fenced JSON needs no repair");
1030        Ok(())
1031    }
1032
1033    #[tokio::test]
1034    async fn prompt_renders_manifest_suppression_and_turn() -> Result<(), Box<dyn std::error::Error>>
1035    {
1036        let client = ScriptedLlm::new(vec![r#"{"selected_ids": [], "coverage": "sufficient"}"#]);
1037        let profile = SelectorProfile::embedded_default();
1038        let suppressed: HashSet<String> = ["mem-3".to_string()].into();
1039        select(
1040            &manifest(),
1041            "the incoming turn text",
1042            &suppressed,
1043            &profile,
1044            &client,
1045        )
1046        .await?;
1047        let prompt = &client.prompts()[0];
1048        assert!(prompt.contains("- mem-1 [gotcha, saved 3 days ago, rank 1] Cargo wrapper"));
1049        assert!(prompt.contains("— When running cargo commands"), "{prompt}");
1050        assert!(
1051            prompt.contains("- mem-3\n") || prompt.ends_with("- mem-3"),
1052            "{prompt}"
1053        );
1054        assert!(prompt.contains("the incoming turn text"));
1055        assert!(!prompt.contains("{{manifest}}"));
1056        assert!(!prompt.contains("{{turn_text}}"));
1057        assert!(!prompt.contains("{{suppression_list}}"));
1058        Ok(())
1059    }
1060
1061    #[tokio::test]
1062    async fn shuffle_changes_manifest_order_across_calls() -> Result<(), Box<dyn std::error::Error>>
1063    {
1064        // 12 records have 12! orderings; 24 shuffles landing identical is
1065        // vanishingly unlikely, so a stuck shuffle fails deterministically.
1066        let manifest: Vec<RecordMeta> = (0..12)
1067            .map(|i| meta(&format!("mem-{i}"), &format!("Title {i}"), ""))
1068            .collect();
1069        let replies = vec![r#"{"selected_ids": [], "coverage": "sufficient"}"#; 24];
1070        let client = ScriptedLlm::new(replies);
1071        let profile = SelectorProfile::embedded_default();
1072        for _ in 0..24 {
1073            select(&manifest, "turn", &HashSet::new(), &profile, &client).await?;
1074        }
1075        let prompts = client.prompts();
1076        let orders: HashSet<String> = prompts
1077            .iter()
1078            .map(|prompt| {
1079                prompt
1080                    .lines()
1081                    .filter(|line| line.starts_with("- mem-"))
1082                    .collect::<Vec<_>>()
1083                    .join("|")
1084            })
1085            .collect();
1086        assert!(
1087            orders.len() > 1,
1088            "manifest order must vary across calls (§8.3 bias guard)"
1089        );
1090        Ok(())
1091    }
1092
1093    #[test]
1094    fn embedded_prompt_matches_calibration_bundle() -> Result<(), Box<dyn std::error::Error>> {
1095        // The crate-local embed and the memory-evals calibration artifact
1096        // must stay byte-identical; skip when the evals tree is absent
1097        // (published crate builds).
1098        let bundle =
1099            Path::new(env!("CARGO_MANIFEST_DIR")).join("../memory-evals/prompts/selector-v0.md");
1100        if !bundle.is_file() {
1101            return Ok(());
1102        }
1103        let text = std::fs::read_to_string(bundle)?;
1104        assert_eq!(
1105            text, EMBEDDED_PROMPT_V0,
1106            "memory-evals/prompts/selector-v0.md and \
1107             src/memory/selector_prompt_v0.md have drifted"
1108        );
1109        Ok(())
1110    }
1111
1112    #[test]
1113    fn embedded_default_profile_validates_and_names_a_catalog_model() {
1114        let profile = SelectorProfile::embedded_default();
1115        profile.validate().expect("embedded profile must validate");
1116        assert_eq!(
1117            meerkat_models::infer_provider(&profile.model),
1118            Some(profile.provider),
1119            "embedded default model must resolve in the catalog"
1120        );
1121    }
1122
1123    #[test]
1124    fn external_profile_loads_from_evals_layout() -> Result<(), Box<dyn std::error::Error>> {
1125        let path =
1126            Path::new(env!("CARGO_MANIFEST_DIR")).join("../memory-evals/profiles/selector-v0.toml");
1127        if !path.is_file() {
1128            return Ok(());
1129        }
1130        let profile = SelectorProfile::load(&path)?;
1131        assert_eq!(profile.stage, "selector");
1132        assert_eq!(profile.model, SelectorProfile::embedded_default().model);
1133        assert_eq!(profile.prompt_template, EMBEDDED_PROMPT_V0);
1134        assert_eq!(profile.params.working_set_k, 200);
1135        Ok(())
1136    }
1137
1138    #[test]
1139    fn spec_parse_is_fail_loud() {
1140        assert_eq!(parse_spec("off").unwrap(), SelectorSpec::Off);
1141        assert_eq!(parse_spec("").unwrap(), SelectorSpec::Off);
1142        assert_eq!(parse_spec("default").unwrap(), SelectorSpec::Default);
1143        assert_eq!(
1144            parse_spec("profile:/tmp/p.toml").unwrap(),
1145            SelectorSpec::Profile(PathBuf::from("/tmp/p.toml"))
1146        );
1147        assert!(parse_spec("lexical").is_err());
1148        assert!(parse_spec("profile:").is_err());
1149    }
1150
1151    #[test]
1152    fn hard_ceiling_truncation_drops_oldest_unranked_and_names_them() {
1153        let record = |id: &str, rank: Option<u32>, age_days: u64| RecordMeta {
1154            id: id.to_string(),
1155            kind: MemoryKind::Fact,
1156            title: "t".to_string(),
1157            description: String::new(),
1158            age_days,
1159            rank,
1160        };
1161        // Below the ceiling: untouched, order preserved, nothing dropped.
1162        let manifest = vec![record("a", None, 90), record("b", Some(2), 1)];
1163        let (kept, dropped) = truncate_full_manifest(manifest.clone(), 2);
1164        assert_eq!(
1165            kept.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
1166            vec!["a", "b"]
1167        );
1168        assert!(dropped.is_empty());
1169
1170        // Above the ceiling: ranked survive (rank ascending), then unranked
1171        // newest-first; the oldest unranked records are the ones dropped —
1172        // and they are named.
1173        let manifest = vec![
1174            record("old-unranked", None, 90),
1175            record("rank-2", Some(2), 50),
1176            record("new-unranked", None, 0),
1177            record("rank-1", Some(1), 70),
1178            record("mid-unranked", None, 30),
1179        ];
1180        let (kept, dropped) = truncate_full_manifest(manifest, 3);
1181        assert_eq!(
1182            kept.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
1183            vec!["rank-1", "rank-2", "new-unranked"]
1184        );
1185        assert_eq!(dropped, vec!["mid-unranked", "old-unranked"]);
1186    }
1187
1188    #[test]
1189    fn chunking_respects_byte_ceiling_and_covers_everything() {
1190        let big_description = "d".repeat(390);
1191        let manifest: Vec<RecordMeta> = (0..600)
1192            .map(|i| meta(&format!("mem-{i}"), "Title", &big_description))
1193            .collect();
1194        let chunks = chunk_manifest(&manifest);
1195        assert!(chunks.len() > 1, "600 fat rows must not fit one chunk");
1196        let total: usize = chunks.iter().map(|chunk| chunk.len()).sum();
1197        assert_eq!(total, manifest.len(), "chunking must not drop rows");
1198        for chunk in &chunks {
1199            let bytes: usize = chunk
1200                .iter()
1201                .map(|meta| render_manifest_row(meta).len() + 1)
1202                .sum();
1203            assert!(
1204                bytes <= FULL_SWEEP_CHUNK_BYTES,
1205                "chunk exceeds the §8.3 per-call ceiling: {bytes}"
1206            );
1207        }
1208    }
1209}