Skip to main content

ai_memory/
profile.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.6.4-001 — `Profile` resolution for the MCP tool surface.
5//!
6//! A profile is a set of tool *families* (`Family`) that the MCP server
7//! advertises in its `tools/list` response. v0.6.4 collapses the default
8//! surface from 43 tools (full) to 5 (core) so eager-loading harnesses
9//! stop pre-paying ~6,000 input tokens of tool schemas per request. The
10//! 38 tools outside `core` remain reachable via runtime expansion through
11//! `memory_capabilities --include-schema family=<name>` (Track C —
12//! v0.6.4-006), so no functionality is lost; only the eager prefix cost
13//! goes away.
14//!
15//! ## Resolution order
16//!
17//! `CLI flag > AI_MEMORY_PROFILE env > [mcp].profile config > "core"`.
18//!
19//! `clap` natively handles "CLI > env" with `#[arg(env = "...")]`, so
20//! the daemon-runtime side only needs to call
21//! [`AppConfig::effective_profile`] with the resolved CLI/env value
22//! (already merged by clap) plus the config-file value (read by
23//! `serde`).
24//!
25//! ## Profile vocabulary
26//!
27//! - `core` — 5 tools, the new v0.6.4 default. Always loaded.
28//! - `graph` — adds the 8 KG/entity tools. ~13 tools.
29//! - `admin` — adds lifecycle (5) + governance (8). ~18 tools.
30//! - `power` — adds the 6 LLM-augmented tools (consolidate, auto_tag, …).
31//!   ~11 tools.
32//! - `full` — every family. 43 tools, 1:1 v0.6.3 surface.
33//! - `custom` — comma-separated family list (`core,graph,archive` …).
34//!   `core` is implicitly added if missing — there's no profile that
35//!   ships *less than* the 5 core tools.
36//!
37//! ## Custom-profile parsing edge cases
38//!
39//! Documented in this RFC + pinned by unit tests:
40//!
41//! - empty string → `Profile::core()` (default)
42//! - `core,core` → dedupe silently
43//! - `core,xyz` → `ProfileParseError::UnknownFamily("xyz")` listing
44//!   every valid family name
45//! - mixed-case (`Core`) → `ProfileParseError::CaseMismatch`. Profiles
46//!   are case-sensitive lowercase. Rejecting mixed case prevents
47//!   `Profile` vs `profile` config-file divergence from creating two
48//!   different surfaces in production.
49//! - whitespace-only token (`core, ,graph`) → silently skipped
50//! - `core,full` → `Profile::full()` (full subsumes everything; not an
51//!   error)
52//! - duplicates across the named-then-custom path (`full,core`) → also
53//!   resolves to full.
54
55use std::str::FromStr;
56
57/// A tool family. Source-anchored at `src/mcp.rs::tool_definitions()`
58/// 2026-05-04. Counts must sum to 43 (the v0.6.3.1 baseline).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
60pub enum Family {
61    /// store, recall, list, get, search — 5
62    Core,
63    /// update, delete, forget, gc, promote — 5
64    Lifecycle,
65    /// kg_query, kg_timeline, kg_invalidate, link, get_links,
66    /// entity_register, entity_get_by_alias, get_taxonomy — 8
67    Graph,
68    /// pending_list/approve/reject, namespace_set/get/clear_standard,
69    /// subscribe, unsubscribe — 8
70    Governance,
71    /// consolidate, detect_contradiction, check_duplicate, auto_tag,
72    /// expand_query, inbox — 6
73    Power,
74    /// capabilities, agent_register, agent_list, session_start, stats — 5
75    Meta,
76    /// archive_list, archive_purge, archive_restore, archive_stats — 4
77    Archive,
78    /// list_subscriptions, notify — 2
79    Other,
80}
81
82/// Tool names that are loaded in every profile, regardless of which
83/// families it includes. v0.6.4 reserves `memory_capabilities` as the
84/// always-on bootstrap so the runtime-discovery dance works out of the
85/// box on `--profile core`. Per RFC S27 and the v0.6.4-002 acceptance
86/// criteria.
87pub const ALWAYS_ON_TOOLS: &[&str] = &["memory_capabilities"];
88
89impl Family {
90    /// Lookup the family that owns a given tool name. Source-anchored
91    /// at `src/mcp.rs::tool_definitions()` 2026-05-04. Every name listed
92    /// in the v0.6.3.1 baseline is covered; `None` means the tool is
93    /// either unknown to this enumeration or moved out of bounds (which
94    /// should make `tool_definitions_returns_43_tools` red and force a
95    /// reconciliation).
96    #[must_use]
97    pub fn for_tool(name: &str) -> Option<Self> {
98        match name {
99            // core (5)
100            "memory_store" | "memory_recall" | "memory_list" | "memory_get" | "memory_search" => {
101                Some(Self::Core)
102            }
103            // lifecycle (5)
104            "memory_update" | "memory_delete" | "memory_forget" | "memory_gc"
105            | "memory_promote" => Some(Self::Lifecycle),
106            // graph (8)
107            "memory_kg_query"
108            | "memory_kg_timeline"
109            | "memory_kg_invalidate"
110            | "memory_link"
111            | "memory_get_links"
112            | "memory_entity_register"
113            | "memory_entity_get_by_alias"
114            | "memory_get_taxonomy" => Some(Self::Graph),
115            // governance (8)
116            "memory_pending_list"
117            | "memory_pending_approve"
118            | "memory_pending_reject"
119            | "memory_namespace_set_standard"
120            | "memory_namespace_get_standard"
121            | "memory_namespace_clear_standard"
122            | "memory_subscribe"
123            | "memory_unsubscribe" => Some(Self::Governance),
124            // power (6)
125            "memory_consolidate"
126            | "memory_detect_contradiction"
127            | "memory_check_duplicate"
128            | "memory_auto_tag"
129            | "memory_expand_query"
130            | "memory_inbox" => Some(Self::Power),
131            // meta (5)
132            "memory_capabilities"
133            | "memory_agent_register"
134            | "memory_agent_list"
135            | "memory_session_start"
136            | "memory_stats" => Some(Self::Meta),
137            // archive (4)
138            "memory_archive_list"
139            | "memory_archive_purge"
140            | "memory_archive_restore"
141            | "memory_archive_stats" => Some(Self::Archive),
142            // other (2)
143            "memory_list_subscriptions" | "memory_notify" => Some(Self::Other),
144            _ => None,
145        }
146    }
147
148    /// Lowercase canonical name as used in CLI/env/config.
149    #[must_use]
150    pub const fn name(self) -> &'static str {
151        match self {
152            Self::Core => "core",
153            Self::Lifecycle => "lifecycle",
154            Self::Graph => "graph",
155            Self::Governance => "governance",
156            Self::Power => "power",
157            Self::Meta => "meta",
158            Self::Archive => "archive",
159            Self::Other => "other",
160        }
161    }
162
163    /// All eight families in declaration order. Useful for `--profile full`
164    /// and for the `ProfileParseError::UnknownFamily` diagnostic.
165    #[must_use]
166    pub const fn all() -> &'static [Family] {
167        &[
168            Self::Core,
169            Self::Lifecycle,
170            Self::Graph,
171            Self::Governance,
172            Self::Power,
173            Self::Meta,
174            Self::Archive,
175            Self::Other,
176        ]
177    }
178
179    /// Expected tool count for this family. v0.6.4-002 will assert
180    /// that the actual `register_<family>` matches this constant.
181    #[must_use]
182    pub const fn expected_tool_count(self) -> usize {
183        match self {
184            Self::Core | Self::Lifecycle | Self::Meta => 5,
185            Self::Graph | Self::Governance => 8,
186            Self::Power => 6,
187            Self::Archive => 4,
188            Self::Other => 2,
189        }
190    }
191}
192
193impl FromStr for Family {
194    type Err = ProfileParseError;
195    fn from_str(s: &str) -> Result<Self, Self::Err> {
196        // Reject mixed case explicitly. Lowercase form below.
197        if s.chars().any(|c| c.is_ascii_uppercase()) {
198            return Err(ProfileParseError::CaseMismatch(s.to_string()));
199        }
200        match s {
201            "core" => Ok(Self::Core),
202            "lifecycle" => Ok(Self::Lifecycle),
203            "graph" => Ok(Self::Graph),
204            "governance" => Ok(Self::Governance),
205            "power" => Ok(Self::Power),
206            "meta" => Ok(Self::Meta),
207            "archive" => Ok(Self::Archive),
208            "other" => Ok(Self::Other),
209            unknown => Err(ProfileParseError::UnknownFamily(unknown.to_string())),
210        }
211    }
212}
213
214/// A resolved tool profile — the set of families to register on the
215/// MCP server.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct Profile {
218    families: Vec<Family>,
219}
220
221impl Profile {
222    /// `core` — 5 tools (`store, recall, list, get, search`). The new
223    /// v0.6.4 default. Registers exactly the `Core` family.
224    ///
225    /// **Design note (v0.6.4-002 hook):** `memory_capabilities` is
226    /// **always-on** regardless of profile per RFC scenario S27. It is
227    /// NOT in this family list because the registration filter
228    /// (v0.6.4-002) injects it as a bootstrap tool outside the
229    /// profile-driven path. That keeps the "core profile = 5 tools"
230    /// claim accurate while still making the runtime-discovery dance
231    /// reachable.
232    #[must_use]
233    pub fn core() -> Self {
234        Self {
235            families: vec![Family::Core],
236        }
237    }
238
239    /// `graph` — core + graph. 13 tools.
240    #[must_use]
241    pub fn graph() -> Self {
242        Self {
243            families: vec![Family::Core, Family::Graph],
244        }
245    }
246
247    /// `admin` — core + lifecycle + governance. 18 tools.
248    #[must_use]
249    pub fn admin() -> Self {
250        Self {
251            families: vec![Family::Core, Family::Lifecycle, Family::Governance],
252        }
253    }
254
255    /// `power` — core + power. 11 tools.
256    #[must_use]
257    pub fn power() -> Self {
258        Self {
259            families: vec![Family::Core, Family::Power],
260        }
261    }
262
263    /// `full` — every family. 43 tools, 1:1 v0.6.3 surface.
264    #[must_use]
265    pub fn full() -> Self {
266        Self {
267            families: Family::all().to_vec(),
268        }
269    }
270
271    /// Family list, sorted in declaration order, deduplicated.
272    #[must_use]
273    pub fn families(&self) -> &[Family] {
274        &self.families
275    }
276
277    /// `true` if this profile would register tools from `family`.
278    #[must_use]
279    pub fn includes(&self, family: Family) -> bool {
280        self.families.contains(&family)
281    }
282
283    /// Sum of expected tool counts. v0.6.4-002 will assert that the
284    /// runtime registration matches.
285    #[must_use]
286    pub fn expected_tool_count(&self) -> usize {
287        self.families.iter().map(|f| f.expected_tool_count()).sum()
288    }
289
290    /// `true` if a tool with this name is loaded under this profile.
291    /// Treats every name in [`ALWAYS_ON_TOOLS`] as loaded regardless of
292    /// the family map (per RFC S27 — `memory_capabilities` is the
293    /// bootstrap tool for runtime discovery).
294    #[must_use]
295    pub fn loads(&self, tool_name: &str) -> bool {
296        if ALWAYS_ON_TOOLS.contains(&tool_name) {
297            return true;
298        }
299        Family::for_tool(tool_name).is_some_and(|f| self.includes(f))
300    }
301
302    /// Parse a profile name. Accepts the named profiles plus
303    /// comma-separated family lists. Empty or whitespace-only input
304    /// resolves to [`Profile::core`]. See module docs for full edge-case
305    /// matrix.
306    ///
307    /// # Errors
308    ///
309    /// - [`ProfileParseError::UnknownFamily`] if a comma-separated
310    ///   token is neither a known profile nor a known family.
311    /// - [`ProfileParseError::CaseMismatch`] if any token contains an
312    ///   uppercase letter.
313    pub fn parse(s: &str) -> Result<Self, ProfileParseError> {
314        let trimmed = s.trim();
315        if trimmed.is_empty() {
316            return Ok(Self::core());
317        }
318
319        // Reject mixed case at the whole-string level so `Core` doesn't
320        // sneak past as a family (Family::from_str would also catch it,
321        // but the diagnostic is clearer here).
322        if trimmed.chars().any(|c| c.is_ascii_uppercase()) {
323            return Err(ProfileParseError::CaseMismatch(trimmed.to_string()));
324        }
325
326        // Single named profile?
327        match trimmed {
328            "core" => return Ok(Self::core()),
329            "graph" => return Ok(Self::graph()),
330            "admin" => return Ok(Self::admin()),
331            "power" => return Ok(Self::power()),
332            "full" => return Ok(Self::full()),
333            _ => {}
334        }
335
336        // Comma-separated. Could mix profile names and family names.
337        // `core,graph` registers core+meta (from `core`) plus graph
338        // (from the family). `core,full` is full because full subsumes.
339        let mut families = Vec::with_capacity(8);
340        for raw_token in trimmed.split(',') {
341            let token = raw_token.trim();
342            if token.is_empty() {
343                continue;
344            }
345            // Each token is either a profile or a family.
346            match token {
347                "core" => merge(&mut families, Self::core().families()),
348                "graph" => merge(&mut families, Self::graph().families()),
349                "admin" => merge(&mut families, Self::admin().families()),
350                "power" => merge(&mut families, Self::power().families()),
351                "full" => return Ok(Self::full()),
352                _ => {
353                    let f = Family::from_str(token)?;
354                    if !families.contains(&f) {
355                        families.push(f);
356                    }
357                }
358            }
359        }
360
361        // Every profile implicitly includes `core` — there is no
362        // legitimate use case for a profile smaller than the 5
363        // core tools.
364        if !families.contains(&Family::Core) {
365            families.insert(0, Family::Core);
366        }
367
368        // Sort into declaration order so two equivalent profile
369        // strings (`graph,core` vs `core,graph`) resolve to the same
370        // value.
371        families.sort_unstable();
372        families.dedup();
373
374        Ok(Self { families })
375    }
376}
377
378impl Default for Profile {
379    fn default() -> Self {
380        Self::core()
381    }
382}
383
384fn merge(dst: &mut Vec<Family>, src: &[Family]) {
385    for f in src {
386        if !dst.contains(f) {
387            dst.push(*f);
388        }
389    }
390}
391
392/// Errors produced by [`Profile::parse`] / [`Family::from_str`].
393#[derive(Debug, Clone, PartialEq, Eq)]
394pub enum ProfileParseError {
395    /// A custom-profile token was neither a known profile nor a family.
396    UnknownFamily(String),
397    /// A token contained an uppercase letter. Profile vocabulary is
398    /// case-sensitive lowercase.
399    CaseMismatch(String),
400}
401
402impl std::fmt::Display for ProfileParseError {
403    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404        match self {
405            Self::UnknownFamily(name) => {
406                let valid: Vec<&str> = Family::all().iter().map(|f| f.name()).collect();
407                let profiles = "core, graph, admin, power, full";
408                write!(
409                    f,
410                    "unknown profile or family '{name}'. \
411                     Valid profiles: {profiles}. \
412                     Valid families: {valid}.",
413                    valid = valid.join(", ")
414                )
415            }
416            Self::CaseMismatch(s) => {
417                write!(
418                    f,
419                    "profile '{s}' contains uppercase letters; \
420                     profile vocabulary is case-sensitive lowercase \
421                     (e.g. 'core', not 'Core')"
422                )
423            }
424        }
425    }
426}
427
428impl std::error::Error for ProfileParseError {}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    // ---------- Family ----------
435
436    #[test]
437    fn family_all_has_eight_entries() {
438        assert_eq!(Family::all().len(), 8);
439    }
440
441    #[test]
442    fn family_expected_tool_counts_sum_to_43() {
443        let total: usize = Family::all().iter().map(|f| f.expected_tool_count()).sum();
444        assert_eq!(
445            total, 43,
446            "v0.6.3.1 baseline is 43 tools — if this drifts, update \
447             Family::expected_tool_count and the family map docs together"
448        );
449    }
450
451    #[test]
452    fn family_from_str_lowercase_canonical() {
453        assert_eq!(Family::from_str("core").unwrap(), Family::Core);
454        assert_eq!(Family::from_str("meta").unwrap(), Family::Meta);
455        assert_eq!(Family::from_str("graph").unwrap(), Family::Graph);
456    }
457
458    #[test]
459    fn family_from_str_rejects_mixed_case() {
460        assert!(matches!(
461            Family::from_str("Core"),
462            Err(ProfileParseError::CaseMismatch(_))
463        ));
464        assert!(matches!(
465            Family::from_str("CORE"),
466            Err(ProfileParseError::CaseMismatch(_))
467        ));
468    }
469
470    #[test]
471    fn family_from_str_unknown_returns_diagnostic() {
472        let err = Family::from_str("xyz").unwrap_err();
473        match err {
474            ProfileParseError::UnknownFamily(s) => assert_eq!(s, "xyz"),
475            _ => panic!("expected UnknownFamily, got {err:?}"),
476        }
477    }
478
479    // ---------- Profile named ----------
480
481    #[test]
482    fn profile_core_has_five_tools() {
483        let p = Profile::core();
484        assert_eq!(p.expected_tool_count(), 5);
485        assert!(p.includes(Family::Core));
486        // meta is NOT in core's family list — `memory_capabilities`
487        // is bootstrapped separately as always-on per RFC S27. The
488        // other meta tools (agent_register/list/session_start/stats)
489        // are NOT advertised by the core profile.
490        assert!(!p.includes(Family::Meta));
491        assert!(!p.includes(Family::Lifecycle));
492    }
493
494    #[test]
495    fn profile_graph_has_thirteen_tools() {
496        let p = Profile::graph();
497        assert_eq!(p.expected_tool_count(), 5 + 8);
498        assert!(p.includes(Family::Graph));
499    }
500
501    #[test]
502    fn profile_admin_has_eighteen_tools() {
503        let p = Profile::admin();
504        assert_eq!(p.expected_tool_count(), 5 + 5 + 8);
505    }
506
507    #[test]
508    fn profile_power_has_eleven_tools() {
509        let p = Profile::power();
510        assert_eq!(p.expected_tool_count(), 5 + 6);
511    }
512
513    #[test]
514    fn profile_full_has_forty_three_tools() {
515        let p = Profile::full();
516        assert_eq!(p.expected_tool_count(), 43);
517    }
518
519    // ---------- Profile::parse ----------
520
521    #[test]
522    fn parse_empty_returns_core() {
523        assert_eq!(Profile::parse("").unwrap(), Profile::core());
524        assert_eq!(Profile::parse("   ").unwrap(), Profile::core());
525    }
526
527    #[test]
528    fn parse_named_profiles() {
529        assert_eq!(Profile::parse("core").unwrap(), Profile::core());
530        assert_eq!(Profile::parse("graph").unwrap(), Profile::graph());
531        assert_eq!(Profile::parse("admin").unwrap(), Profile::admin());
532        assert_eq!(Profile::parse("power").unwrap(), Profile::power());
533        assert_eq!(Profile::parse("full").unwrap(), Profile::full());
534    }
535
536    #[test]
537    fn parse_custom_comma_list_dedup() {
538        // `core,graph` → core (5) + graph (8) = 13 tools.
539        // Meta is NOT included — `memory_capabilities` is always-on
540        // bootstrapped outside the family map (v0.6.4-002).
541        let p = Profile::parse("core,graph").unwrap();
542        assert!(p.includes(Family::Core));
543        assert!(!p.includes(Family::Meta));
544        assert!(p.includes(Family::Graph));
545        assert_eq!(p.expected_tool_count(), 13);
546    }
547
548    #[test]
549    fn parse_custom_dedupes_repeated_token() {
550        let p = Profile::parse("core,core").unwrap();
551        assert_eq!(p, Profile::core());
552    }
553
554    #[test]
555    fn parse_custom_with_full_subsumes() {
556        let p = Profile::parse("graph,full").unwrap();
557        assert_eq!(p, Profile::full());
558    }
559
560    #[test]
561    fn parse_custom_implicitly_includes_core() {
562        // Asking for just `archive` should still load core because
563        // there is no legitimate profile smaller than the 5 core tools.
564        let p = Profile::parse("archive").unwrap();
565        assert!(p.includes(Family::Core));
566        assert!(p.includes(Family::Archive));
567    }
568
569    #[test]
570    fn parse_custom_unknown_family_errors() {
571        let err = Profile::parse("core,xyz").unwrap_err();
572        match err {
573            ProfileParseError::UnknownFamily(s) => assert_eq!(s, "xyz"),
574            _ => panic!("expected UnknownFamily, got {err:?}"),
575        }
576    }
577
578    #[test]
579    fn parse_rejects_mixed_case() {
580        assert!(matches!(
581            Profile::parse("Core"),
582            Err(ProfileParseError::CaseMismatch(_))
583        ));
584        assert!(matches!(
585            Profile::parse("core,Graph"),
586            Err(ProfileParseError::CaseMismatch(_))
587        ));
588    }
589
590    #[test]
591    fn parse_skips_whitespace_only_tokens() {
592        // `core, ,graph` should resolve to graph not error.
593        let p = Profile::parse("core, ,graph").unwrap();
594        assert_eq!(p, Profile::graph());
595    }
596
597    #[test]
598    fn parse_order_independence() {
599        // `graph,core` resolves identically to `core,graph`.
600        let a = Profile::parse("core,graph").unwrap();
601        let b = Profile::parse("graph,core").unwrap();
602        assert_eq!(a, b);
603    }
604
605    #[test]
606    fn parse_diagnostic_error_lists_valid_options() {
607        let err = Profile::parse("xyz").unwrap_err();
608        let msg = err.to_string();
609        // The diagnostic must mention the valid profiles and families
610        // so a confused operator can self-correct.
611        assert!(msg.contains("core"));
612        assert!(msg.contains("graph"));
613        assert!(msg.contains("full"));
614        assert!(msg.contains("xyz"));
615    }
616
617    #[test]
618    fn default_is_core() {
619        assert_eq!(Profile::default(), Profile::core());
620    }
621
622    // ---------- Tool name → family / loads ----------
623
624    #[test]
625    fn family_for_tool_resolves_every_baseline_name() {
626        // Source-anchored at src/mcp.rs::tool_definitions() — if any
627        // tool here is missing from `for_tool`, the family map is
628        // out of sync and `--profile <family>` would silently miss it.
629        let baseline = [
630            // core
631            "memory_store",
632            "memory_recall",
633            "memory_list",
634            "memory_get",
635            "memory_search",
636            // lifecycle
637            "memory_update",
638            "memory_delete",
639            "memory_forget",
640            "memory_gc",
641            "memory_promote",
642            // graph
643            "memory_kg_query",
644            "memory_kg_timeline",
645            "memory_kg_invalidate",
646            "memory_link",
647            "memory_get_links",
648            "memory_entity_register",
649            "memory_entity_get_by_alias",
650            "memory_get_taxonomy",
651            // governance
652            "memory_pending_list",
653            "memory_pending_approve",
654            "memory_pending_reject",
655            "memory_namespace_set_standard",
656            "memory_namespace_get_standard",
657            "memory_namespace_clear_standard",
658            "memory_subscribe",
659            "memory_unsubscribe",
660            // power
661            "memory_consolidate",
662            "memory_detect_contradiction",
663            "memory_check_duplicate",
664            "memory_auto_tag",
665            "memory_expand_query",
666            "memory_inbox",
667            // meta
668            "memory_capabilities",
669            "memory_agent_register",
670            "memory_agent_list",
671            "memory_session_start",
672            "memory_stats",
673            // archive
674            "memory_archive_list",
675            "memory_archive_purge",
676            "memory_archive_restore",
677            "memory_archive_stats",
678            // other
679            "memory_list_subscriptions",
680            "memory_notify",
681        ];
682        assert_eq!(baseline.len(), 43, "baseline list itself must be 43");
683        for name in baseline {
684            assert!(
685                Family::for_tool(name).is_some(),
686                "Family::for_tool({name}) returned None — update the family map"
687            );
688        }
689    }
690
691    #[test]
692    fn family_for_tool_returns_none_for_unknown() {
693        assert!(Family::for_tool("memory_does_not_exist").is_none());
694        assert!(Family::for_tool("").is_none());
695    }
696
697    #[test]
698    fn loads_includes_core_tools_under_core_profile() {
699        let p = Profile::core();
700        assert!(p.loads("memory_store"));
701        assert!(p.loads("memory_recall"));
702        assert!(!p.loads("memory_kg_query"));
703        // memory_capabilities is always-on bootstrap.
704        assert!(p.loads("memory_capabilities"));
705    }
706
707    #[test]
708    fn loads_full_profile_includes_every_tool() {
709        let p = Profile::full();
710        // Every tool in the baseline must load under full.
711        for name in [
712            "memory_store",
713            "memory_kg_query",
714            "memory_consolidate",
715            "memory_archive_list",
716            "memory_notify",
717            "memory_capabilities",
718        ] {
719            assert!(p.loads(name), "full profile should load {name}");
720        }
721    }
722
723    #[test]
724    fn loads_unknown_tool_returns_false() {
725        let p = Profile::full();
726        assert!(!p.loads("memory_does_not_exist"));
727    }
728
729    #[test]
730    fn always_on_tools_loaded_in_every_profile() {
731        for p in [
732            Profile::core(),
733            Profile::graph(),
734            Profile::admin(),
735            Profile::power(),
736            Profile::full(),
737        ] {
738            for name in ALWAYS_ON_TOOLS {
739                assert!(p.loads(name), "{name} must load in every profile");
740            }
741        }
742    }
743}