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