Skip to main content

kmp_application/memory/
service.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::Arc;
3
4use kmp_domain::{
5    AuthorNodeCard, BundleNode, BundleRelationship, ContextEventStore, DimensionScopeMode,
6    DimensionSelection, DimensionSelectionMode, EntryLabels, GraphNeighborhoodReader, KmpBundle,
7    KmpMode, LabelSelector, MemoryAboutIndexReader, MemoryDimensionIdentity, MemoryRelationType,
8    NodeCard, NodeCardRejection, NodeCardStore, NodeDetailReader, NodeRelationshipReader,
9    ProjectionWriter, ResolutionTier, SnapshotStore, TemporalCoordinate, TemporalMemoryTraversal,
10    TemporalTraversalRequest, labels_by_entry,
11};
12
13use crate::ApplicationError;
14use crate::commands::CommandApplicationService;
15use crate::memory::{
16    AskMemoryQuery, ExistingMemoryRefs, InspectMemoryQuery, InspectMemoryResult, InspectedEvidence,
17    MemoryIngestCommand, MemoryIngestOutcome, MemoryRelabelCommand, MemoryRelabelOutcome,
18    RelateMemoryQuery, TemporalMemoryQuery, TemporalMemoryResult, TraceMemoryQuery, VisualLabel,
19    VisualProjectionQuery, VisualProjectionResult, WakeMemoryQuery, build_visual_projection,
20    crosses_abouts, relabel_logical_digest, replayed_relabel_outcome, translate_memory_ingest,
21    translate_memory_relabel, validate_ref_token, validate_supplied_entry_ref,
22    validate_supplied_member_ref,
23};
24use crate::queries::{
25    ContextRenderOptions, EndpointHint, GetContextPathQuery, GetContextPathResult, GetContextQuery,
26    GetContextResult, GetNodeDetailQuery, GetNodeRelationshipsQuery,
27    MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH, QueryApplicationService, render_graph_bundle_with_options,
28};
29
30const MEMORY_EXISTING_REFS_LOOKUP_DEPTH: u32 = 1;
31
32#[path = "recall_read.rs"]
33mod recall_read;
34#[path = "temporal_catalogue.rs"]
35mod temporal_catalogue;
36#[path = "temporal_index_cache.rs"]
37mod temporal_index_cache;
38#[path = "temporal_index_identity.rs"]
39mod temporal_index_identity;
40#[path = "temporal_index_read.rs"]
41mod temporal_index_read;
42#[path = "temporal_support_read.rs"]
43mod temporal_support_read;
44#[path = "visual_projection_cache.rs"]
45mod visual_projection_cache;
46#[path = "visual_projection_identity.rs"]
47mod visual_projection_identity;
48#[path = "visual_projection_retention.rs"]
49mod visual_projection_retention;
50
51pub struct KernelMemoryApplicationService<G, D, S, E, W> {
52    query_application: Arc<QueryApplicationService<G, D, S>>,
53    command_application: Arc<CommandApplicationService<E, W>>,
54    /// The derived card view, when this kernel has one. Mounted the way read
55    /// snapshots are, as one object-safe port, so a backend that stores no
56    /// cards composes without carrying a generic for a table it never reads.
57    node_cards: Option<Arc<dyn NodeCardStore>>,
58    temporal_index_cache: Arc<std::sync::Mutex<temporal_index_cache::TemporalIndexCache>>,
59    visual_projection_cache: Arc<std::sync::Mutex<visual_projection_cache::VisualProjectionCache>>,
60}
61
62impl<G, D, S, E, W> KernelMemoryApplicationService<G, D, S, E, W> {
63    pub fn new(
64        query_application: Arc<QueryApplicationService<G, D, S>>,
65        command_application: Arc<CommandApplicationService<E, W>>,
66    ) -> Self {
67        Self {
68            query_application,
69            command_application,
70            node_cards: None,
71            temporal_index_cache: Arc::new(std::sync::Mutex::new(Default::default())),
72            visual_projection_cache: Arc::new(std::sync::Mutex::new(Default::default())),
73        }
74    }
75
76    /// Mounts the store that holds reader-authored cards. Without it,
77    /// condensing is refused by name rather than answered with silence.
78    pub fn with_node_cards(mut self, node_cards: Arc<dyn NodeCardStore>) -> Self {
79        self.node_cards = Some(node_cards);
80        self
81    }
82
83    /// Authors or refreshes one node's compact card.
84    ///
85    /// The outer result is this service failing; the inner one is the card
86    /// policy refusing a write the store could have performed. A caller that
87    /// collapses them turns "your card is out of date" into "memory is down".
88    pub async fn condense(
89        &self,
90        command: AuthorNodeCard,
91    ) -> Result<Result<NodeCard, NodeCardRejection>, ApplicationError> {
92        let Some(cards) = &self.node_cards else {
93            return Err(ApplicationError::Validation(
94                "this kernel serves no reader-authored cards; kmp_condense needs a store with \
95                 the card view mounted"
96                    .into(),
97            ));
98        };
99        Ok(cards.author_node_card(command).await?)
100    }
101}
102
103impl<G, D, S, E, W> KernelMemoryApplicationService<G, D, S, E, W>
104where
105    G: GraphNeighborhoodReader + MemoryAboutIndexReader + NodeRelationshipReader + Send + Sync,
106    D: NodeDetailReader + Send + Sync,
107    S: SnapshotStore + Send + Sync,
108    E: ContextEventStore + Send + Sync,
109    W: ProjectionWriter + Send + Sync,
110{
111    pub async fn ingest(
112        &self,
113        command: MemoryIngestCommand,
114    ) -> Result<MemoryIngestOutcome, ApplicationError> {
115        let reviewing = super::write_neighborhood::requires_review(&command);
116        // An attachment has no entries of its own: its endpoint admission
117        // depends on stored refs, including for fallback links that need no
118        // semantic review. Bind that read to the atomic commit as well.
119        let consistent_read = reviewing || command.memory.entries.is_empty();
120        let read_guard = if consistent_read {
121            Some(self.command_application.projection_read().await)
122        } else {
123            None
124        };
125        // SQLite publishes events and projections together. Sampling each
126        // about before and after reading rejects a mixed-version neighborhood
127        // even when another process commits between the individual queries.
128        let mut revisions = BTreeMap::new();
129        if consistent_read {
130            revisions.insert(
131                command.about.clone(),
132                self.command_application
133                    .memory_revision(&command.about)
134                    .await?,
135            );
136        }
137        let mut bundles = self
138            .existing_memory_bundle(&command.about)
139            .await?
140            .into_iter()
141            .collect::<Vec<_>>();
142        let mut existing = bundles
143            .first()
144            .map(existing_refs_from_bundle)
145            .unwrap_or_default();
146        // A relation that declares an equivalence across abouts must land
147        // on a ref that exists somewhere; the translation refuses it unless
148        // this read found it, and reads nothing for any other relation.
149        for relation in &command.memory.relations {
150            let Ok(relation_type) = MemoryRelationType::new(&relation.rel) else {
151                continue;
152            };
153            let target_ref = relation.target_ref.trim().to_string();
154            if !crosses_abouts(&command.about, relation, &relation_type, &target_ref) {
155                continue;
156            }
157            match self
158                .query_application
159                .get_node_detail(GetNodeDetailQuery {
160                    node_id: target_ref.clone(),
161                })
162                .await
163            {
164                Ok(detail) => {
165                    if consistent_read {
166                        let owner =
167                            detail.node.properties.get("memory_about").ok_or_else(|| {
168                                ApplicationError::Validation(
169                                    "foreign endpoint lacks memory ownership".into(),
170                                )
171                            })?;
172                        if !bundles
173                            .iter()
174                            .any(|bundle| bundle.root_node_id().as_str() == owner)
175                        {
176                            revisions.insert(
177                                owner.clone(),
178                                self.command_application.memory_revision(owner).await?,
179                            );
180                            if let Some(bundle) = self.existing_memory_bundle(owner).await? {
181                                bundles.push(bundle);
182                            }
183                        }
184                    }
185                    existing.foreign.insert(target_ref);
186                }
187                Err(ApplicationError::NotFound(_)) => {}
188                Err(error) => return Err(error),
189            }
190        }
191        let (update_context, mut outcome) = translate_memory_ingest(&command, &existing)?;
192        if reviewing
193            && self
194                .command_application
195                .accepted_outcome(&command.idempotency_key)
196                .await?
197                .is_none()
198        {
199            let neighborhood = super::write_neighborhood::build_neighborhood(&command, &bundles);
200            for about in &neighborhood.abouts {
201                if revisions.get(about).copied()
202                    != Some(self.command_application.memory_revision(about).await?)
203                {
204                    return Err(ApplicationError::RetryableConflict(
205                        "write neighborhood changed during read; retry the same logical write to refresh it".into(),
206                    ));
207                }
208            }
209            if command.neighborhood_review.as_deref() != Some(neighborhood.token.as_str()) {
210                outcome.neighborhood = Some(neighborhood);
211                outcome.receipt_ref = None;
212                outcome.clocks = None;
213                outcome.accepted = super::MemoryAcceptedCounts {
214                    entries: 0,
215                    relations: 0,
216                    evidence: 0,
217                };
218                outcome.created_dimensions.clear();
219                return Ok(outcome);
220            }
221        }
222        drop(read_guard);
223        if command.dry_run {
224            outcome.receipt_ref = None;
225            // No ingestion clock was committed by a preview.
226            outcome.clocks = None;
227            outcome
228                .warnings
229                .push("dry_run=true; validated memory without writing to the kernel".to_string());
230            return Ok(outcome);
231        }
232
233        let accepted = self
234            .command_application
235            .update_context_after_read(update_context, &revisions)
236            .await?;
237        outcome.read_after_write_ready = true;
238        outcome.replayed = accepted.replayed;
239        if accepted.replayed {
240            // The new translation has a new ingestion time. Only the accepted
241            // receipt can describe the clocks of the original logical write.
242            outcome.clocks = accepted.replayed_receipt.and_then(|receipt| {
243                serde_json::from_str::<serde_json::Value>(&receipt.payload_json)
244                    .ok()
245                    .and_then(|body| serde_json::from_value(body["clocks"].clone()).ok())
246            });
247        }
248        outcome.warnings.extend(accepted.warnings);
249        Ok(outcome)
250    }
251
252    /// Changes the labels one entry stands in without rewriting its text.
253    /// Read like a write: the about's catalogue and the entry's coordinates
254    /// come from the store, the translation refuses what only the caller
255    /// can fix, and one `memory_relabel` change goes to the log.
256    pub async fn relabel(
257        &self,
258        command: MemoryRelabelCommand,
259    ) -> Result<MemoryRelabelOutcome, ApplicationError> {
260        let existing = self.existing_memory_refs(&command.about).await?;
261        let current = self
262            .entry_coordinates(&command.about, &command.ref_id, &existing)
263            .await?;
264        // A relabel translated after its own first apply refuses the labels
265        // that apply put there, so an accepted key is answered before the
266        // translation, the way ingest's replay is answered after it.
267        if !command.idempotency_key.trim().is_empty()
268            && let Some(accepted) = self
269                .command_application
270                .accepted_outcome(&command.idempotency_key)
271                .await?
272        {
273            if accepted.logical_digest.as_deref() != Some(relabel_logical_digest(&command).as_str())
274            {
275                return Err(ApplicationError::Ports(kmp_domain::PortError::Conflict(
276                    format!(
277                        "idempotency key '{}' was already accepted with different content",
278                        command.idempotency_key
279                    ),
280                )));
281            }
282            return replayed_relabel_outcome(&command, &current);
283        }
284        let (update_context, mut outcome) =
285            translate_memory_relabel(&command, &existing, &current)?;
286        if command.dry_run {
287            outcome.warnings.push(
288                "dry_run=true; validated the relabel against the store without writing to the kernel"
289                    .to_string(),
290            );
291            return Ok(outcome);
292        }
293
294        let accepted = self
295            .command_application
296            .update_context(update_context)
297            .await?;
298        outcome.read_after_write_ready = true;
299        outcome.warnings.extend(accepted.warnings);
300        Ok(outcome)
301    }
302
303    /// The coordinates an entry stands in now, read off its `contains_entry`
304    /// edges — the same reading `kmp_inspect` returns as the raw record.
305    async fn entry_coordinates(
306        &self,
307        about: &str,
308        ref_id: &str,
309        existing: &ExistingMemoryRefs,
310    ) -> Result<Vec<TemporalCoordinate>, ApplicationError> {
311        validate_supplied_entry_ref(about, "ref", ref_id).map_err(ApplicationError::Validation)?;
312        if !existing.refs.contains(ref_id) {
313            return Err(ApplicationError::NotFound(format!(
314                "`{ref_id}` is not a memory of `{about}`"
315            )));
316        }
317        let links = self
318            .query_application
319            .get_node_relationships(GetNodeRelationshipsQuery {
320                node_id: ref_id.to_string(),
321            })
322            .await?;
323        inspect_raw_coordinates(ref_id, Some(&links))
324    }
325
326    /// The abouts the memory currently indexes, for consumers that render an
327    /// index of what can be recalled — a viewer's sidebar, a CLI listing.
328    pub async fn list_abouts(&self) -> Result<Vec<String>, ApplicationError> {
329        self.query_application.list_memory_abouts().await
330    }
331
332    async fn read_snapshot(&self) -> Result<Option<Self>, ApplicationError> {
333        Ok(self.query_application.read_snapshot().await?.map(|query| {
334            let mut snapshot = Self::new(Arc::new(query), Arc::clone(&self.command_application));
335            snapshot.temporal_index_cache = Arc::clone(&self.temporal_index_cache);
336            snapshot.visual_projection_cache = Arc::clone(&self.visual_projection_cache);
337            snapshot
338        }))
339    }
340
341    pub async fn wake(&self, query: WakeMemoryQuery) -> Result<GetContextResult, ApplicationError> {
342        let snapshot = self.read_snapshot().await?;
343        snapshot.as_ref().unwrap_or(self).wake_snapshot(query).await
344    }
345
346    async fn wake_snapshot(
347        &self,
348        query: WakeMemoryQuery,
349    ) -> Result<GetContextResult, ApplicationError> {
350        let render_options = memory_render_options(
351            query.token_budget,
352            query.max_tier,
353            KmpMode::ResumeFocused,
354            EndpointHint::Neighborhood,
355        );
356        let dimensions = query.dimensions.resolve_current_about(&query.about);
357        self.selected_recall_context(
358            &query.about,
359            &query.role,
360            query.depth,
361            &dimensions,
362            &render_options,
363        )
364        .await
365    }
366
367    pub async fn ask(&self, query: AskMemoryQuery) -> Result<GetContextResult, ApplicationError> {
368        let snapshot = self.read_snapshot().await?;
369        snapshot.as_ref().unwrap_or(self).ask_snapshot(query).await
370    }
371
372    async fn ask_snapshot(
373        &self,
374        query: AskMemoryQuery,
375    ) -> Result<GetContextResult, ApplicationError> {
376        let render_options = memory_render_options(
377            query.token_budget,
378            query.max_tier,
379            KmpMode::ReasonPreserving,
380            EndpointHint::Neighborhood,
381        );
382        let dimensions = query.dimensions.resolve_current_about(&query.about);
383        self.selected_recall_context(
384            &query.about,
385            "answerer",
386            query.depth,
387            &dimensions,
388            &render_options,
389        )
390        .await
391    }
392
393    pub async fn temporal(
394        &self,
395        query: TemporalMemoryQuery,
396    ) -> Result<TemporalMemoryResult, ApplicationError> {
397        let snapshot = self.read_snapshot().await?;
398        snapshot
399            .as_ref()
400            .unwrap_or(self)
401            .temporal_snapshot(query)
402            .await
403    }
404
405    async fn temporal_snapshot(
406        &self,
407        query: TemporalMemoryQuery,
408    ) -> Result<TemporalMemoryResult, ApplicationError> {
409        let (index, dimensions) = self.temporal_index_read(&query).await?;
410        let request = temporal_request(&query, &dimensions)?;
411        let traversal = index.traverse(&request)?;
412        let source_bundle = filter_bundle_by_memory_dimensions_with_labels(
413            index.bundle(),
414            &dimensions,
415            &traversal.proof_labels(index.bundle())?,
416        )?;
417        // Source-node metadata (including receipt clocks) can affect a
418        // relation even when its body was not requested. Admit all selected
419        // proof nodes before projecting paths, lifecycle and evidence.
420        let node_refs = kmp_domain::TemporalProofPlan::select(
421            &source_bundle,
422            &traversal,
423            query.include.dependencies,
424        )?
425        .body_refs()
426        .clone();
427        let source_bundle = self
428            .query_application
429            .materialize_selected_nodes(source_bundle, &node_refs)
430            .await?;
431        let source_bundle = self
432            .materialize_temporal_supports(source_bundle, &node_refs)
433            .await?;
434        let mut result = TemporalMemoryResult {
435            traversal,
436            source_bundle,
437            include: query.include,
438        };
439        let selected = if result.include.evidence || result.include.dependencies {
440            kmp_domain::TemporalProofPlan::select(
441                &result.source_bundle,
442                &result.traversal,
443                result.include.dependencies,
444            )?
445            .body_refs()
446            .clone()
447        } else if result.include.raw_refs {
448            result
449                .traversal
450                .entries()
451                .iter()
452                .map(|entry| entry.ref_id().to_string())
453                .collect()
454        } else {
455            BTreeSet::new()
456        };
457        result.source_bundle = self
458            .query_application
459            .materialize_selected_details(result.source_bundle, &selected)
460            .await?;
461        Ok(result)
462    }
463
464    /// The context a temporal read traverses, as the graph returned it:
465    /// the read's dimension filter has not narrowed it yet, so the whole
466    /// catalogue of the about is still in the bundle.
467    async fn temporal_read(
468        &self,
469        query: &TemporalMemoryQuery,
470        include_details: bool,
471    ) -> Result<TemporalRead, ApplicationError> {
472        let dimensions = query.dimensions.resolve_current_about(&query.about);
473        let roots = self.memory_context_roots(&query.about, &dimensions).await?;
474        let scopes = requested_dimension_scopes(&query.about, &dimensions, &roots);
475        let mut bundles = Vec::with_capacity(roots.len());
476        for root in roots {
477            let request = kmp_domain::NeighborhoodRequest::new(
478                root,
479                crate::queries::clamp_native_graph_traversal_depth(query.depth),
480            )
481            .with_scopes(scopes.clone());
482            bundles.push(if include_details {
483                self.query_application
484                    .read_context_bundle(&request, "temporal-reader")
485                    .await?
486            } else {
487                self.query_application
488                    .read_context_catalogue(&request, "temporal-reader")
489                    .await?
490            });
491        }
492        Ok(TemporalRead {
493            bundle: super::merge_memory_bundles::merge(bundles)?,
494            dimensions,
495        })
496    }
497
498    /// A range- and level-of-detail-aware read model for renderers.
499    ///
500    /// This stays on the application facade: storage never gains a viewer
501    /// query and renderers never reach into an engine. The temporal move
502    /// narrows by the selected clock first; bins, clusters and moment entries
503    /// are then projected on a channel whose result is not a model prompt.
504    pub async fn visual_projection(
505        &self,
506        query: VisualProjectionQuery,
507    ) -> Result<VisualProjectionResult, ApplicationError> {
508        let snapshot = self.read_snapshot().await?;
509        snapshot
510            .as_ref()
511            .unwrap_or(self)
512            .visual_projection_snapshot(query)
513            .await
514    }
515
516    async fn visual_projection_snapshot(
517        &self,
518        query: VisualProjectionQuery,
519    ) -> Result<VisualProjectionResult, ApplicationError> {
520        let temporal_query = query.temporal_query()?;
521        let identity = self
522            .query_application
523            .graph_read_revision()
524            .await?
525            .map(
526                |revision| visual_projection_identity::VisualProjectionIdentity {
527                    revision,
528                    query: query.clone(),
529                },
530            );
531        if let Some(identity) = &identity {
532            // Drop the cache lock before cloning the caller's owned result.
533            let cached = self
534                .visual_projection_cache
535                .lock()
536                .ok()
537                .and_then(|mut cache| cache.get(identity));
538            if let Some(cached) = cached {
539                return Ok(cached.as_ref().clone());
540            }
541        }
542        let read = self.temporal_read(&temporal_query, true).await?;
543        // The catalogue is read before the filter: a renderer draws the
544        // about's labels, and says which are empty in this range. Under
545        // `scope_ids` the graph read itself is narrowed to those scopes, so
546        // the catalogue is what that read reached.
547        let catalogue = VisualLabel::catalogue(&read.bundle);
548        let declarations = if query.level_of_detail == super::VisualLevelOfDetail::Moment {
549            super::visual_projection::declared_equivalences(&read.bundle)
550        } else {
551            Vec::new()
552        };
553        let temporal = temporal_result(temporal_query, read)?;
554        let mut projection = build_visual_projection(&query, temporal, catalogue)?;
555        super::visual_projection::include_owned_declarations(&mut projection, declarations);
556        if let Some(identity) = identity
557            && let Ok(mut cache) = self.visual_projection_cache.lock()
558        {
559            cache.put(identity, &projection);
560        }
561        Ok(projection)
562    }
563
564    /// The neighbourhood `relate` reads: the same load as `ask` over the
565    /// abouts the selection names or resolves. Where the facts fall in time,
566    /// and what they have to do with each other, is read from the bundle
567    /// afterwards; the store is asked for nothing it does not hold.
568    pub async fn relate(
569        &self,
570        query: RelateMemoryQuery,
571    ) -> Result<GetContextResult, ApplicationError> {
572        let snapshot = self.read_snapshot().await?;
573        snapshot
574            .as_ref()
575            .unwrap_or(self)
576            .relate_snapshot(query)
577            .await
578    }
579
580    async fn relate_snapshot(
581        &self,
582        query: RelateMemoryQuery,
583    ) -> Result<GetContextResult, ApplicationError> {
584        let render_options = memory_render_options(
585            query.token_budget,
586            query.max_tier,
587            KmpMode::ReasonPreserving,
588            EndpointHint::Neighborhood,
589        );
590        let dimensions = query.dimensions.resolve_current_about(&query.about);
591        let result = self
592            .memory_context(
593                &query.about,
594                "relater",
595                query.depth,
596                &dimensions,
597                &render_options,
598            )
599            .await?;
600        apply_dimension_selection(result, &dimensions, &render_options)
601    }
602
603    /// Library-level seed discovery. The MCP transport does not expose this
604    /// internal role policy until its progressive agent surface is designed.
605    pub async fn evidence_paths(
606        &self,
607        request: kmp_domain::EvidencePathRequest,
608    ) -> Result<kmp_domain::EvidencePathResult, ApplicationError> {
609        request
610            .validate()
611            .map_err(|e| ApplicationError::Validation(e.to_string()))?;
612        validate_supplied_entry_ref(&request.about, "evidence seed", &request.from)
613            .map_err(ApplicationError::Validation)?;
614        if let Some(kmp_domain::TemporalCursor::Ref(reference)) = request.temporal.cursor() {
615            validate_supplied_entry_ref(&request.about, "as_of.ref", reference)
616                .map_err(ApplicationError::Validation)?;
617        }
618        self.query_application.evidence_paths(&request).await
619    }
620
621    pub async fn trace_search(
622        &self,
623        request: kmp_domain::TraceSearchRequest,
624    ) -> Result<kmp_domain::TraceSearchResult, ApplicationError> {
625        request
626            .validate()
627            .map_err(|e| ApplicationError::Validation(e.to_string()))?;
628        for reference in std::iter::once(&request.from).chain(request.targets.iter()) {
629            validate_supplied_entry_ref(&request.about, "trace search ref", reference)
630                .map_err(ApplicationError::Validation)?;
631        }
632        if let Some(kmp_domain::TemporalCursor::Ref(reference)) = request.temporal.cursor() {
633            validate_supplied_entry_ref(&request.about, "as_of.ref", reference)
634                .map_err(ApplicationError::Validation)?;
635        }
636        self.query_application.trace_search(&request).await
637    }
638
639    pub async fn trace(
640        &self,
641        query: TraceMemoryQuery,
642    ) -> Result<GetContextPathResult, ApplicationError> {
643        let snapshot = self.read_snapshot().await?;
644        snapshot
645            .as_ref()
646            .unwrap_or(self)
647            .trace_snapshot(query)
648            .await
649    }
650
651    async fn trace_snapshot(
652        &self,
653        query: TraceMemoryQuery,
654    ) -> Result<GetContextPathResult, ApplicationError> {
655        self.validate_read_members(
656            &query.about,
657            &[("from", query.from.as_str()), ("to", query.to.as_str())],
658        )
659        .await?;
660        self.query_application
661            .get_context_path(GetContextPathQuery {
662                root_node_id: query.from,
663                target_node_id: query.to,
664                role: query.role,
665                subtree_depth: Some(0),
666                render_options: ContextRenderOptions {
667                    focus_node_id: None,
668                    token_budget: (query.token_budget > 0).then_some(query.token_budget),
669                    max_tier: Some(ResolutionTier::L2EvidencePack),
670                    rehydration_mode: KmpMode::ReasonPreserving,
671                    endpoint_hint: EndpointHint::FocusedPath,
672                },
673            })
674            .await
675    }
676
677    /// Resolve the selected headers and coordinates together without loading proof bodies.
678    pub async fn read_nodes(
679        &self,
680        request: kmp_domain::MemoryNodesRequest,
681    ) -> Result<kmp_domain::MemoryNodesResult, ApplicationError> {
682        request.validate()?;
683        self.query_application.read_nodes(&request).await
684    }
685
686    pub async fn inspect(
687        &self,
688        query: InspectMemoryQuery,
689    ) -> Result<InspectMemoryResult, ApplicationError> {
690        let snapshot = self.read_snapshot().await?;
691        snapshot
692            .as_ref()
693            .unwrap_or(self)
694            .inspect_snapshot(query)
695            .await
696    }
697
698    async fn inspect_snapshot(
699        &self,
700        query: InspectMemoryQuery,
701    ) -> Result<InspectMemoryResult, ApplicationError> {
702        if query.ref_id.starts_with("receipt:") {
703            if query.expect_revision.is_some() {
704                return Err(ApplicationError::Validation(
705                    "a receipt is immutable command detail and carries no body revision; \
706                     drop expect to inspect one"
707                        .into(),
708                ));
709            }
710            let reference = kmp_domain::MemoryReceiptRef::parse(&query.ref_id)
711                .ok_or_else(|| ApplicationError::Validation("invalid receipt ref".into()))?;
712            if reference.about() != query.about {
713                return Err(ApplicationError::Validation(
714                    "receipt ref belongs to another about".into(),
715                ));
716            }
717            let accepted = self
718                .command_application
719                .accepted_outcome(reference.idempotency_key())
720                .await?
721                .ok_or_else(|| {
722                    ApplicationError::NotFound(format!("receipt not found: {}", query.ref_id))
723                })?;
724            return super::receipt::inspect_receipt(query, accepted);
725        }
726        self.validate_read_members(&query.about, &[("ref", query.ref_id.as_str())])
727            .await?;
728        let include_incoming = query.include_incoming;
729        let include_outgoing = query.include_outgoing;
730        let include_details = query.include_details;
731        let detail = self
732            .query_application
733            .get_node_detail(GetNodeDetailQuery {
734                node_id: query.ref_id.clone(),
735            })
736            .await?;
737        // Canonical expansion. The store keeps one body version per node, so
738        // the only promise it can keep is "exactly the revision you declared,
739        // or a conflict naming the one that is here". It never reconstructs
740        // an older body, and it returns no text when it cannot keep it.
741        if let Some(expected) = query.expect_revision {
742            let actual = detail.detail.as_ref().map(|body| body.revision);
743            if actual != Some(expected) {
744                return Err(ApplicationError::Ports(kmp_domain::PortError::Conflict(
745                    match actual {
746                        Some(actual) => format!(
747                            "`{}` is at body revision {actual}, not the declared {expected}; \
748                             this store keeps one body version per entry, so the declared one \
749                             cannot be shown. Inspect without expect to read revision {actual}",
750                            query.ref_id
751                        ),
752                        None => format!(
753                            "`{}` has no stored body, so body revision {expected} cannot be \
754                             expanded",
755                            query.ref_id
756                        ),
757                    },
758                )));
759            }
760        }
761
762        // Evidence is part of Inspect's contract independently of whether the
763        // caller asks to render the incoming links. Resolve the direct graph
764        // once, then follow only typed evidence sources.
765        let links = self
766            .query_application
767            .get_node_relationships(GetNodeRelationshipsQuery {
768                node_id: query.ref_id.clone(),
769            })
770            .await?;
771        let mut evidence = Vec::new();
772        let supporting_refs = links
773            .incoming
774            .iter()
775            .filter(|relationship| relationship.relationship_type == "supports")
776            .map(|relationship| relationship.source_node_id.clone())
777            .collect::<BTreeSet<_>>();
778        // One source has no dispatches to amortize. Keep its existing direct
779        // operation; collect typed nodes and bodies together for larger sets.
780        let sources = if supporting_refs.len() == 1 {
781            let node_id = supporting_refs.into_iter().next().expect("one source");
782            vec![match self
783                .query_application
784                .get_node_detail(GetNodeDetailQuery { node_id })
785                .await
786            {
787                Ok(detail) => Some(detail),
788                Err(ApplicationError::NotFound(_)) => None,
789                Err(error) => return Err(error),
790            }]
791        } else {
792            self.query_application
793                .get_node_details(supporting_refs.into_iter().collect())
794                .await?
795        };
796        // A stale edge is not evidence; a present typed source with no body
797        // retains the same fallback as the single-node operation.
798        for evidence_detail in sources.into_iter().flatten() {
799            if !is_memory_evidence_kind(&evidence_detail.node.node_kind) {
800                continue;
801            }
802            evidence.push(InspectedEvidence {
803                supports: projected_evidence_supports(&evidence_detail, &query.ref_id),
804                detail: evidence_detail,
805            });
806        }
807        let raw_coordinates = if query.include_raw {
808            inspect_raw_coordinates(&query.ref_id, Some(&links))?
809        } else {
810            Vec::new()
811        };
812
813        Ok(InspectMemoryResult {
814            detail,
815            incoming: if include_incoming {
816                links.incoming.clone()
817            } else {
818                Vec::new()
819            },
820            outgoing: if include_outgoing {
821                links.outgoing.clone()
822            } else {
823                Vec::new()
824            },
825            evidence,
826            raw_coordinates,
827            include_details,
828            include_raw: query.include_raw,
829        })
830    }
831
832    async fn existing_memory_refs(
833        &self,
834        about: &str,
835    ) -> Result<ExistingMemoryRefs, ApplicationError> {
836        Ok(self
837            .existing_memory_bundle(about)
838            .await?
839            .as_ref()
840            .map(existing_refs_from_bundle)
841            .unwrap_or_default())
842    }
843
844    async fn existing_memory_bundle(
845        &self,
846        about: &str,
847    ) -> Result<Option<KmpBundle>, ApplicationError> {
848        match self
849            .query_application
850            .get_context(GetContextQuery {
851                root_node_id: about.to_string(),
852                role: "memory".to_string(),
853                // Existing-ref validation only needs direct structural memory edges:
854                // anchor -> dimensions, anchor -> entries, and anchor -> evidence.
855                // Full semantic traversal here grows with every writer relation and
856                // makes repeated ingest progressively slower.
857                depth: MEMORY_EXISTING_REFS_LOOKUP_DEPTH,
858                requested_scopes: Vec::new(),
859                render_options: ContextRenderOptions::default(),
860            })
861            .await
862        {
863            Ok(result) => Ok(Some(result.bundle)),
864            Err(ApplicationError::NotFound(_)) => Ok(None),
865            Err(error) => Err(error),
866        }
867    }
868
869    async fn validate_read_members(
870        &self,
871        about: &str,
872        members: &[(&str, &str)],
873    ) -> Result<(), ApplicationError> {
874        let mut graph_members = Vec::new();
875        for (path, member_ref) in members {
876            validate_ref_token(path, member_ref).map_err(ApplicationError::Validation)?;
877            if validate_supplied_member_ref(about, path, member_ref).is_err() {
878                graph_members.push((*path, *member_ref));
879            }
880        }
881        if graph_members.is_empty() {
882            return Ok(());
883        }
884
885        let visible = match self
886            .query_application
887            .get_context(GetContextQuery {
888                root_node_id: about.to_string(),
889                role: "memory-boundary".to_string(),
890                depth: MAX_NATIVE_GRAPH_TRAVERSAL_DEPTH,
891                requested_scopes: Vec::new(),
892                render_options: ContextRenderOptions::default(),
893            })
894            .await
895        {
896            Ok(result) => bundle_node_ids(&result.bundle),
897            Err(ApplicationError::NotFound(_)) => BTreeSet::new(),
898            Err(error) => return Err(error),
899        };
900        for (path, member_ref) in graph_members {
901            if !visible.contains(member_ref) {
902                return Err(ApplicationError::Validation(format!(
903                    "`{path}` `{member_ref}` does not belong to about `{about}`"
904                )));
905            }
906        }
907        Ok(())
908    }
909
910    async fn memory_context(
911        &self,
912        about: &str,
913        role: &str,
914        depth: u32,
915        dimensions: &DimensionSelection,
916        render_options: &ContextRenderOptions,
917    ) -> Result<GetContextResult, ApplicationError> {
918        let roots = self.memory_context_roots(about, dimensions).await?;
919        let requested_scopes = requested_dimension_scopes(about, dimensions, &roots);
920        let mut results = Vec::new();
921        for root in &roots {
922            results.push(
923                self.query_application
924                    .get_context(GetContextQuery {
925                        root_node_id: root.clone(),
926                        role: role.to_string(),
927                        depth,
928                        requested_scopes: requested_scopes.clone(),
929                        render_options: render_options.clone(),
930                    })
931                    .await?,
932            );
933        }
934
935        merge_context_results(results, render_options)
936    }
937
938    async fn memory_context_roots(
939        &self,
940        current_about: &str,
941        selection: &DimensionSelection,
942    ) -> Result<Vec<String>, ApplicationError> {
943        if selection.scope_mode() != DimensionScopeMode::AllAbouts {
944            return context_roots(current_about, selection);
945        }
946
947        let roots = if should_filter_all_abouts_by_dimensions(selection) {
948            // Kinds from `include`, exact ids from `scope_ids`, keys and
949            // values from the positive selectors: the index resolves any of
950            // them, and the filter that follows reads them all. The union is
951            // a superset of what the conjoined filter keeps, never less.
952            let dimension_ids = index_dimension_ids(selection);
953            self.query_application
954                .list_memory_abouts_by_dimensions(&dimension_ids)
955                .await?
956        } else {
957            self.query_application.list_memory_abouts().await?
958        };
959
960        let roots = prioritize_current_about(normalize_about_roots(roots), current_about);
961        if roots.is_empty() {
962            return Err(ApplicationError::NotFound(
963                "no memory abouts found for ALL_ABOUTS scope".to_string(),
964            ));
965        }
966        Ok(roots)
967    }
968}
969
970/// One temporal read before its filter: the context the graph returned and
971/// the selection resolved against the current about.
972struct TemporalRead {
973    bundle: KmpBundle,
974    dimensions: DimensionSelection,
975}
976
977/// Narrows the read to its selection and traverses it.
978fn temporal_result(
979    query: TemporalMemoryQuery,
980    read: TemporalRead,
981) -> Result<TemporalMemoryResult, ApplicationError> {
982    let TemporalRead { bundle, dimensions } = read;
983
984    let request = temporal_request(&query, &dimensions)?;
985
986    let traversal = TemporalMemoryTraversal::traverse(&bundle, &request)?;
987    let source_bundle = filter_bundle_by_memory_dimensions_with_labels(
988        &bundle,
989        &dimensions,
990        &traversal.proof_labels(&bundle)?,
991    )?;
992    Ok(TemporalMemoryResult {
993        traversal,
994        source_bundle,
995        include: query.include,
996    })
997}
998
999fn temporal_request(
1000    query: &TemporalMemoryQuery,
1001    dimensions: &DimensionSelection,
1002) -> Result<TemporalTraversalRequest, ApplicationError> {
1003    let request = TemporalTraversalRequest::new(query.direction, query.cursor.clone())
1004        .with_entry_selection(query.entry_selection.clone())
1005        .with_axis(query.axis)
1006        .with_dimensions(dimensions.clone())
1007        .with_requested_dimensions(query.dimensions.clone())
1008        .with_window(query.window);
1009    let request = if let Some(interval) = query.interval.clone() {
1010        request.with_interval(interval)
1011    } else {
1012        request
1013    };
1014    let request = if let Some(limit_entries) = query.limit_entries {
1015        request.with_limit_entries(limit_entries)?
1016    } else {
1017        request
1018    };
1019
1020    Ok(request)
1021}
1022
1023fn bundle_node_ids(bundle: &KmpBundle) -> BTreeSet<String> {
1024    std::iter::once(bundle.root_node().node_id())
1025        .chain(bundle.neighbor_nodes().iter().map(BundleNode::node_id))
1026        .map(ToString::to_string)
1027        .collect()
1028}
1029
1030fn projected_evidence_supports(
1031    evidence: &crate::queries::GetNodeDetailResult,
1032    inspected_ref: &str,
1033) -> Vec<String> {
1034    evidence
1035        .node
1036        .properties
1037        .get("payload_supports")
1038        .and_then(|value| serde_json::from_str::<Vec<String>>(value).ok())
1039        .filter(|supports| !supports.is_empty())
1040        .unwrap_or_else(|| vec![inspected_ref.to_string()])
1041}
1042
1043fn should_filter_all_abouts_by_dimensions(selection: &DimensionSelection) -> bool {
1044    selection.scope_mode() == DimensionScopeMode::AllAbouts
1045        && ((selection.mode() == DimensionSelectionMode::Only
1046            && !selection.dimensions().is_empty())
1047            || !selection.scope_ids().is_empty()
1048            || selection.selectors().iter().any(LabelSelector::is_positive))
1049}
1050
1051fn index_dimension_ids(selection: &DimensionSelection) -> Vec<String> {
1052    let mut dimension_ids = Vec::new();
1053    if selection.mode() == DimensionSelectionMode::Only {
1054        dimension_ids.extend(selection.dimensions().iter().cloned());
1055    }
1056    dimension_ids.extend(selection.scope_ids().iter().cloned());
1057    dimension_ids.extend(selection.positive_selector_ids());
1058    dimension_ids
1059}
1060
1061fn inspect_raw_coordinates(
1062    ref_id: &str,
1063    links: Option<&crate::queries::GetNodeRelationshipsResult>,
1064) -> Result<Vec<TemporalCoordinate>, ApplicationError> {
1065    let Some(links) = links else {
1066        return Ok(Vec::new());
1067    };
1068
1069    let mut coordinates = Vec::new();
1070    for relationship in links.incoming.iter().chain(links.outgoing.iter()) {
1071        if relationship.relationship_type != "contains_entry"
1072            || relationship.target_node_id != ref_id
1073        {
1074            continue;
1075        }
1076        if let Some(coordinate) =
1077            TemporalCoordinate::from_relation_explanation(&relationship.explanation)?
1078        {
1079            coordinates.push(coordinate);
1080        }
1081    }
1082
1083    Ok(coordinates)
1084}
1085
1086fn memory_render_options(
1087    token_budget: u32,
1088    max_tier: Option<ResolutionTier>,
1089    rehydration_mode: KmpMode,
1090    endpoint_hint: EndpointHint,
1091) -> ContextRenderOptions {
1092    ContextRenderOptions {
1093        focus_node_id: None,
1094        token_budget: (token_budget > 0).then_some(token_budget),
1095        max_tier,
1096        rehydration_mode,
1097        endpoint_hint,
1098    }
1099}
1100
1101fn requested_dimension_scopes(
1102    _current_about: &str,
1103    selection: &DimensionSelection,
1104    _context_roots: &[String],
1105) -> Vec<String> {
1106    // Hints are keys, bare values or complete refs. The adapter compares
1107    // them to the decoded label identity; callers never manufacture refs.
1108    if !selection.scope_ids().is_empty() {
1109        return selection.scope_ids().iter().cloned().collect();
1110    }
1111    if selection.mode() == DimensionSelectionMode::Only {
1112        return selection.dimensions().iter().cloned().collect();
1113    }
1114    Vec::new()
1115}
1116
1117fn context_roots(
1118    current_about: &str,
1119    selection: &DimensionSelection,
1120) -> Result<Vec<String>, ApplicationError> {
1121    match selection.scope_mode() {
1122        DimensionScopeMode::Abouts if !selection.abouts().is_empty() => {
1123            Ok(selection.abouts().iter().cloned().collect())
1124        }
1125        DimensionScopeMode::CurrentAbout => Ok(vec![current_about.to_string()]),
1126        DimensionScopeMode::Abouts => Err(ApplicationError::Validation(
1127            "dimension scope ABOUTS requires at least one about".to_string(),
1128        )),
1129        DimensionScopeMode::AllAbouts => Err(ApplicationError::Validation(
1130            "dimension scope ALL_ABOUTS must be resolved through the memory about index"
1131                .to_string(),
1132        )),
1133    }
1134}
1135
1136fn apply_dimension_selection(
1137    mut result: GetContextResult,
1138    dimensions: &DimensionSelection,
1139    render_options: &ContextRenderOptions,
1140) -> Result<GetContextResult, ApplicationError> {
1141    result.bundle = filter_bundle_by_memory_dimensions(&result.bundle, dimensions)?;
1142    result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
1143    Ok(result)
1144}
1145
1146fn normalize_about_roots(values: Vec<String>) -> Vec<String> {
1147    values
1148        .into_iter()
1149        .map(|value| value.trim().to_string())
1150        .filter(|value| !value.is_empty())
1151        .collect::<BTreeSet<_>>()
1152        .into_iter()
1153        .collect()
1154}
1155
1156fn prioritize_current_about(mut roots: Vec<String>, current_about: &str) -> Vec<String> {
1157    let current_about = current_about.trim();
1158    if current_about.is_empty() {
1159        return roots;
1160    }
1161    if let Some(position) = roots.iter().position(|root| root == current_about) {
1162        let root = roots.remove(position);
1163        roots.insert(0, root);
1164    }
1165    roots
1166}
1167
1168fn filter_bundle_by_memory_dimensions(
1169    bundle: &KmpBundle,
1170    dimensions: &DimensionSelection,
1171) -> Result<KmpBundle, ApplicationError> {
1172    filter_bundle_by_memory_dimensions_with_labels(bundle, dimensions, &labels_by_entry(bundle))
1173}
1174
1175fn filter_bundle_by_memory_dimensions_with_labels(
1176    bundle: &KmpBundle,
1177    dimensions: &DimensionSelection,
1178    labels: &BTreeMap<String, EntryLabels>,
1179) -> Result<KmpBundle, ApplicationError> {
1180    let mut included_node_ids = BTreeSet::from([bundle.root_node().node_id().to_string()]);
1181    let mut selected_entry_ids = BTreeSet::new();
1182    let node_kinds = bundle_node_kinds(bundle);
1183
1184    for relationship in bundle
1185        .relationships()
1186        .iter()
1187        .filter(|relationship| relationship.relationship_type() == "contains_entry")
1188    {
1189        if contains_entry_selected(relationship, dimensions, labels) {
1190            included_node_ids.insert(relationship.source_node_id().to_string());
1191            included_node_ids.insert(relationship.target_node_id().to_string());
1192            selected_entry_ids.insert(relationship.target_node_id().to_string());
1193        }
1194    }
1195
1196    for relationship in bundle.relationships().iter().filter(|relationship| {
1197        relationship.relationship_type() == "supports"
1198            && selected_entry_ids.contains(relationship.target_node_id())
1199            && node_kinds
1200                .get(relationship.source_node_id())
1201                .is_some_and(|kind| is_memory_evidence_kind(kind))
1202    }) {
1203        included_node_ids.insert(relationship.source_node_id().to_string());
1204    }
1205
1206    let neighbor_nodes = bundle
1207        .neighbor_nodes()
1208        .iter()
1209        .filter(|node| included_node_ids.contains(node.node_id()))
1210        .cloned()
1211        .collect::<Vec<_>>();
1212    let relationships = bundle
1213        .relationships()
1214        .iter()
1215        .filter(|relationship| {
1216            if relationship.relationship_type() == "contains_entry" {
1217                return contains_entry_selected(relationship, dimensions, labels);
1218            }
1219            included_node_ids.contains(relationship.source_node_id())
1220                && included_node_ids.contains(relationship.target_node_id())
1221        })
1222        .cloned()
1223        .collect::<Vec<_>>();
1224    let node_details = bundle
1225        .node_details()
1226        .iter()
1227        .filter(|detail| included_node_ids.contains(detail.node_id()))
1228        .cloned()
1229        .collect::<Vec<_>>();
1230
1231    KmpBundle::new(
1232        bundle.root_node_id().clone(),
1233        bundle.role().clone(),
1234        bundle.root_node().clone(),
1235        neighbor_nodes,
1236        relationships,
1237        node_details,
1238        bundle.metadata().clone(),
1239    )
1240    .map_err(Into::into)
1241}
1242
1243/// A `contains_entry` edge survives when its coordinate passes the
1244/// coordinate filters and the entry it points at passes every selector:
1245/// the first reads one coordinate, the second the entry's whole label map.
1246fn contains_entry_selected(
1247    relationship: &BundleRelationship,
1248    dimensions: &DimensionSelection,
1249    labels: &BTreeMap<String, EntryLabels>,
1250) -> bool {
1251    let explanation = relationship.explanation();
1252    let coordinate_passes = dimensions.includes_coordinate(
1253        explanation.dimension().unwrap_or_default(),
1254        explanation.scope_id().unwrap_or_default(),
1255    );
1256    coordinate_passes
1257        && (!dimensions.has_selectors()
1258            || dimensions.admits(
1259                labels
1260                    .get(relationship.target_node_id())
1261                    .unwrap_or(&EntryLabels::default()),
1262            ))
1263}
1264
1265fn bundle_node_kinds(bundle: &KmpBundle) -> BTreeMap<&str, &str> {
1266    let mut node_kinds =
1267        BTreeMap::from([(bundle.root_node().node_id(), bundle.root_node().node_kind())]);
1268    for node in bundle.neighbor_nodes() {
1269        node_kinds.insert(node.node_id(), node.node_kind());
1270    }
1271    node_kinds
1272}
1273
1274fn is_memory_evidence_kind(kind: &str) -> bool {
1275    matches!(kind, "memory_evidence" | "evidence")
1276}
1277
1278fn existing_refs_from_bundle(bundle: &KmpBundle) -> ExistingMemoryRefs {
1279    let mut refs = BTreeSet::from([bundle.root_node().node_id().to_string()]);
1280    let mut dimensions = BTreeSet::new();
1281    let mut max_sequences = BTreeMap::new();
1282
1283    let mut labels = BTreeSet::new();
1284    for node in bundle.neighbor_nodes() {
1285        refs.insert(node.node_id().to_string());
1286        if node.node_kind() == "memory_dimension" {
1287            dimensions.insert(node.node_id().to_string());
1288            if let Some(kind) = node.properties().get("dimension_kind") {
1289                let value = MemoryDimensionIdentity::parse(node.node_id())
1290                    .map(|identity| identity.dimension_id().to_string())
1291                    .unwrap_or_else(|| node.node_id().to_string());
1292                labels.insert((kind.clone(), value));
1293            }
1294        }
1295    }
1296
1297    for relationship in bundle
1298        .relationships()
1299        .iter()
1300        .filter(|relationship| relationship.relationship_type() == "contains_entry")
1301    {
1302        dimensions.insert(relationship.source_node_id().to_string());
1303        let explanation = relationship.explanation();
1304        if let (Some(dimension), Some(scope_id), Some(sequence)) = (
1305            explanation.dimension(),
1306            explanation.scope_id(),
1307            explanation.sequence(),
1308        ) {
1309            max_sequences
1310                .entry((dimension.to_string(), scope_id.to_string()))
1311                .and_modify(|current: &mut u32| *current = (*current).max(sequence))
1312                .or_insert(sequence);
1313        }
1314    }
1315
1316    ExistingMemoryRefs {
1317        refs,
1318        dimensions,
1319        labels,
1320        max_sequences,
1321        foreign: BTreeSet::new(),
1322    }
1323}
1324
1325fn merge_context_results(
1326    mut results: Vec<GetContextResult>,
1327    render_options: &ContextRenderOptions,
1328) -> Result<GetContextResult, ApplicationError> {
1329    let mut result = results.remove(0);
1330    if results.is_empty() {
1331        return Ok(result);
1332    }
1333
1334    let bundles = std::iter::once(result.bundle)
1335        .chain(results.into_iter().map(|other| other.bundle))
1336        .collect();
1337    result.bundle = super::merge_memory_bundles::merge(bundles)?;
1338    result.rendered = render_graph_bundle_with_options(&result.bundle, render_options);
1339    Ok(result)
1340}
1341
1342#[cfg(test)]
1343mod tests {
1344    use std::collections::BTreeMap;
1345
1346    use kmp_domain::{BundleMetadata, CaseId, RelationExplanation, RelationSemanticClass, Role};
1347
1348    use super::*;
1349
1350    #[test]
1351    fn all_abouts_scope_requires_about_index_resolution() {
1352        let selection = DimensionSelection::all().with_all_about_scope();
1353        let error = context_roots("question:current", &selection)
1354            .expect_err("ALL_ABOUTS must not fall back to current about directly");
1355
1356        assert!(matches!(
1357            error,
1358            ApplicationError::Validation(message)
1359                if message.contains("resolved through the memory about index")
1360        ));
1361    }
1362
1363    #[test]
1364    fn requested_scopes_preserves_keys_as_index_hints() {
1365        let selection = DimensionSelection::only(["timeline"]).with_all_about_scope();
1366        let scopes = requested_dimension_scopes(
1367            "question:current",
1368            &selection,
1369            &["question:a".to_string(), "question:b".to_string()],
1370        );
1371
1372        assert_eq!(scopes, vec!["timeline".to_string()]);
1373    }
1374
1375    #[test]
1376    fn requested_scopes_preserves_values_and_exact_refs_as_index_hints() {
1377        let selection = DimensionSelection::only(["conversation"])
1378            .with_about_scope(["question:a", "question:b"])
1379            .with_scope_ids([
1380                "conversation:alpha",
1381                "label:v1:question%3Ab:conversation:conversation%3Abeta",
1382            ]);
1383        let scopes = requested_dimension_scopes("question:current", &selection, &[]);
1384
1385        assert_eq!(
1386            scopes,
1387            vec![
1388                "conversation:alpha".to_string(),
1389                "label:v1:question%3Ab:conversation:conversation%3Abeta".to_string()
1390            ]
1391        );
1392    }
1393
1394    #[test]
1395    fn bundle_filter_narrows_same_dimension_kind_by_exact_scope_id() {
1396        let bundle = scoped_conversation_bundle();
1397        let selection = DimensionSelection::only(["conversation"])
1398            .resolve_current_about("question:a")
1399            .with_scope_ids(["conversation:alpha"]);
1400
1401        let filtered =
1402            filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
1403        let node_ids = filtered
1404            .neighbor_nodes()
1405            .iter()
1406            .map(|node| node.node_id())
1407            .collect::<Vec<_>>();
1408        let relationships = filtered
1409            .relationships()
1410            .iter()
1411            .map(|relationship| {
1412                (
1413                    relationship.source_node_id(),
1414                    relationship.target_node_id(),
1415                    relationship.relationship_type(),
1416                )
1417            })
1418            .collect::<Vec<_>>();
1419
1420        assert!(node_ids.contains(&"label:v1:question%3Aa:conversation:conversation%3Aalpha"));
1421        assert!(node_ids.contains(&"claim:alpha"));
1422        assert!(!node_ids.contains(&"label:v1:question%3Aa:conversation:conversation%3Abeta"));
1423        assert!(!node_ids.contains(&"claim:beta"));
1424        assert_eq!(
1425            relationships,
1426            vec![(
1427                "label:v1:question%3Aa:conversation:conversation%3Aalpha",
1428                "claim:alpha",
1429                "contains_entry"
1430            )]
1431        );
1432    }
1433
1434    #[test]
1435    fn bundle_filter_only_pulls_support_sources_when_source_is_memory_evidence() {
1436        let bundle = scoped_conversation_bundle_with_supports();
1437        let selection = DimensionSelection::only(["conversation"])
1438            .resolve_current_about("question:a")
1439            .with_scope_ids(["conversation:alpha"]);
1440
1441        let filtered =
1442            filter_bundle_by_memory_dimensions(&bundle, &selection).expect("bundle should filter");
1443        let node_ids = filtered
1444            .neighbor_nodes()
1445            .iter()
1446            .map(|node| node.node_id())
1447            .collect::<Vec<_>>();
1448        let relationships = filtered
1449            .relationships()
1450            .iter()
1451            .map(|relationship| {
1452                (
1453                    relationship.source_node_id(),
1454                    relationship.target_node_id(),
1455                    relationship.relationship_type(),
1456                )
1457            })
1458            .collect::<Vec<_>>();
1459
1460        assert!(node_ids.contains(&"claim:alpha"));
1461        assert!(node_ids.contains(&"evidence:alpha"));
1462        assert!(!node_ids.contains(&"claim:beta"));
1463        assert_eq!(
1464            relationships,
1465            vec![
1466                (
1467                    "label:v1:question%3Aa:conversation:conversation%3Aalpha",
1468                    "claim:alpha",
1469                    "contains_entry"
1470                ),
1471                ("evidence:alpha", "claim:alpha", "supports")
1472            ]
1473        );
1474    }
1475
1476    #[test]
1477    fn normalize_about_roots_trims_sorts_and_deduplicates() {
1478        assert_eq!(
1479            normalize_about_roots(vec![
1480                " question:b ".to_string(),
1481                String::new(),
1482                "question:a".to_string(),
1483                "question:b".to_string(),
1484            ]),
1485            vec!["question:a".to_string(), "question:b".to_string()]
1486        );
1487    }
1488
1489    /// A sweep is narrowed by dimension kind through `include` and by exact
1490    /// scope through `scope_ids`; either alone is enough to ask the index.
1491    #[test]
1492    fn a_sweep_is_narrowed_by_kinds_or_by_exact_scopes() {
1493        let by_kind = DimensionSelection::only(["incident"]).with_all_about_scope();
1494        assert!(should_filter_all_abouts_by_dimensions(&by_kind));
1495        let by_scope = DimensionSelection::all()
1496            .with_all_about_scope()
1497            .with_scope_ids(["incident:north-outage"]);
1498        assert!(should_filter_all_abouts_by_dimensions(&by_scope));
1499        let whole_sweep = DimensionSelection::all().with_all_about_scope();
1500        assert!(!should_filter_all_abouts_by_dimensions(&whole_sweep));
1501        let inside_one_about = DimensionSelection::only(["incident"]);
1502        assert!(!should_filter_all_abouts_by_dimensions(&inside_one_about));
1503    }
1504
1505    #[test]
1506    fn prioritize_current_about_keeps_all_roots_but_moves_current_first() {
1507        assert_eq!(
1508            prioritize_current_about(
1509                vec![
1510                    "question:a".to_string(),
1511                    "question:current".to_string(),
1512                    "question:z".to_string(),
1513                ],
1514                "question:current",
1515            ),
1516            vec![
1517                "question:current".to_string(),
1518                "question:a".to_string(),
1519                "question:z".to_string()
1520            ]
1521        );
1522    }
1523
1524    #[test]
1525    fn bundle_filter_reads_selectors_over_the_entry_not_the_coordinate() {
1526        use kmp_domain::LabelSelectorOperator;
1527
1528        let bundle = scoped_conversation_bundle();
1529        let keeps = |selection: DimensionSelection| {
1530            filter_bundle_by_memory_dimensions(&bundle, &selection)
1531                .expect("bundle should filter")
1532                .relationships()
1533                .iter()
1534                .filter(|relationship| relationship.relationship_type() == "contains_entry")
1535                .map(|relationship| relationship.target_node_id().to_string())
1536                .collect::<Vec<_>>()
1537        };
1538        let selector = |key: &str, operator: LabelSelectorOperator, values: &[&str]| {
1539            LabelSelector::new(key, operator, values.iter().copied()).expect("selector")
1540        };
1541
1542        assert_eq!(
1543            keeps(DimensionSelection::all().with_selectors([selector(
1544                "conversation",
1545                LabelSelectorOperator::In,
1546                &["conversation:alpha"]
1547            )])),
1548            vec!["claim:alpha"]
1549        );
1550        assert_eq!(
1551            keeps(DimensionSelection::all().with_selectors([selector(
1552                "conversation",
1553                LabelSelectorOperator::NotIn,
1554                &["conversation:alpha"]
1555            )])),
1556            vec!["claim:beta"]
1557        );
1558        assert!(
1559            keeps(DimensionSelection::all().with_selectors([selector(
1560                "conversation",
1561                LabelSelectorOperator::NotExists,
1562                &[]
1563            )]))
1564            .is_empty()
1565        );
1566        assert_eq!(
1567            keeps(DimensionSelection::all().with_selectors([selector(
1568                "conversation",
1569                LabelSelectorOperator::Exists,
1570                &[]
1571            )])),
1572            vec!["claim:alpha", "claim:beta"]
1573        );
1574    }
1575
1576    #[test]
1577    fn all_abouts_index_reads_positive_selectors_and_never_the_except_list() {
1578        use kmp_domain::LabelSelectorOperator;
1579
1580        let by_selector = DimensionSelection::all()
1581            .with_all_about_scope()
1582            .with_selectors([
1583                LabelSelector::new(
1584                    "incident",
1585                    LabelSelectorOperator::Exists,
1586                    Vec::<String>::new(),
1587                )
1588                .expect("selector"),
1589                LabelSelector::new("env", LabelSelectorOperator::In, ["prod"]).expect("selector"),
1590            ]);
1591        assert!(should_filter_all_abouts_by_dimensions(&by_selector));
1592        assert_eq!(index_dimension_ids(&by_selector), vec!["incident", "prod"]);
1593
1594        let negative_only = DimensionSelection::except(["task"])
1595            .with_all_about_scope()
1596            .with_selectors([LabelSelector::new(
1597                "customer",
1598                LabelSelectorOperator::NotIn,
1599                ["acme"],
1600            )
1601            .expect("selector")]);
1602        assert!(!should_filter_all_abouts_by_dimensions(&negative_only));
1603        assert!(index_dimension_ids(&negative_only).is_empty());
1604    }
1605
1606    fn scoped_conversation_bundle() -> KmpBundle {
1607        KmpBundle::new(
1608            CaseId::new("question:a").expect("case id should be valid"),
1609            Role::new("temporal-reader").expect("role should be valid"),
1610            BundleNode::new(
1611                "question:a",
1612                "question",
1613                "Question A",
1614                "Test question",
1615                "ACTIVE",
1616                Vec::new(),
1617                BTreeMap::new(),
1618            ),
1619            vec![
1620                memory_dimension_node("label:v1:question%3Aa:conversation:conversation%3Aalpha"),
1621                memory_dimension_node("label:v1:question%3Aa:conversation:conversation%3Abeta"),
1622                claim_node("claim:alpha"),
1623                claim_node("claim:beta"),
1624            ],
1625            vec![
1626                contains_entry(
1627                    "label:v1:question%3Aa:conversation:conversation%3Aalpha",
1628                    "claim:alpha",
1629                    1,
1630                ),
1631                contains_entry(
1632                    "label:v1:question%3Aa:conversation:conversation%3Abeta",
1633                    "claim:beta",
1634                    2,
1635                ),
1636                cross_scope_constraint("claim:beta", "claim:alpha"),
1637            ],
1638            Vec::new(),
1639            BundleMetadata::initial("test"),
1640        )
1641        .expect("test bundle should be valid")
1642    }
1643
1644    fn scoped_conversation_bundle_with_supports() -> KmpBundle {
1645        KmpBundle::new(
1646            CaseId::new("question:a").expect("case id should be valid"),
1647            Role::new("temporal-reader").expect("role should be valid"),
1648            BundleNode::new(
1649                "question:a",
1650                "question",
1651                "Question A",
1652                "Test question",
1653                "ACTIVE",
1654                Vec::new(),
1655                BTreeMap::new(),
1656            ),
1657            vec![
1658                memory_dimension_node("label:v1:question%3Aa:conversation:conversation%3Aalpha"),
1659                memory_dimension_node("label:v1:question%3Aa:conversation:conversation%3Abeta"),
1660                claim_node("claim:alpha"),
1661                claim_node("claim:beta"),
1662                evidence_node("evidence:alpha"),
1663            ],
1664            vec![
1665                contains_entry(
1666                    "label:v1:question%3Aa:conversation:conversation%3Aalpha",
1667                    "claim:alpha",
1668                    1,
1669                ),
1670                contains_entry(
1671                    "label:v1:question%3Aa:conversation:conversation%3Abeta",
1672                    "claim:beta",
1673                    2,
1674                ),
1675                supports("claim:beta", "claim:alpha"),
1676                supports("evidence:alpha", "claim:alpha"),
1677            ],
1678            Vec::new(),
1679            BundleMetadata::initial("test"),
1680        )
1681        .expect("test bundle should be valid")
1682    }
1683
1684    fn memory_dimension_node(node_id: &str) -> BundleNode {
1685        BundleNode::new(
1686            node_id,
1687            "memory_dimension",
1688            node_id,
1689            "Conversation scope",
1690            "ACTIVE",
1691            Vec::new(),
1692            BTreeMap::new(),
1693        )
1694    }
1695
1696    fn claim_node(node_id: &str) -> BundleNode {
1697        BundleNode::new(
1698            node_id,
1699            "claim",
1700            node_id,
1701            "Claim",
1702            "ACTIVE",
1703            Vec::new(),
1704            BTreeMap::new(),
1705        )
1706    }
1707
1708    fn evidence_node(node_id: &str) -> BundleNode {
1709        BundleNode::new(
1710            node_id,
1711            "memory_evidence",
1712            node_id,
1713            "Evidence",
1714            "ACTIVE",
1715            Vec::new(),
1716            BTreeMap::new(),
1717        )
1718    }
1719
1720    fn contains_entry(scope_id: &str, target_node_id: &str, sequence: u32) -> BundleRelationship {
1721        BundleRelationship::new(
1722            scope_id,
1723            target_node_id,
1724            "contains_entry",
1725            RelationExplanation::new(RelationSemanticClass::Structural)
1726                .with_dimension("conversation")
1727                .with_scope_id(scope_id)
1728                .with_sequence(sequence),
1729        )
1730    }
1731
1732    fn cross_scope_constraint(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1733        BundleRelationship::new(
1734            source_node_id,
1735            target_node_id,
1736            "contextual_constraint",
1737            RelationExplanation::new(RelationSemanticClass::Constraint)
1738                .with_rationale("Off-scope relation must not leak through exact scope filtering.")
1739                .with_confidence("medium"),
1740        )
1741    }
1742
1743    fn supports(source_node_id: &str, target_node_id: &str) -> BundleRelationship {
1744        BundleRelationship::new(
1745            source_node_id,
1746            target_node_id,
1747            "supports",
1748            RelationExplanation::new(RelationSemanticClass::Evidential)
1749                .with_rationale("Support relation for scoped filtering.")
1750                .with_confidence("medium"),
1751        )
1752    }
1753}