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