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` — 7 tools, the new v0.6.4 default (v0.7 B1 added
28//! `memory_load_family`; v0.7 B2 added `memory_smart_load`).
29//! Always loaded.
30//! - `graph` — adds the 11 KG/entity/replay/verify/find_paths tools. ~18 tools.
31//! - `admin` — adds lifecycle (6) + governance (8). ~21 tools.
32//! - `power` — adds the 8 LLM-augmented + operator tools (consolidate,
33//! auto_tag, …, plus the v0.7 K7 subscription-reliability pair).
34//! ~15 tools.
35//! - `full` — every family. **74 advertised entries at v0.7.0**
36//! (73 callable "memory tools" + the always-on `memory_capabilities`
37//! bootstrap; `Profile::full().expected_tool_count()` is the
38//! canonical assertion).
39//! - `custom` — comma-separated family list (`core,graph,archive` …).
40//! `core` is implicitly added if missing — there's no profile that
41//! ships *less than* the 7 core tools at v0.7.0 (the original 5 +
42//! `memory_load_family` + `memory_smart_load`).
43//!
44//! ## Custom-profile parsing edge cases
45//!
46//! Documented in this RFC + pinned by unit tests:
47//!
48//! - empty string → `Profile::core()` (default)
49//! - `core,core` → dedupe silently
50//! - `core,xyz` → `ProfileParseError::UnknownFamily("xyz")` listing
51//! every valid family name
52//! - mixed-case (`Core`) → `ProfileParseError::CaseMismatch`. Profiles
53//! are case-sensitive lowercase. Rejecting mixed case prevents
54//! `Profile` vs `profile` config-file divergence from creating two
55//! different surfaces in production.
56//! - whitespace-only token (`core, ,graph`) → silently skipped
57//! - `core,full` → `Profile::full()` (full subsumes everything; not an
58//! error)
59//! - duplicates across the named-then-custom path (`full,core`) → also
60//! resolves to full.
61
62use std::str::FromStr;
63
64/// A tool family. Source-anchored at `crate::mcp::registry::tool_definitions()`
65/// 2026-05-05. Counts must sum to 51 (the v0.6.3.1 baseline of 43 +
66/// v0.7.0 I4 `memory_replay` + v0.7 H4 `memory_verify` (both in
67/// `Family::Graph`) + v0.7 B1 `memory_load_family` and v0.7 B2
68/// `memory_smart_load` in `Family::Core` +
69/// v0.7 K7 `memory_subscription_replay` and `memory_subscription_dlq_list`
70/// in `Family::Power` + v0.7 J7 `memory_find_paths` in `Family::Graph` +
71/// v0.7 K8 `memory_quota_status` in `Family::Power`).
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
73pub enum Family {
74 /// store, recall, list, get, search, load_family, smart_load — 7
75 /// (load_family added in v0.7 B1 — always-on family loader that
76 /// returns the top-k recent + high-priority memories whose
77 /// `metadata.family` matches one of the eight enum names;
78 /// smart_load added in v0.7 B2 — intent-routed front door that
79 /// picks the best Family from a free-text intent and forwards to
80 /// `memory_load_family`.)
81 Core,
82 /// update, delete, forget, gc, promote — 5
83 Lifecycle,
84 /// kg_query, kg_timeline, kg_invalidate, link, get_links,
85 /// entity_register, entity_get_by_alias, get_taxonomy, replay,
86 /// verify, find_paths — 11 (replay added in v0.7.0 I4 — joins to the I2
87 /// transcript-link substrate to reconstruct a memory's source
88 /// transcript chain; verify added in v0.7 H4 — re-checks the
89 /// Ed25519 signature on a stored memory_links row.)
90 Graph,
91 /// pending_list/approve/reject, namespace_set/get/clear_standard,
92 /// subscribe, unsubscribe — 8
93 Governance,
94 /// consolidate, detect_contradiction, check_duplicate, auto_tag,
95 /// expand_query, inbox, subscription_replay, subscription_dlq_list,
96 /// quota_status — 9 (v0.7 K7 added the two
97 /// operator/governance subscription-reliability tools — replay
98 /// events from the audit log + inspect the DLQ; v0.7 K8 added
99 /// `memory_quota_status` for the per-agent rate-limit + storage-cap
100 /// substrate.)
101 Power,
102 /// capabilities, agent_register, agent_list, session_start, stats — 5
103 Meta,
104 /// archive_list, archive_purge, archive_restore, archive_stats — 4
105 Archive,
106 /// list_subscriptions, notify — 2
107 Other,
108}
109
110/// Tool names that are loaded in every profile, regardless of which
111/// families it includes. v0.6.4 reserves `memory_capabilities` as the
112/// always-on bootstrap so the runtime-discovery dance works out of the
113/// box on `--profile core`. Per RFC S27 and the v0.6.4-002 acceptance
114/// criteria.
115///
116/// v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep): the
117/// literal references the canonical const so this slice cannot drift
118/// from the dispatch table.
119///
120/// DOC-7 (med/low review batch) — semantic note: this is a sentinel
121/// list with `len == 1` at v0.7.0 (only `memory_capabilities`). The
122/// `&[…]` slice shape is intentionally extensible: future profile
123/// work may promote additional bootstrap-class tools (e.g.
124/// `memory_load_family`, `memory_smart_load`) into the always-on set,
125/// at which point [`Profile::core`] would need to subtract them from
126/// the per-family count to avoid double-counting. Today the family
127/// counts treat `memory_load_family` and `memory_smart_load` as core
128/// family members, NOT as always-on; promotion would require updating
129/// `Profile::expected_tool_count` arithmetic accordingly.
130pub const ALWAYS_ON_TOOLS: &[&str] = &[crate::mcp::registry::tool_names::MEMORY_CAPABILITIES];
131
132impl Family {
133 /// Lookup the family that owns a given tool name. Source-anchored
134 /// at `crate::mcp::registry::tool_definitions()` 2026-05-04. Every name listed
135 /// in the v0.6.3.1 baseline is covered; `None` means the tool is
136 /// either unknown to this enumeration or moved out of bounds (which
137 /// should make `tool_definitions_returns_43_tools` red and force a
138 /// reconciliation).
139 #[must_use]
140 pub fn for_tool(name: &str) -> Option<Self> {
141 // v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep) — every
142 // match arm references a const from
143 // [`crate::mcp::registry::tool_names`] so the family routing
144 // table and the dispatch table cannot drift in name spelling.
145 use crate::mcp::registry::tool_names as tn;
146 match name {
147 // core (7 — v0.7 B1 added memory_load_family as the always-on
148 // alternative to memory_recall when the agent already knows
149 // which family taxonomy it wants; v0.7 B2 added
150 // memory_smart_load as the intent-routed front door that
151 // picks the best family for the caller).
152 tn::MEMORY_STORE | tn::MEMORY_RECALL | tn::MEMORY_LIST | tn::MEMORY_GET
153 | tn::MEMORY_SEARCH | tn::MEMORY_LOAD_FAMILY | tn::MEMORY_SMART_LOAD => {
154 Some(Self::Core)
155 }
156 // lifecycle (6 — v0.7.0 #1389 L4 added memory_capture_turn, the
157 // host-volunteered idempotent turn-capture substrate primitive).
158 tn::MEMORY_UPDATE | tn::MEMORY_DELETE | tn::MEMORY_FORGET | tn::MEMORY_GC
159 | tn::MEMORY_PROMOTE | tn::MEMORY_CAPTURE_TURN => Some(Self::Lifecycle),
160 // graph (11 — v0.7.0 I4 added memory_replay; v0.7 H4 added memory_verify;
161 // v0.7 J7 added memory_find_paths)
162 tn::MEMORY_KG_QUERY
163 | tn::MEMORY_KG_TIMELINE
164 | tn::MEMORY_KG_INVALIDATE
165 | tn::MEMORY_LINK
166 | tn::MEMORY_GET_LINKS
167 | tn::MEMORY_ENTITY_REGISTER
168 | tn::MEMORY_ENTITY_GET_BY_ALIAS
169 | tn::MEMORY_GET_TAXONOMY
170 | tn::MEMORY_REPLAY
171 | tn::MEMORY_VERIFY
172 | tn::MEMORY_FIND_PATHS => Some(Self::Graph),
173 // governance (8)
174 tn::MEMORY_PENDING_LIST
175 | tn::MEMORY_PENDING_APPROVE
176 | tn::MEMORY_PENDING_REJECT
177 | tn::MEMORY_NAMESPACE_SET_STANDARD
178 | tn::MEMORY_NAMESPACE_GET_STANDARD
179 | tn::MEMORY_NAMESPACE_CLEAR_STANDARD
180 | tn::MEMORY_SUBSCRIBE
181 | tn::MEMORY_UNSUBSCRIBE => Some(Self::Governance),
182 // power (23 — the actual count; v0.7 K7 added the
183 // subscription-reliability pair (`memory_subscription_replay`
184 // + `memory_subscription_dlq_list`); v0.7 K8 added
185 // `memory_quota_status`; v0.7.0 Task 4/8 added
186 // `memory_reflect`; v0.7.0 L2-2/L2-3 added
187 // `memory_reflection_origin` + `memory_dependents_of_invalidated`;
188 // v0.7.0 QW-1 added `memory_export_reflection`; v0.7.0 QW-2
189 // added `memory_persona` + `memory_persona_generate`; v0.7.0
190 // QW-3 follow-up added `memory_offload` + `memory_deref`;
191 // v0.7.0 WT-1-C added `memory_atomise`; v0.7.0 Form 3 added
192 // `memory_ingest_multistep`; v0.7.0 Form 5 added
193 // `memory_calibrate_confidence`; v0.7.0 #691 added
194 // `memory_check_agent_action` + `memory_rule_list`; v0.7.0
195 // #224/#311 added `memory_share`. All operator/governance,
196 // not data-plane. Pinned by `Family::expected_tool_count`
197 // (derived from this slice) and the
198 // `family_tool_names_cover_registry_all` test.
199 tn::MEMORY_CONSOLIDATE
200 | tn::MEMORY_DETECT_CONTRADICTION
201 | tn::MEMORY_CHECK_DUPLICATE
202 | tn::MEMORY_AUTO_TAG
203 | tn::MEMORY_EXPAND_QUERY
204 | tn::MEMORY_INBOX
205 | tn::MEMORY_SUBSCRIPTION_REPLAY
206 | tn::MEMORY_SUBSCRIPTION_DLQ_LIST
207 | tn::MEMORY_QUOTA_STATUS
208 | tn::MEMORY_REFLECT
209 | tn::MEMORY_REFLECTION_ORIGIN
210 // v0.7.0 QW-1 — file-backed reflection chain export.
211 // Operator-facing; substrate returns rendered content,
212 // agent harness owns the disk write.
213 | tn::MEMORY_EXPORT_REFLECTION
214 // v0.7.0 QW-2 — Persona-as-artifact. The read-only
215 // `memory_persona` lookup and the write-side
216 // `memory_persona_generate` regeneration both sit under
217 // Power. Tier-gating in the MCP dispatcher refuses the
218 // write-side surface unless smart+autonomous is enabled.
219 | tn::MEMORY_PERSONA
220 | tn::MEMORY_PERSONA_GENERATE
221 // v0.7.0 Form 5 (issue #758) — calibration sweep over the
222 // shadow-mode observation table. Operator-callable
223 // equivalent of `ai-memory calibrate confidence
224 // --from-shadow`. Lives in Power alongside the other
225 // operator-facing observability tools.
226 | tn::MEMORY_CALIBRATE_CONFIDENCE
227 // v0.7.0 L2-3 (issue #668) — read-side surface for the
228 // reflection invalidation propagation walker. Operator-
229 // facing inspector for the per-reflection dependent set
230 // that gets notified on Reflection→Reflection supersedes.
231 | tn::MEMORY_DEPENDENTS_OF_INVALIDATED
232 // v0.7.0 (issue #691) — substrate-level agent-action rules
233 // engine. Both tools live in Family::Power (governance /
234 // operator-facing, not data-plane). Mutation tools are
235 // explicitly NOT registered over MCP per design revision
236 // 2026-05-13 — operator uses CLI / HTTP with signed key.
237 | tn::MEMORY_CHECK_AGENT_ACTION
238 | tn::MEMORY_RULE_LIST
239 // v0.7.0 QW-3 follow-up — context-offload substrate primitive.
240 // The pair lives in Family::Power so the `power` (and `full`)
241 // profile surfaces them while keeping the keyword-tier
242 // `core` surface unchanged (semantic-tier+ exposure per the
243 // QW-3 brief).
244 | tn::MEMORY_OFFLOAD
245 | tn::MEMORY_DEREF
246 // v0.7.0 WT-1-C — curator-pass atomisation tool. Lives in
247 // the same family/profile group as memory_consolidate and
248 // memory_reflect (semantic+ tier; the keyword tier short-
249 // circuits with a tier-locked advisory envelope).
250 | tn::MEMORY_ATOMISE
251 // v0.7.0 Form 3 (issue #756) — multi-step ingest
252 // orchestrator. Lives at Family::Power alongside the other
253 // LLM-driven write-side tools; tier-gated to smart+ with
254 // the standard tier-locked advisory on keyword.
255 | tn::MEMORY_INGEST_MULTISTEP
256 // v0.7.0 (issues #224 + #311) — Phase 3 Memory Sharing &
257 // Sync RFC pulled forward per operator directive
258 // `28860423-d12c-4959-bc8b-8fa9a94a33d9`. Substrate-level
259 // point-to-point copy into `_shared/<from>→<to>/`.
260 | tn::MEMORY_SHARE => Some(Self::Power),
261 // meta (6 — 5 baseline + v0.7.0 Gap 3 (#886)
262 // memory_recall_observations).
263 tn::MEMORY_CAPABILITIES
264 | tn::MEMORY_AGENT_REGISTER
265 | tn::MEMORY_AGENT_LIST
266 | tn::MEMORY_SESSION_START
267 | tn::MEMORY_STATS
268 | tn::MEMORY_RECALL_OBSERVATIONS => Some(Self::Meta),
269 // archive (4)
270 tn::MEMORY_ARCHIVE_LIST
271 | tn::MEMORY_ARCHIVE_PURGE
272 | tn::MEMORY_ARCHIVE_RESTORE
273 | tn::MEMORY_ARCHIVE_STATS => Some(Self::Archive),
274 // other (9 — 2 baseline + v0.7.0 L1-5 5 skill tools +
275 // v0.7.0 L2-6 memory_skill_promote_from_reflection (#671) +
276 // v0.7.0 L2-7 memory_skill_compositional_context (#672))
277 tn::MEMORY_LIST_SUBSCRIPTIONS
278 | tn::MEMORY_NOTIFY
279 | tn::MEMORY_SKILL_REGISTER
280 | tn::MEMORY_SKILL_LIST
281 | tn::MEMORY_SKILL_GET
282 | tn::MEMORY_SKILL_RESOURCE
283 | tn::MEMORY_SKILL_EXPORT
284 | tn::MEMORY_SKILL_PROMOTE_FROM_REFLECTION
285 | tn::MEMORY_SKILL_COMPOSITIONAL_CONTEXT => Some(Self::Other),
286 _ => None,
287 }
288 }
289
290 /// Lowercase canonical name as used in CLI/env/config.
291 #[must_use]
292 pub const fn name(self) -> &'static str {
293 match self {
294 Self::Core => "core",
295 Self::Lifecycle => "lifecycle",
296 Self::Graph => "graph",
297 Self::Governance => crate::models::field_names::GOVERNANCE,
298 Self::Power => "power",
299 Self::Meta => "meta",
300 Self::Archive => "archive",
301 Self::Other => "other",
302 }
303 }
304
305 /// All eight families in declaration order. Useful for `--profile full`
306 /// and for the `ProfileParseError::UnknownFamily` diagnostic.
307 #[must_use]
308 pub const fn all() -> &'static [Family] {
309 &[
310 Self::Core,
311 Self::Lifecycle,
312 Self::Graph,
313 Self::Governance,
314 Self::Power,
315 Self::Meta,
316 Self::Archive,
317 Self::Other,
318 ]
319 }
320
321 /// Number of MCP tools advertised by this family.
322 ///
323 /// Derived from [`Family::tool_names`] — that slice is the single
324 /// source of truth for both the names AND the count. Adding a tool
325 /// to a family is therefore exactly one edit (append a `tn::*`
326 /// entry to the slice arm); every count that depends on it —
327 /// per-profile expectations, the full-profile total, the registry
328 /// lockstep — recomputes automatically. There are NO hand-maintained
329 /// per-family magic numbers here by design (the historical
330 /// `match self { Core => 7, … }` form drifted whenever a tool landed
331 /// without the matching count bump).
332 #[must_use]
333 pub const fn expected_tool_count(self) -> usize {
334 self.tool_names().len()
335 }
336
337 /// v0.7.0 A2 — tool names belonging to this family. Forward of the
338 /// `Family::for_tool` reverse map; source-anchored at
339 /// `crate::mcp::registry::tool_definitions()` 2026-05-04 (same anchor as
340 /// [`Family::for_tool`] and [`Family::expected_tool_count`]).
341 /// Order is the order each tool appears in
342 /// `tool_definitions_for_profile`'s registration walk, so an
343 /// LLM-facing preview ("the first three tools loaded") aligns with
344 /// the actual `tools/list` output.
345 ///
346 /// This slice is the single source of truth for the family's tool
347 /// set. [`Family::expected_tool_count`] derives its return value
348 /// from `self.tool_names().len()`, and the
349 /// `family_tool_names_cover_registry_all` unit test pins the union
350 /// of all families against the canonical registry set.
351 #[must_use]
352 pub const fn tool_names(self) -> &'static [&'static str] {
353 // v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep) — every
354 // entry references a `pub const` from
355 // [`crate::mcp::registry::tool_names`] so the per-family lists,
356 // the dispatch table, and the registry iterator cannot drift
357 // in name spelling.
358 use crate::mcp::registry::tool_names as tn;
359 match self {
360 Self::Core => &[
361 tn::MEMORY_STORE,
362 tn::MEMORY_RECALL,
363 tn::MEMORY_LIST,
364 tn::MEMORY_GET,
365 tn::MEMORY_SEARCH,
366 // v0.7 B1 — always-on alternative to memory_recall when
367 // the agent already knows the Family taxonomy it wants.
368 tn::MEMORY_LOAD_FAMILY,
369 // v0.7 B2 — intent-routed front door. Caller passes a
370 // free-text intent; the handler picks the best family
371 // from the cached descriptors and forwards to
372 // `memory_load_family`.
373 tn::MEMORY_SMART_LOAD,
374 ],
375 Self::Lifecycle => &[
376 tn::MEMORY_UPDATE,
377 tn::MEMORY_DELETE,
378 tn::MEMORY_FORGET,
379 tn::MEMORY_GC,
380 tn::MEMORY_PROMOTE,
381 // v0.7.0 #1389 L4 — host-volunteered idempotent turn
382 // capture (RFC-0001). One memory row + one
383 // transcript_line_dedup row per host turn.
384 tn::MEMORY_CAPTURE_TURN,
385 ],
386 Self::Graph => &[
387 tn::MEMORY_KG_QUERY,
388 tn::MEMORY_KG_TIMELINE,
389 tn::MEMORY_KG_INVALIDATE,
390 tn::MEMORY_LINK,
391 tn::MEMORY_GET_LINKS,
392 tn::MEMORY_ENTITY_REGISTER,
393 tn::MEMORY_ENTITY_GET_BY_ALIAS,
394 tn::MEMORY_GET_TAXONOMY,
395 // v0.7.0 I4 — traverses memory_transcript_links (I2) to
396 // reconstruct the source-transcript chain for a memory.
397 tn::MEMORY_REPLAY,
398 // v0.7 H4 — re-verifies a stored link's Ed25519
399 // signature on demand, returning attest_level.
400 tn::MEMORY_VERIFY,
401 // v0.7 J7 — enumerate up to N paths between two memories
402 // (BFS with cycle detection over memory_links).
403 tn::MEMORY_FIND_PATHS,
404 ],
405 Self::Governance => &[
406 tn::MEMORY_PENDING_LIST,
407 tn::MEMORY_PENDING_APPROVE,
408 tn::MEMORY_PENDING_REJECT,
409 tn::MEMORY_NAMESPACE_SET_STANDARD,
410 tn::MEMORY_NAMESPACE_GET_STANDARD,
411 tn::MEMORY_NAMESPACE_CLEAR_STANDARD,
412 tn::MEMORY_SUBSCRIBE,
413 tn::MEMORY_UNSUBSCRIBE,
414 ],
415 Self::Power => &[
416 tn::MEMORY_CONSOLIDATE,
417 tn::MEMORY_DETECT_CONTRADICTION,
418 tn::MEMORY_CHECK_DUPLICATE,
419 tn::MEMORY_AUTO_TAG,
420 tn::MEMORY_EXPAND_QUERY,
421 tn::MEMORY_INBOX,
422 // v0.7 K7 — operator/governance subscription-reliability
423 // tools. Replay reads back the audit row series for one
424 // subscription since an RFC3339 cursor; dlq_list inspects
425 // payloads that exhausted the [200ms, 1s, 5s] retry ladder.
426 tn::MEMORY_SUBSCRIPTION_REPLAY,
427 tn::MEMORY_SUBSCRIPTION_DLQ_LIST,
428 // v0.7 K8 — per-agent quota status (memories/day, storage
429 // bytes, links/day). Operator-facing inspector for the K8
430 // rate-limit substrate.
431 tn::MEMORY_QUOTA_STATUS,
432 // v0.7.0 Task 4/8 (recursive learning, issue #655) —
433 // substrate-native reflection primitive. Inserts a
434 // reflection memory plus N `reflects_on` provenance
435 // links in a single atomic transaction.
436 tn::MEMORY_REFLECT,
437 // v0.7.0 L2-2 (S6-M1) — cross-peer reflection origin
438 // inspector. Returns peer_origin / signing_agent /
439 // original_depth / local_depth_at_arrival for a row.
440 tn::MEMORY_REFLECTION_ORIGIN,
441 // v0.7.0 L2-3 (issue #668) — invalidation propagation
442 // read-side inspector. Lists dependents flagged by
443 // the walker on Reflection→Reflection supersedes.
444 tn::MEMORY_DEPENDENTS_OF_INVALIDATED,
445 // v0.7.0 (issue #691) — substrate-level agent-action
446 // rules engine. Read-side surface; mutation tools are
447 // NOT registered over MCP (operator uses CLI / HTTP).
448 tn::MEMORY_CHECK_AGENT_ACTION,
449 tn::MEMORY_RULE_LIST,
450 // v0.7.0 QW-1 — file-backed reflection chain export
451 // companion. Renders the markdown / JSON envelope;
452 // does NOT write to disk (agent harness owns disk I/O).
453 tn::MEMORY_EXPORT_REFLECTION,
454 // v0.7.0 QW-3 follow-up — context-offload substrate
455 // primitive (offload + deref). Power-family registration
456 // gives semantic-tier+ exposure per the QW-3 brief; the
457 // handlers themselves live at src/mcp/tools/offload.rs.
458 tn::MEMORY_OFFLOAD,
459 tn::MEMORY_DEREF,
460 // v0.7.0 WT-1-C — curator-pass atomisation tool.
461 // Decomposes a coarse memory into 2-10 atomic
462 // propositions; archives the source. Lives in Power
463 // alongside memory_consolidate / memory_reflect.
464 tn::MEMORY_ATOMISE,
465 // v0.7.0 QW-2 — Persona-as-artifact. Read-only lookup
466 // + smart+ regeneration. Substrate writes the SQL row
467 // (and optionally the filesystem export via namespace
468 // policy); the agent never holds the keypair.
469 tn::MEMORY_PERSONA,
470 tn::MEMORY_PERSONA_GENERATE,
471 // v0.7.0 Form 3 (issue #756) — multi-step ingest
472 // orchestrator. Deterministic helpers + LLM stages
473 // with explicit-trust slots and prompt-cache reuse.
474 tn::MEMORY_INGEST_MULTISTEP,
475 // v0.7.0 Form 5 (issue #758) — calibration sweep over
476 // the shadow-mode observation table. Operator surface
477 // for tuning per-(namespace, source) confidence
478 // baselines.
479 tn::MEMORY_CALIBRATE_CONFIDENCE,
480 // v0.7.0 (issues #224 + #311) — Phase 3 Memory Sharing &
481 // Sync RFC pulled forward per operator directive
482 // `28860423-d12c-4959-bc8b-8fa9a94a33d9`. Substrate-
483 // level point-to-point copy into `_shared/<from>→<to>/`.
484 tn::MEMORY_SHARE,
485 ],
486 Self::Meta => &[
487 tn::MEMORY_CAPABILITIES,
488 tn::MEMORY_AGENT_REGISTER,
489 tn::MEMORY_AGENT_LIST,
490 tn::MEMORY_SESSION_START,
491 tn::MEMORY_STATS,
492 // v0.7.0 Gap 3 (#886) — read-side surface for the
493 // `recall_observations` ledger.
494 tn::MEMORY_RECALL_OBSERVATIONS,
495 ],
496 Self::Archive => &[
497 tn::MEMORY_ARCHIVE_LIST,
498 tn::MEMORY_ARCHIVE_PURGE,
499 tn::MEMORY_ARCHIVE_RESTORE,
500 tn::MEMORY_ARCHIVE_STATS,
501 ],
502 Self::Other => &[
503 tn::MEMORY_LIST_SUBSCRIPTIONS,
504 tn::MEMORY_NOTIFY,
505 // v0.7.0 L1-5 — Agent Skills ingestion substrate (Pillar 1.5).
506 tn::MEMORY_SKILL_REGISTER,
507 tn::MEMORY_SKILL_LIST,
508 tn::MEMORY_SKILL_GET,
509 tn::MEMORY_SKILL_RESOURCE,
510 tn::MEMORY_SKILL_EXPORT,
511 // v0.7.0 L2-6 (issue #671) — closing the recursive-learning loop.
512 tn::MEMORY_SKILL_PROMOTE_FROM_REFLECTION,
513 // v0.7.0 L2-7 (issue #672) — reflection-skill composition.
514 tn::MEMORY_SKILL_COMPOSITIONAL_CONTEXT,
515 ],
516 }
517 }
518}
519
520impl FromStr for Family {
521 type Err = ProfileParseError;
522 fn from_str(s: &str) -> Result<Self, Self::Err> {
523 // Reject mixed case explicitly. Lowercase form below.
524 if s.chars().any(|c| c.is_ascii_uppercase()) {
525 return Err(ProfileParseError::CaseMismatch(s.to_string()));
526 }
527 match s {
528 "core" => Ok(Self::Core),
529 "lifecycle" => Ok(Self::Lifecycle),
530 "graph" => Ok(Self::Graph),
531 crate::models::field_names::GOVERNANCE => Ok(Self::Governance),
532 "power" => Ok(Self::Power),
533 "meta" => Ok(Self::Meta),
534 "archive" => Ok(Self::Archive),
535 "other" => Ok(Self::Other),
536 unknown => Err(ProfileParseError::UnknownFamily(unknown.to_string())),
537 }
538 }
539}
540
541/// A resolved tool profile — the set of families to register on the
542/// MCP server.
543#[derive(Debug, Clone, PartialEq, Eq, Hash)]
544pub struct Profile {
545 families: Vec<Family>,
546}
547
548impl Profile {
549 /// `core` — 7 tools (`store, recall, list, get, search,
550 /// load_family, smart_load`). The new v0.6.4 default; v0.7 B1
551 /// added `memory_load_family` as the always-on family loader and
552 /// v0.7 B2 added `memory_smart_load` as the intent-routed front
553 /// door. Registers exactly the `Core` family.
554 ///
555 /// **Design note (v0.6.4-002 hook):** `memory_capabilities` is
556 /// **always-on** regardless of profile per RFC scenario S27. It is
557 /// NOT in this family list because the registration filter
558 /// (v0.6.4-002) injects it as a bootstrap tool outside the
559 /// profile-driven path. That keeps the "core profile = 7 tools at
560 /// v0.7.0" claim accurate (5 original + memory_load_family +
561 /// memory_smart_load) while still making the runtime-discovery
562 /// dance reachable. Cross-check with
563 /// `Profile::core().expected_tool_count()`.
564 #[must_use]
565 pub fn core() -> Self {
566 Self {
567 families: vec![Family::Core],
568 }
569 }
570
571 /// `graph` — core + graph. 18 tools (v0.7.0 I4 added `memory_replay`;
572 /// v0.7 H4 added `memory_verify`; v0.7 B1 added `memory_load_family`
573 /// to core; v0.7 B2 added `memory_smart_load` to core; v0.7 J7
574 /// added `memory_find_paths`).
575 #[must_use]
576 pub fn graph() -> Self {
577 Self {
578 families: vec![Family::Core, Family::Graph],
579 }
580 }
581
582 /// `admin` — core + lifecycle + governance. 21 tools
583 /// (core 7 + lifecycle 6 + governance 8; v0.7 B1 added
584 /// `memory_load_family` to core; v0.7 B2 added
585 /// `memory_smart_load` to core; #1389 L4 added
586 /// `memory_capture_turn` to lifecycle, 20 → 21).
587 #[must_use]
588 pub fn admin() -> Self {
589 Self {
590 families: vec![Family::Core, Family::Lifecycle, Family::Governance],
591 }
592 }
593
594 /// `power` — core + power. 30 tools (core 7 + power 23; v0.7 B1
595 /// added `memory_load_family` to core; v0.7 B2 added `memory_smart_load`
596 /// to core; v0.7 K7 added the two subscription-reliability tools
597 /// to `Family::Power`).
598 #[must_use]
599 pub fn power() -> Self {
600 Self {
601 families: vec![Family::Core, Family::Power],
602 }
603 }
604
605 /// `full` — every family. The advertised entry count (callable
606 /// "memory tools" + the always-on `memory_capabilities` bootstrap)
607 /// is whatever `Profile::full().expected_tool_count()` returns —
608 /// that accessor, derived from the per-family `tool_names` slices,
609 /// is the canonical SSOT; no literal is restated here.
610 #[must_use]
611 pub fn full() -> Self {
612 Self {
613 families: Family::all().to_vec(),
614 }
615 }
616
617 /// Family list, sorted in declaration order, deduplicated.
618 #[must_use]
619 pub fn families(&self) -> &[Family] {
620 &self.families
621 }
622
623 /// `true` if this profile would register tools from `family`.
624 #[must_use]
625 pub fn includes(&self, family: Family) -> bool {
626 self.families.contains(&family)
627 }
628
629 /// Sum of expected tool counts. v0.6.4-002 will assert that the
630 /// runtime registration matches.
631 #[must_use]
632 pub fn expected_tool_count(&self) -> usize {
633 self.families.iter().map(|f| f.expected_tool_count()).sum()
634 }
635
636 /// `true` if a tool with this name is loaded under this profile.
637 /// Treats every name in [`ALWAYS_ON_TOOLS`] as loaded regardless of
638 /// the family map (per RFC S27 — `memory_capabilities` is the
639 /// bootstrap tool for runtime discovery).
640 #[must_use]
641 pub fn loads(&self, tool_name: &str) -> bool {
642 if ALWAYS_ON_TOOLS.contains(&tool_name) {
643 return true;
644 }
645 Family::for_tool(tool_name).is_some_and(|f| self.includes(f))
646 }
647
648 /// Parse a profile name. Accepts the named profiles plus
649 /// comma-separated family lists. Empty or whitespace-only input
650 /// resolves to [`Profile::core`]. See module docs for full edge-case
651 /// matrix.
652 ///
653 /// # Errors
654 ///
655 /// - [`ProfileParseError::UnknownFamily`] if a comma-separated
656 /// token is neither a known profile nor a known family.
657 /// - [`ProfileParseError::CaseMismatch`] if any token contains an
658 /// uppercase letter.
659 pub fn parse(s: &str) -> Result<Self, ProfileParseError> {
660 let trimmed = s.trim();
661 if trimmed.is_empty() {
662 return Ok(Self::core());
663 }
664
665 // Reject mixed case at the whole-string level so `Core` doesn't
666 // sneak past as a family (Family::from_str would also catch it,
667 // but the diagnostic is clearer here).
668 if trimmed.chars().any(|c| c.is_ascii_uppercase()) {
669 return Err(ProfileParseError::CaseMismatch(trimmed.to_string()));
670 }
671
672 // Single named profile?
673 match trimmed {
674 "core" => return Ok(Self::core()),
675 "graph" => return Ok(Self::graph()),
676 "admin" => return Ok(Self::admin()),
677 "power" => return Ok(Self::power()),
678 "full" => return Ok(Self::full()),
679 _ => {}
680 }
681
682 // Comma-separated. Could mix profile names and family names.
683 // `core,graph` registers core+meta (from `core`) plus graph
684 // (from the family). `core,full` is full because full subsumes.
685 let mut families = Vec::with_capacity(8);
686 for raw_token in trimmed.split(',') {
687 let token = raw_token.trim();
688 if token.is_empty() {
689 continue;
690 }
691 // Each token is either a profile or a family.
692 match token {
693 "core" => merge(&mut families, Self::core().families()),
694 "graph" => merge(&mut families, Self::graph().families()),
695 "admin" => merge(&mut families, Self::admin().families()),
696 "power" => merge(&mut families, Self::power().families()),
697 "full" => return Ok(Self::full()),
698 _ => {
699 let f = Family::from_str(token)?;
700 if !families.contains(&f) {
701 families.push(f);
702 }
703 }
704 }
705 }
706
707 // Every profile implicitly includes `core` — there is no
708 // legitimate use case for a profile smaller than the 5
709 // core tools.
710 if !families.contains(&Family::Core) {
711 families.insert(0, Family::Core);
712 }
713
714 // Sort into declaration order so two equivalent profile
715 // strings (`graph,core` vs `core,graph`) resolve to the same
716 // value.
717 families.sort_unstable();
718 families.dedup();
719
720 Ok(Self { families })
721 }
722}
723
724impl Default for Profile {
725 fn default() -> Self {
726 Self::core()
727 }
728}
729
730fn merge(dst: &mut Vec<Family>, src: &[Family]) {
731 for f in src {
732 if !dst.contains(f) {
733 dst.push(*f);
734 }
735 }
736}
737
738/// Errors produced by [`Profile::parse`] / [`Family::from_str`].
739#[derive(Debug, Clone, PartialEq, Eq)]
740pub enum ProfileParseError {
741 /// A custom-profile token was neither a known profile nor a family.
742 UnknownFamily(String),
743 /// A token contained an uppercase letter. Profile vocabulary is
744 /// case-sensitive lowercase.
745 CaseMismatch(String),
746}
747
748impl std::fmt::Display for ProfileParseError {
749 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
750 match self {
751 Self::UnknownFamily(name) => {
752 let valid: Vec<&str> = Family::all().iter().map(|f| f.name()).collect();
753 let profiles = "core, graph, admin, power, full";
754 write!(
755 f,
756 "unknown profile or family '{name}'. \
757 Valid profiles: {profiles}. \
758 Valid families: {valid}.",
759 valid = valid.join(", ")
760 )
761 }
762 Self::CaseMismatch(s) => {
763 write!(
764 f,
765 "profile '{s}' contains uppercase letters; \
766 profile vocabulary is case-sensitive lowercase \
767 (e.g. 'core', not 'Core')"
768 )
769 }
770 }
771 }
772}
773
774impl std::error::Error for ProfileParseError {}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779
780 // ---------- Family ----------
781
782 #[test]
783 fn family_all_has_eight_entries() {
784 assert_eq!(Family::all().len(), 8);
785 }
786
787 #[test]
788 fn family_tool_names_cover_registry_all() {
789 // Cross-module SSOT invariant (no magic number): the union of
790 // every family's `tool_names` slice must be exactly the
791 // canonical registry set `tool_names::ALL`. Both sides are
792 // hand-maintained name lists in different modules; pinning
793 // their lengths against each other catches a tool added to one
794 // side but not the other. The aggregate `--profile full` count
795 // is whatever this union holds — it is never asserted as a
796 // literal anywhere.
797 let family_total: usize = Family::all().iter().map(|f| f.tool_names().len()).sum();
798 assert_eq!(
799 family_total,
800 crate::mcp::registry::tool_names::ALL.len(),
801 "per-family tool_names slices must cover exactly the registry ALL set; \
802 a tool was added to one side but not the other"
803 );
804 }
805
806 #[test]
807 fn family_from_str_lowercase_canonical() {
808 assert_eq!(Family::from_str("core").unwrap(), Family::Core);
809 assert_eq!(Family::from_str("meta").unwrap(), Family::Meta);
810 assert_eq!(Family::from_str("graph").unwrap(), Family::Graph);
811 }
812
813 #[test]
814 fn family_from_str_rejects_mixed_case() {
815 assert!(matches!(
816 Family::from_str("Core"),
817 Err(ProfileParseError::CaseMismatch(_))
818 ));
819 assert!(matches!(
820 Family::from_str("CORE"),
821 Err(ProfileParseError::CaseMismatch(_))
822 ));
823 }
824
825 #[test]
826 fn family_from_str_unknown_returns_diagnostic() {
827 let err = Family::from_str("xyz").unwrap_err();
828 match err {
829 ProfileParseError::UnknownFamily(s) => assert_eq!(s, "xyz"),
830 _ => panic!("expected UnknownFamily, got {err:?}"),
831 }
832 }
833
834 // ---------- Profile named ----------
835
836 #[test]
837 fn profile_core_loads_only_core_family() {
838 let p = Profile::core();
839 // Core advertises exactly the Core family's tools — derived
840 // from the SSOT slice, never a literal.
841 assert_eq!(p.expected_tool_count(), Family::Core.tool_names().len());
842 assert!(p.includes(Family::Core));
843 // meta is NOT in core's family list — `memory_capabilities`
844 // is bootstrapped separately as always-on per RFC S27. The
845 // other meta tools (agent_register/list/session_start/stats)
846 // are NOT advertised by the core profile.
847 assert!(!p.includes(Family::Meta));
848 assert!(!p.includes(Family::Lifecycle));
849 }
850
851 #[test]
852 fn profile_graph_loads_core_plus_graph() {
853 let p = Profile::graph();
854 // Graph = Core + Graph families; count derived from the SSOT
855 // slices so the v0.7 surface additions can't drift this test.
856 assert_eq!(
857 p.expected_tool_count(),
858 Family::Core.tool_names().len() + Family::Graph.tool_names().len()
859 );
860 assert!(p.includes(Family::Graph));
861 }
862
863 #[test]
864 fn profile_admin_loads_core_lifecycle_governance() {
865 let p = Profile::admin();
866 // admin = Core + Lifecycle + Governance families; count derived
867 // from the SSOT slices. Graph isn't in admin, and the #1389 L4
868 // memory_capture_turn addition to Lifecycle flows through
869 // automatically rather than needing a literal bump here.
870 assert_eq!(
871 p.expected_tool_count(),
872 Family::Core.tool_names().len()
873 + Family::Lifecycle.tool_names().len()
874 + Family::Governance.tool_names().len()
875 );
876 }
877
878 #[test]
879 fn profile_power_loads_core_plus_power() {
880 let p = Profile::power();
881 // Power = Core + Power families; count derived from the SSOT
882 // slices so every Power-family addition flows through here
883 // without a literal bump.
884 assert_eq!(
885 p.expected_tool_count(),
886 Family::Core.tool_names().len() + Family::Power.tool_names().len()
887 );
888 }
889
890 #[test]
891 fn profile_full_matches_registry_all() {
892 let p = Profile::full();
893 // `--profile full` advertises every family. Its count is the
894 // canonical registry set `tool_names::ALL` — anchored on the
895 // SSOT, never a literal. This is the test that the #1389 L4
896 // memory_capture_turn addition flows through automatically.
897 assert_eq!(
898 p.expected_tool_count(),
899 crate::mcp::registry::tool_names::ALL.len()
900 );
901
902 // The `power` profile is Core + Power; same SSOT derivation.
903 assert_eq!(
904 Profile::power().expected_tool_count(),
905 Family::Core.tool_names().len() + Family::Power.tool_names().len()
906 );
907 }
908
909 // ---------- Profile::parse ----------
910
911 #[test]
912 fn parse_empty_returns_core() {
913 assert_eq!(Profile::parse("").unwrap(), Profile::core());
914 assert_eq!(Profile::parse(" ").unwrap(), Profile::core());
915 }
916
917 #[test]
918 fn parse_named_profiles() {
919 assert_eq!(Profile::parse("core").unwrap(), Profile::core());
920 assert_eq!(Profile::parse("graph").unwrap(), Profile::graph());
921 assert_eq!(Profile::parse("admin").unwrap(), Profile::admin());
922 assert_eq!(Profile::parse("power").unwrap(), Profile::power());
923 assert_eq!(Profile::parse("full").unwrap(), Profile::full());
924 }
925
926 #[test]
927 fn parse_custom_comma_list_dedup() {
928 // `core,graph` → Core + Graph families. Meta is NOT included —
929 // `memory_capabilities` is always-on bootstrapped outside the
930 // family map (v0.6.4-002). Count derived from the SSOT slices.
931 let p = Profile::parse("core,graph").unwrap();
932 assert!(p.includes(Family::Core));
933 assert!(!p.includes(Family::Meta));
934 assert!(p.includes(Family::Graph));
935 assert_eq!(
936 p.expected_tool_count(),
937 Family::Core.tool_names().len() + Family::Graph.tool_names().len()
938 );
939 }
940
941 #[test]
942 fn parse_custom_dedupes_repeated_token() {
943 let p = Profile::parse("core,core").unwrap();
944 assert_eq!(p, Profile::core());
945 }
946
947 #[test]
948 fn parse_custom_with_full_subsumes() {
949 let p = Profile::parse("graph,full").unwrap();
950 assert_eq!(p, Profile::full());
951 }
952
953 #[test]
954 fn parse_custom_implicitly_includes_core() {
955 // Asking for just `archive` should still load core because
956 // there is no legitimate profile smaller than the 7 core tools at v0.7.0.
957 let p = Profile::parse("archive").unwrap();
958 assert!(p.includes(Family::Core));
959 assert!(p.includes(Family::Archive));
960 }
961
962 #[test]
963 fn parse_custom_unknown_family_errors() {
964 let err = Profile::parse("core,xyz").unwrap_err();
965 match err {
966 ProfileParseError::UnknownFamily(s) => assert_eq!(s, "xyz"),
967 _ => panic!("expected UnknownFamily, got {err:?}"),
968 }
969 }
970
971 #[test]
972 fn parse_rejects_mixed_case() {
973 assert!(matches!(
974 Profile::parse("Core"),
975 Err(ProfileParseError::CaseMismatch(_))
976 ));
977 assert!(matches!(
978 Profile::parse("core,Graph"),
979 Err(ProfileParseError::CaseMismatch(_))
980 ));
981 }
982
983 #[test]
984 fn parse_skips_whitespace_only_tokens() {
985 // `core, ,graph` should resolve to graph not error.
986 let p = Profile::parse("core, ,graph").unwrap();
987 assert_eq!(p, Profile::graph());
988 }
989
990 #[test]
991 fn parse_order_independence() {
992 // `graph,core` resolves identically to `core,graph`.
993 let a = Profile::parse("core,graph").unwrap();
994 let b = Profile::parse("graph,core").unwrap();
995 assert_eq!(a, b);
996 }
997
998 #[test]
999 fn parse_diagnostic_error_lists_valid_options() {
1000 let err = Profile::parse("xyz").unwrap_err();
1001 let msg = err.to_string();
1002 // The diagnostic must mention the valid profiles and families
1003 // so a confused operator can self-correct.
1004 assert!(msg.contains("core"));
1005 assert!(msg.contains("graph"));
1006 assert!(msg.contains("full"));
1007 assert!(msg.contains("xyz"));
1008 }
1009
1010 #[test]
1011 fn default_is_core() {
1012 assert_eq!(Profile::default(), Profile::core());
1013 }
1014
1015 // ---------- Tool name → family / loads ----------
1016
1017 #[test]
1018 fn family_for_tool_resolves_every_baseline_name() {
1019 // Source-anchored at crate::mcp::registry::tool_definitions() — if any
1020 // tool here is missing from `for_tool`, the family map is
1021 // out of sync and `--profile <family>` would silently miss it.
1022 let baseline = [
1023 // core
1024 "memory_store",
1025 "memory_recall",
1026 "memory_list",
1027 "memory_get",
1028 "memory_search",
1029 // core (v0.7 B1 addition)
1030 "memory_load_family",
1031 // core (v0.7 B2 addition)
1032 "memory_smart_load",
1033 // lifecycle
1034 "memory_update",
1035 "memory_delete",
1036 "memory_forget",
1037 "memory_gc",
1038 "memory_promote",
1039 // graph
1040 "memory_kg_query",
1041 "memory_kg_timeline",
1042 "memory_kg_invalidate",
1043 "memory_link",
1044 "memory_get_links",
1045 "memory_entity_register",
1046 "memory_entity_get_by_alias",
1047 "memory_get_taxonomy",
1048 // graph (v0.7.0 I4 addition)
1049 "memory_replay",
1050 // graph (v0.7 H4 addition)
1051 "memory_verify",
1052 // graph (v0.7 J7 addition)
1053 "memory_find_paths",
1054 // governance
1055 "memory_pending_list",
1056 "memory_pending_approve",
1057 "memory_pending_reject",
1058 "memory_namespace_set_standard",
1059 "memory_namespace_get_standard",
1060 "memory_namespace_clear_standard",
1061 "memory_subscribe",
1062 "memory_unsubscribe",
1063 // power
1064 "memory_consolidate",
1065 "memory_detect_contradiction",
1066 "memory_check_duplicate",
1067 "memory_auto_tag",
1068 "memory_expand_query",
1069 "memory_inbox",
1070 // power (v0.7 K7 additions — subscription reliability)
1071 "memory_subscription_replay",
1072 "memory_subscription_dlq_list",
1073 // power (v0.7 K8 addition — per-agent quota status)
1074 "memory_quota_status",
1075 // power (v0.7.0 Task 4/8 — substrate-native reflection primitive)
1076 "memory_reflect",
1077 // meta
1078 "memory_capabilities",
1079 "memory_agent_register",
1080 "memory_agent_list",
1081 "memory_session_start",
1082 "memory_stats",
1083 // archive
1084 "memory_archive_list",
1085 "memory_archive_purge",
1086 "memory_archive_restore",
1087 "memory_archive_stats",
1088 // other
1089 "memory_list_subscriptions",
1090 "memory_notify",
1091 // other (v0.7.0 L1-5 — Agent Skills substrate)
1092 "memory_skill_register",
1093 "memory_skill_list",
1094 "memory_skill_get",
1095 "memory_skill_resource",
1096 "memory_skill_export",
1097 // other (v0.7.0 L2-6 — issue #671: reflections become skills)
1098 "memory_skill_promote_from_reflection",
1099 // v0.7.0 L2-7 (issue #672) — reflection-skill composition.
1100 "memory_skill_compositional_context",
1101 // v0.7.0 QW-1 — file-backed reflection chain export (Family::Power).
1102 "memory_export_reflection",
1103 // v0.7.0 QW-3 follow-up — context-offload substrate (Family::Power).
1104 "memory_offload",
1105 "memory_deref",
1106 // v0.7.0 WT-1-C (curator-pass atomisation) — Family::Power.
1107 "memory_atomise",
1108 // v0.7.0 Form 3 (#756) — multi-step ingest orchestrator.
1109 "memory_ingest_multistep",
1110 // v0.7.0 Form 5 (issue #758) — calibration sweep over the
1111 // shadow-mode observation table (Family::Power).
1112 "memory_calibrate_confidence",
1113 // v0.7.0 (issues #224 + #311) — Phase 3 Memory Sharing &
1114 // Sync RFC pulled forward per operator directive
1115 // `28860423-d12c-4959-bc8b-8fa9a94a33d9`.
1116 "memory_share",
1117 ];
1118 assert_eq!(
1119 baseline.len(),
1120 66,
1121 "baseline list = 43 (v0.6.3.1) + 1 (v0.7.0 I4 memory_replay) + \
1122 1 (v0.7 H4 memory_verify) + 1 (v0.7 B1 memory_load_family) + \
1123 1 (v0.7 B2 memory_smart_load) + \
1124 2 (v0.7 K7 memory_subscription_replay + memory_subscription_dlq_list) + \
1125 1 (v0.7 J7 memory_find_paths) + 1 (v0.7 K8 memory_quota_status) + \
1126 1 (v0.7.0 Task 4/8 memory_reflect) + \
1127 5 (v0.7.0 L1-5 skill tools) + \
1128 1 (v0.7.0 L2-6 memory_skill_promote_from_reflection) + \
1129 1 (v0.7.0 L2-7 memory_skill_compositional_context) + \
1130 1 (v0.7.0 QW-1 memory_export_reflection) + \
1131 2 (v0.7.0 QW-3 follow-up memory_offload + memory_deref) + \
1132 1 (v0.7.0 WT-1-C memory_atomise) + \
1133 1 (v0.7.0 Form 3 memory_ingest_multistep) + \
1134 1 (v0.7.0 Form 5 memory_calibrate_confidence) + \
1135 1 (v0.7.0 issues #224 + #311 memory_share) = 66"
1136 );
1137 for name in baseline {
1138 assert!(
1139 Family::for_tool(name).is_some(),
1140 "Family::for_tool({name}) returned None — update the family map"
1141 );
1142 }
1143 }
1144
1145 #[test]
1146 fn family_for_tool_returns_none_for_unknown() {
1147 assert!(Family::for_tool("memory_does_not_exist").is_none());
1148 assert!(Family::for_tool("").is_none());
1149 }
1150
1151 #[test]
1152 fn loads_includes_core_tools_under_core_profile() {
1153 let p = Profile::core();
1154 assert!(p.loads("memory_store"));
1155 assert!(p.loads("memory_recall"));
1156 assert!(!p.loads("memory_kg_query"));
1157 // memory_capabilities is always-on bootstrap.
1158 assert!(p.loads("memory_capabilities"));
1159 }
1160
1161 #[test]
1162 fn loads_full_profile_includes_every_tool() {
1163 let p = Profile::full();
1164 // Every tool in the baseline must load under full.
1165 for name in [
1166 "memory_store",
1167 "memory_kg_query",
1168 "memory_consolidate",
1169 "memory_archive_list",
1170 "memory_notify",
1171 "memory_capabilities",
1172 ] {
1173 assert!(p.loads(name), "full profile should load {name}");
1174 }
1175 }
1176
1177 #[test]
1178 fn loads_unknown_tool_returns_false() {
1179 let p = Profile::full();
1180 assert!(!p.loads("memory_does_not_exist"));
1181 }
1182
1183 #[test]
1184 fn always_on_tools_loaded_in_every_profile() {
1185 for p in [
1186 Profile::core(),
1187 Profile::graph(),
1188 Profile::admin(),
1189 Profile::power(),
1190 Profile::full(),
1191 ] {
1192 for name in ALWAYS_ON_TOOLS {
1193 assert!(p.loads(name), "{name} must load in every profile");
1194 }
1195 }
1196 }
1197}