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