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