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