1use std::collections::BTreeSet;
2use std::time::Instant;
3
4use kmp_domain::{
5 BundleMetadata, BundleNode, BundleNodeDetail, BundleRelationship, CaseId,
6 GraphNeighborhoodReader, KmpBundle, NodeDetailReader, NodeNeighborhood, NodeProjection, Role,
7};
8
9use crate::ApplicationError;
10use crate::queries::QueryTimingBreakdown;
11use crate::queries::ordered_neighborhood::ordered_neighborhood;
12use crate::queries::{
13 DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH, clamp_native_graph_subtree_depth,
14 clamp_native_graph_traversal_depth,
15};
16
17#[derive(Debug, Clone)]
18pub struct NodeCentricProjectionReader<G, D> {
19 graph_reader: G,
20 detail_reader: D,
21}
22
23impl<G, D> NodeCentricProjectionReader<G, D> {
24 pub fn new(graph_reader: G, detail_reader: D) -> Self {
25 Self {
26 graph_reader,
27 detail_reader,
28 }
29 }
30}
31
32impl<G, D> NodeCentricProjectionReader<G, D>
33where
34 G: GraphNeighborhoodReader + Send + Sync,
35 D: NodeDetailReader + Send + Sync,
36{
37 pub async fn load_bundle(
38 &self,
39 root_node_id: &str,
40 role: &str,
41 generator_version: &str,
42 ) -> Result<(Option<KmpBundle>, QueryTimingBreakdown), ApplicationError> {
43 self.load_bundle_with_depth(
44 root_node_id,
45 role,
46 generator_version,
47 DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH,
48 )
49 .await
50 }
51
52 pub async fn load_bundle_with_depth(
53 &self,
54 root_node_id: &str,
55 role: &str,
56 generator_version: &str,
57 depth: u32,
58 ) -> Result<(Option<KmpBundle>, QueryTimingBreakdown), ApplicationError> {
59 let graph_start = Instant::now();
60 let Some(neighborhood) = self
61 .graph_reader
62 .load_neighborhood(root_node_id, clamp_native_graph_traversal_depth(depth))
63 .await?
64 else {
65 return Ok((None, QueryTimingBreakdown::not_found(graph_start.elapsed())));
66 };
67 if is_placeholder_projection_node(&neighborhood.root) {
68 return Ok((None, QueryTimingBreakdown::not_found(graph_start.elapsed())));
69 }
70 let neighborhood = ordered_neighborhood(filter_placeholder_nodes(neighborhood));
71 let graph_load = graph_start.elapsed();
72
73 let batch_size = 1 + neighborhood.neighbors.len();
74
75 let detail_start = Instant::now();
76 let node_details = load_node_details(&self.detail_reader, &neighborhood).await?;
77 let detail_load = detail_start.elapsed();
78
79 let assembly_start = Instant::now();
80 let bundle = build_bundle(
81 root_node_id,
82 role,
83 generator_version,
84 neighborhood,
85 node_details,
86 )?;
87 let bundle_assembly = assembly_start.elapsed();
88
89 let timing = QueryTimingBreakdown {
90 graph_load,
91 detail_load,
92 bundle_assembly,
93 role_count: 1,
94 batch_size,
95 };
96
97 Ok((Some(bundle), timing))
98 }
99
100 pub async fn load_bundles_for_roles(
101 &self,
102 root_node_id: &str,
103 roles: &[String],
104 generator_version: &str,
105 depth: u32,
106 ) -> Result<(Option<Vec<KmpBundle>>, QueryTimingBreakdown), ApplicationError> {
107 let graph_start = Instant::now();
108 let Some(neighborhood) = self
109 .graph_reader
110 .load_neighborhood(root_node_id, clamp_native_graph_traversal_depth(depth))
111 .await?
112 else {
113 return Ok((None, QueryTimingBreakdown::not_found(graph_start.elapsed())));
114 };
115 if is_placeholder_projection_node(&neighborhood.root) {
116 return Ok((None, QueryTimingBreakdown::not_found(graph_start.elapsed())));
117 }
118 let neighborhood = ordered_neighborhood(filter_placeholder_nodes(neighborhood));
119 let graph_load = graph_start.elapsed();
120
121 let batch_size = 1 + neighborhood.neighbors.len();
122
123 let detail_start = Instant::now();
124 let node_details = load_node_details(&self.detail_reader, &neighborhood).await?;
125 let detail_load = detail_start.elapsed();
126
127 let assembly_start = Instant::now();
128 let mut bundles = Vec::with_capacity(roles.len());
129 for role in roles {
130 bundles.push(build_bundle(
131 root_node_id,
132 role,
133 generator_version,
134 neighborhood.clone(),
135 node_details.clone(),
136 )?);
137 }
138 let bundle_assembly = assembly_start.elapsed();
139
140 let timing = QueryTimingBreakdown {
141 graph_load,
142 detail_load,
143 bundle_assembly,
144 role_count: roles.len(),
145 batch_size,
146 };
147
148 Ok((Some(bundles), timing))
149 }
150
151 pub async fn load_context_path_bundle_with_depth(
152 &self,
153 root_node_id: &str,
154 target_node_id: &str,
155 role: &str,
156 generator_version: &str,
157 subtree_depth: u32,
158 ) -> Result<(Option<KmpBundle>, QueryTimingBreakdown), ApplicationError> {
159 let graph_start = Instant::now();
160 let Some(path_neighborhood) = self
161 .graph_reader
162 .load_context_path(
163 root_node_id,
164 target_node_id,
165 clamp_native_graph_subtree_depth(subtree_depth),
166 )
167 .await?
168 else {
169 return Ok((None, QueryTimingBreakdown::not_found(graph_start.elapsed())));
170 };
171
172 if is_placeholder_projection_node(&path_neighborhood.root) {
173 return Ok((None, QueryTimingBreakdown::not_found(graph_start.elapsed())));
174 }
175
176 let neighborhood = filter_placeholder_nodes(NodeNeighborhood {
177 root: path_neighborhood.root,
178 neighbors: path_neighborhood.neighbors,
179 relations: path_neighborhood.relations,
180 });
181 let allowed_node_ids: BTreeSet<String> = std::iter::once(neighborhood.root.node_id.clone())
182 .chain(
183 neighborhood
184 .neighbors
185 .iter()
186 .map(|node| node.node_id.clone()),
187 )
188 .collect();
189 let path_node_ids = path_neighborhood
190 .path_node_ids
191 .into_iter()
192 .filter(|node_id| allowed_node_ids.contains(node_id))
193 .collect::<Vec<_>>();
194 let neighborhood = ordered_neighborhood(neighborhood);
195 let graph_load = graph_start.elapsed();
196
197 let batch_size = path_node_ids.len();
198
199 let detail_start = Instant::now();
200 let node_details = load_node_details_for_ids(&self.detail_reader, path_node_ids).await?;
201 let detail_load = detail_start.elapsed();
202
203 let assembly_start = Instant::now();
204 let bundle = build_bundle(
205 root_node_id,
206 role,
207 generator_version,
208 neighborhood,
209 node_details,
210 )?;
211 let bundle_assembly = assembly_start.elapsed();
212
213 let timing = QueryTimingBreakdown {
214 graph_load,
215 detail_load,
216 bundle_assembly,
217 role_count: 1,
218 batch_size,
219 };
220
221 Ok((Some(bundle), timing))
222 }
223}
224
225fn filter_placeholder_nodes(neighborhood: NodeNeighborhood) -> NodeNeighborhood {
226 let placeholder_ids: BTreeSet<String> = neighborhood
227 .neighbors
228 .iter()
229 .filter(|node| is_placeholder_projection_node(node))
230 .map(|node| node.node_id.clone())
231 .collect();
232
233 if placeholder_ids.is_empty() {
234 return neighborhood;
235 }
236
237 NodeNeighborhood {
238 root: neighborhood.root,
239 neighbors: neighborhood
240 .neighbors
241 .into_iter()
242 .filter(|node| !placeholder_ids.contains(&node.node_id))
243 .collect(),
244 relations: neighborhood
245 .relations
246 .into_iter()
247 .filter(|relation| {
248 !placeholder_ids.contains(&relation.source_node_id)
249 && !placeholder_ids.contains(&relation.target_node_id)
250 })
251 .collect(),
252 }
253}
254
255fn is_placeholder_projection_node(node: &NodeProjection) -> bool {
256 node.node_kind == "placeholder"
257 || node.labels.iter().any(|label| label == "placeholder")
258 || node
259 .properties
260 .get("placeholder")
261 .is_some_and(|value| value == "true")
262}
263
264async fn load_node_details<D>(
265 detail_reader: &D,
266 neighborhood: &NodeNeighborhood,
267) -> Result<Vec<BundleNodeDetail>, kmp_domain::PortError>
268where
269 D: NodeDetailReader + Send + Sync,
270{
271 load_node_details_for_ids(
272 detail_reader,
273 std::iter::once(neighborhood.root.node_id.clone())
274 .chain(
275 neighborhood
276 .neighbors
277 .iter()
278 .map(|node| node.node_id.clone()),
279 )
280 .collect::<Vec<_>>(),
281 )
282 .await
283}
284
285async fn load_node_details_for_ids<D, I>(
286 detail_reader: &D,
287 node_ids: I,
288) -> Result<Vec<BundleNodeDetail>, kmp_domain::PortError>
289where
290 D: NodeDetailReader + Send + Sync,
291 I: IntoIterator<Item = String>,
292{
293 let mut seen = BTreeSet::new();
294 let unique_ids: Vec<String> = node_ids
295 .into_iter()
296 .filter(|id| seen.insert(id.clone()))
297 .collect();
298
299 let batch_results = detail_reader.load_node_details_batch(unique_ids).await?;
300
301 Ok(batch_results
302 .into_iter()
303 .flatten()
304 .map(|detail| BundleNodeDetail::from_projection(&detail))
305 .collect())
306}
307
308fn build_bundle(
309 root_node_id: &str,
310 role: &str,
311 generator_version: &str,
312 neighborhood: NodeNeighborhood,
313 node_details: Vec<BundleNodeDetail>,
314) -> Result<KmpBundle, ApplicationError> {
315 let root_node_id = CaseId::new(root_node_id)?;
316 let role = Role::new(role)?;
317
318 Ok(KmpBundle::new(
319 root_node_id,
320 role,
321 BundleNode::from_projection(&neighborhood.root),
322 neighborhood
323 .neighbors
324 .iter()
325 .map(BundleNode::from_projection)
326 .collect(),
327 neighborhood
328 .relations
329 .iter()
330 .map(BundleRelationship::from_projection)
331 .collect(),
332 node_details,
333 BundleMetadata::initial(generator_version),
334 )?)
335}
336
337#[cfg(test)]
338mod tests {
339 use std::collections::BTreeMap;
340 use std::sync::Arc;
341
342 use kmp_domain::{
343 ContextPathNeighborhood, NodeDetailProjection, NodeNeighborhood, NodeProjection,
344 NodeRelationProjection, PortError, RelationExplanation, RelationSemanticClass,
345 };
346 use tokio::sync::Mutex;
347
348 use super::NodeCentricProjectionReader;
349 use crate::queries::DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH;
350
351 struct StubGraphReader;
352
353 impl kmp_domain::GraphNeighborhoodReader for StubGraphReader {
354 async fn load_neighborhood(
355 &self,
356 _root_node_id: &str,
357 _depth: u32,
358 ) -> Result<Option<NodeNeighborhood>, PortError> {
359 Ok(Some(NodeNeighborhood {
360 root: NodeProjection {
361 node_id: "node-root".to_string(),
362 node_kind: "case".to_string(),
363 title: "Root".to_string(),
364 summary: "Root summary".to_string(),
365 status: "ACTIVE".to_string(),
366 labels: vec!["ProjectionNode".to_string()],
367 properties: BTreeMap::new(),
368 provenance: None,
369 },
370 neighbors: vec![NodeProjection {
371 node_id: "node-1".to_string(),
372 node_kind: "decision".to_string(),
373 title: "Neighbor".to_string(),
374 summary: "Neighbor summary".to_string(),
375 status: "ACTIVE".to_string(),
376 labels: vec!["ProjectionNode".to_string()],
377 properties: BTreeMap::new(),
378 provenance: None,
379 }],
380 relations: vec![NodeRelationProjection {
381 source_node_id: "node-root".to_string(),
382 target_node_id: "node-1".to_string(),
383 relation_type: "RELATES_TO".to_string(),
384 explanation: structural_explanation(),
385 }],
386 }))
387 }
388
389 async fn load_context_path(
390 &self,
391 root_node_id: &str,
392 target_node_id: &str,
393 _subtree_depth: u32,
394 ) -> Result<Option<ContextPathNeighborhood>, PortError> {
395 Ok(
396 (root_node_id == "node-root" && target_node_id == "node-1").then_some(
397 ContextPathNeighborhood {
398 root: NodeProjection {
399 node_id: "node-root".to_string(),
400 node_kind: "case".to_string(),
401 title: "Root".to_string(),
402 summary: "Root summary".to_string(),
403 status: "ACTIVE".to_string(),
404 labels: vec!["ProjectionNode".to_string()],
405 properties: BTreeMap::new(),
406 provenance: None,
407 },
408 neighbors: vec![
409 NodeProjection {
410 node_id: "node-1".to_string(),
411 node_kind: "decision".to_string(),
412 title: "Neighbor".to_string(),
413 summary: "Neighbor summary".to_string(),
414 status: "ACTIVE".to_string(),
415 labels: vec!["ProjectionNode".to_string()],
416 properties: BTreeMap::new(),
417 provenance: None,
418 },
419 NodeProjection {
420 node_id: "node-2".to_string(),
421 node_kind: "artifact".to_string(),
422 title: "Leaf".to_string(),
423 summary: "Leaf summary".to_string(),
424 status: "READY".to_string(),
425 labels: vec!["ProjectionNode".to_string()],
426 properties: BTreeMap::new(),
427 provenance: None,
428 },
429 ],
430 relations: vec![
431 NodeRelationProjection {
432 source_node_id: "node-root".to_string(),
433 target_node_id: "node-1".to_string(),
434 relation_type: "RELATES_TO".to_string(),
435 explanation: structural_explanation(),
436 },
437 NodeRelationProjection {
438 source_node_id: "node-1".to_string(),
439 target_node_id: "node-2".to_string(),
440 relation_type: "HAS_ARTIFACT".to_string(),
441 explanation: structural_explanation(),
442 },
443 ],
444 path_node_ids: vec!["node-root".to_string(), "node-1".to_string()],
445 },
446 ),
447 )
448 }
449 }
450
451 struct StubDetailReader;
452
453 impl kmp_domain::NodeDetailReader for StubDetailReader {
454 async fn load_node_detail(
455 &self,
456 node_id: &str,
457 ) -> Result<Option<NodeDetailProjection>, PortError> {
458 Ok((node_id == "node-root").then(|| NodeDetailProjection {
459 node_id: node_id.to_string(),
460 detail: "Expanded detail".to_string(),
461 content_hash: "hash-1".to_string(),
462 revision: 2,
463 }))
464 }
465
466 async fn load_node_details_batch(
467 &self,
468 node_ids: Vec<String>,
469 ) -> Result<Vec<Option<NodeDetailProjection>>, PortError> {
470 let mut results = Vec::with_capacity(node_ids.len());
471 for node_id in &node_ids {
472 results.push(self.load_node_detail(node_id).await?);
473 }
474 Ok(results)
475 }
476 }
477
478 #[tokio::test]
479 async fn load_bundle_returns_graph_native_bundle() {
480 let reader = NodeCentricProjectionReader::new(StubGraphReader, StubDetailReader);
481 let (bundle, _timing) = reader
482 .load_bundle("node-root", "developer", "0.1.0")
483 .await
484 .expect("bundle load should succeed");
485 let bundle = bundle.expect("bundle should exist");
486
487 assert_eq!(bundle.root_node().node_id(), "node-root");
488 assert_eq!(bundle.neighbor_nodes().len(), 1);
489 assert_eq!(bundle.relationships().len(), 1);
490 assert_eq!(bundle.node_details().len(), 1);
491 }
492
493 struct PlaceholderRootGraphReader;
494
495 impl kmp_domain::GraphNeighborhoodReader for PlaceholderRootGraphReader {
496 async fn load_neighborhood(
497 &self,
498 _root_node_id: &str,
499 _depth: u32,
500 ) -> Result<Option<NodeNeighborhood>, PortError> {
501 Ok(Some(NodeNeighborhood {
502 root: NodeProjection {
503 node_id: "node-root".to_string(),
504 node_kind: "placeholder".to_string(),
505 title: "[unmaterialized node]".to_string(),
506 summary: "Referenced by relation before node materialization".to_string(),
507 status: "UNMATERIALIZED".to_string(),
508 labels: vec!["placeholder".to_string()],
509 properties: BTreeMap::from([("placeholder".to_string(), "true".to_string())]),
510 provenance: None,
511 },
512 neighbors: Vec::new(),
513 relations: Vec::new(),
514 }))
515 }
516
517 async fn load_context_path(
518 &self,
519 _root_node_id: &str,
520 _target_node_id: &str,
521 _subtree_depth: u32,
522 ) -> Result<Option<ContextPathNeighborhood>, PortError> {
523 Ok(None)
524 }
525 }
526
527 #[tokio::test]
528 async fn load_bundle_returns_none_for_placeholder_root() {
529 let reader = NodeCentricProjectionReader::new(PlaceholderRootGraphReader, StubDetailReader);
530 let (bundle, _timing) = reader
531 .load_bundle("node-root", "developer", "0.1.0")
532 .await
533 .expect("bundle load should succeed");
534
535 assert!(bundle.is_none());
536 }
537
538 struct PlaceholderNeighborGraphReader;
539
540 impl kmp_domain::GraphNeighborhoodReader for PlaceholderNeighborGraphReader {
541 async fn load_neighborhood(
542 &self,
543 _root_node_id: &str,
544 _depth: u32,
545 ) -> Result<Option<NodeNeighborhood>, PortError> {
546 Ok(Some(NodeNeighborhood {
547 root: NodeProjection {
548 node_id: "node-root".to_string(),
549 node_kind: "incident".to_string(),
550 title: "Incident".to_string(),
551 summary: "Root summary".to_string(),
552 status: "ACTIVE".to_string(),
553 labels: vec!["incident".to_string()],
554 properties: BTreeMap::new(),
555 provenance: None,
556 },
557 neighbors: vec![
558 NodeProjection {
559 node_id: "node-real".to_string(),
560 node_kind: "decision".to_string(),
561 title: "Real node".to_string(),
562 summary: "Real summary".to_string(),
563 status: "ACTIVE".to_string(),
564 labels: vec!["decision".to_string()],
565 properties: BTreeMap::new(),
566 provenance: None,
567 },
568 NodeProjection {
569 node_id: "node-placeholder".to_string(),
570 node_kind: "placeholder".to_string(),
571 title: "[unmaterialized node]".to_string(),
572 summary: "Referenced by relation before node materialization".to_string(),
573 status: "UNMATERIALIZED".to_string(),
574 labels: vec!["placeholder".to_string()],
575 properties: BTreeMap::from([(
576 "placeholder".to_string(),
577 "true".to_string(),
578 )]),
579 provenance: None,
580 },
581 ],
582 relations: vec![
583 NodeRelationProjection {
584 source_node_id: "node-root".to_string(),
585 target_node_id: "node-real".to_string(),
586 relation_type: "RELATES_TO".to_string(),
587 explanation: structural_explanation(),
588 },
589 NodeRelationProjection {
590 source_node_id: "node-root".to_string(),
591 target_node_id: "node-placeholder".to_string(),
592 relation_type: "RELATES_TO".to_string(),
593 explanation: structural_explanation(),
594 },
595 ],
596 }))
597 }
598
599 async fn load_context_path(
600 &self,
601 _root_node_id: &str,
602 _target_node_id: &str,
603 _subtree_depth: u32,
604 ) -> Result<Option<ContextPathNeighborhood>, PortError> {
605 Ok(None)
606 }
607 }
608
609 struct PlaceholderDetailReader;
610
611 impl kmp_domain::NodeDetailReader for PlaceholderDetailReader {
612 async fn load_node_detail(
613 &self,
614 node_id: &str,
615 ) -> Result<Option<NodeDetailProjection>, PortError> {
616 Ok(match node_id {
617 "node-root" => Some(NodeDetailProjection {
618 node_id: node_id.to_string(),
619 detail: "Expanded detail".to_string(),
620 content_hash: "hash-root".to_string(),
621 revision: 1,
622 }),
623 "node-real" => Some(NodeDetailProjection {
624 node_id: node_id.to_string(),
625 detail: "Real detail".to_string(),
626 content_hash: "hash-real".to_string(),
627 revision: 1,
628 }),
629 "node-placeholder" => Some(NodeDetailProjection {
630 node_id: node_id.to_string(),
631 detail: "Placeholder detail".to_string(),
632 content_hash: "hash-placeholder".to_string(),
633 revision: 1,
634 }),
635 _ => None,
636 })
637 }
638
639 async fn load_node_details_batch(
640 &self,
641 node_ids: Vec<String>,
642 ) -> Result<Vec<Option<NodeDetailProjection>>, PortError> {
643 let mut results = Vec::with_capacity(node_ids.len());
644 for node_id in &node_ids {
645 results.push(self.load_node_detail(node_id).await?);
646 }
647 Ok(results)
648 }
649 }
650
651 #[tokio::test]
652 async fn load_bundle_filters_placeholder_neighbors_relations_and_details() {
653 let reader = NodeCentricProjectionReader::new(
654 PlaceholderNeighborGraphReader,
655 PlaceholderDetailReader,
656 );
657 let (bundle, _timing) = reader
658 .load_bundle("node-root", "developer", "0.1.0")
659 .await
660 .expect("bundle load should succeed");
661 let bundle = bundle.expect("bundle should exist");
662
663 assert_eq!(bundle.neighbor_nodes().len(), 1);
664 assert_eq!(bundle.neighbor_nodes()[0].node_id(), "node-real");
665 assert_eq!(bundle.relationships().len(), 1);
666 assert_eq!(bundle.relationships()[0].target_node_id(), "node-real");
667 assert_eq!(
668 bundle
669 .node_details()
670 .iter()
671 .map(|detail| detail.node_id())
672 .collect::<Vec<_>>(),
673 vec!["node-root", "node-real"]
674 );
675 }
676
677 struct RecordingGraphReader {
678 depths: Arc<Mutex<Vec<u32>>>,
679 }
680
681 impl kmp_domain::GraphNeighborhoodReader for RecordingGraphReader {
682 async fn load_neighborhood(
683 &self,
684 _root_node_id: &str,
685 depth: u32,
686 ) -> Result<Option<NodeNeighborhood>, PortError> {
687 self.depths.lock().await.push(depth);
688 Ok(None)
689 }
690
691 async fn load_context_path(
692 &self,
693 _root_node_id: &str,
694 _target_node_id: &str,
695 subtree_depth: u32,
696 ) -> Result<Option<ContextPathNeighborhood>, PortError> {
697 self.depths.lock().await.push(subtree_depth);
698 Ok(None)
699 }
700 }
701
702 #[tokio::test]
703 async fn load_bundle_uses_default_graph_traversal_depth() {
704 let depths = Arc::new(Mutex::new(Vec::new()));
705 let reader = NodeCentricProjectionReader::new(
706 RecordingGraphReader {
707 depths: Arc::clone(&depths),
708 },
709 StubDetailReader,
710 );
711
712 let (bundle, _timing) = reader
713 .load_bundle("node-root", "developer", "0.1.0")
714 .await
715 .expect("bundle load should succeed");
716
717 assert!(bundle.is_none());
718 assert_eq!(
719 &*depths.lock().await,
720 &[DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH]
721 );
722 }
723
724 #[tokio::test]
725 async fn load_context_path_bundle_allows_zero_subtree_depth() {
726 let depths = Arc::new(Mutex::new(Vec::new()));
727 let reader = NodeCentricProjectionReader::new(
728 RecordingGraphReader {
729 depths: Arc::clone(&depths),
730 },
731 StubDetailReader,
732 );
733
734 let (bundle, _timing) = reader
735 .load_context_path_bundle_with_depth("node-root", "node-1", "developer", "0.1.0", 0)
736 .await
737 .expect("path bundle load should not reject zero subtree depth");
738
739 assert!(bundle.is_none());
740 assert_eq!(&*depths.lock().await, &[0]);
741 }
742
743 #[tokio::test]
744 async fn load_context_path_bundle_only_includes_details_for_path_nodes() {
745 let reader = NodeCentricProjectionReader::new(StubGraphReader, StubDetailReader);
746 let (bundle, _timing) = reader
747 .load_context_path_bundle_with_depth("node-root", "node-1", "developer", "0.1.0", 4)
748 .await
749 .expect("path bundle load should succeed");
750 let bundle = bundle.expect("bundle should exist");
751
752 assert_eq!(bundle.root_node().node_id(), "node-root");
753 assert_eq!(bundle.neighbor_nodes().len(), 2);
754 assert_eq!(bundle.relationships().len(), 2);
755 assert_eq!(
756 bundle
757 .node_details()
758 .iter()
759 .map(|detail| detail.node_id())
760 .collect::<Vec<_>>(),
761 vec!["node-root"]
762 );
763 }
764
765 fn structural_explanation() -> RelationExplanation {
766 RelationExplanation::new(RelationSemanticClass::Structural)
767 }
768
769 #[tokio::test]
770 async fn load_bundles_for_roles_returns_none_when_node_missing() {
771 let reader = NodeCentricProjectionReader::new(
772 RecordingGraphReader {
773 depths: Arc::new(Mutex::new(Vec::new())),
774 },
775 StubDetailReader,
776 );
777
778 let (result, _timing) = reader
779 .load_bundles_for_roles("nonexistent", &["dev".to_string()], "0.1.0", 3)
780 .await
781 .expect("should not error");
782
783 assert!(result.is_none());
784 }
785
786 #[tokio::test]
787 async fn load_bundles_for_roles_single_role_matches_load_bundle() {
788 let reader = NodeCentricProjectionReader::new(StubGraphReader, StubDetailReader);
789
790 let (single, _timing) = reader
791 .load_bundle("node-root", "developer", "0.1.0")
792 .await
793 .expect("single load should succeed");
794 let single = single.expect("bundle should exist");
795
796 let (multi, _timing) = reader
797 .load_bundles_for_roles(
798 "node-root",
799 &["developer".to_string()],
800 "0.1.0",
801 DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH,
802 )
803 .await
804 .expect("multi load should succeed");
805 let multi = multi.expect("bundles should exist");
806
807 assert_eq!(multi.len(), 1);
808 assert_eq!(multi[0].root_node().node_id(), single.root_node().node_id());
809 assert_eq!(
810 multi[0].neighbor_nodes().len(),
811 single.neighbor_nodes().len()
812 );
813 assert_eq!(multi[0].relationships().len(), single.relationships().len());
814 assert_eq!(multi[0].node_details().len(), single.node_details().len());
815 }
816
817 #[tokio::test]
818 async fn load_bundles_for_roles_produces_correct_count() {
819 let reader = NodeCentricProjectionReader::new(
820 StubGraphReader,
821 CountingDetailReader {
822 call_count: Arc::new(Mutex::new(0)),
823 },
824 );
825
826 let (bundles, _timing) = reader
827 .load_bundles_for_roles(
828 "node-root",
829 &["dev".to_string(), "reviewer".to_string(), "ops".to_string()],
830 "0.1.0",
831 DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH,
832 )
833 .await
834 .expect("should succeed");
835 let bundles = bundles.expect("bundles should exist");
836
837 assert_eq!(bundles.len(), 3);
838 }
839
840 struct CountingDetailReader {
841 call_count: Arc<Mutex<u32>>,
842 }
843
844 impl kmp_domain::NodeDetailReader for CountingDetailReader {
845 async fn load_node_detail(
846 &self,
847 node_id: &str,
848 ) -> Result<Option<NodeDetailProjection>, PortError> {
849 Ok(Some(NodeDetailProjection {
850 node_id: node_id.to_string(),
851 detail: "detail".to_string(),
852 content_hash: "hash".to_string(),
853 revision: 1,
854 }))
855 }
856
857 async fn load_node_details_batch(
858 &self,
859 node_ids: Vec<String>,
860 ) -> Result<Vec<Option<NodeDetailProjection>>, PortError> {
861 *self.call_count.lock().await += 1;
862 let mut results = Vec::with_capacity(node_ids.len());
863 for node_id in &node_ids {
864 results.push(self.load_node_detail(node_id).await?);
865 }
866 Ok(results)
867 }
868 }
869
870 #[tokio::test]
871 async fn batch_detail_reader_called_once_for_multi_role() {
872 let call_count = Arc::new(Mutex::new(0u32));
873 let reader = NodeCentricProjectionReader::new(
874 StubGraphReader,
875 CountingDetailReader {
876 call_count: Arc::clone(&call_count),
877 },
878 );
879
880 let (bundles, timing) = reader
881 .load_bundles_for_roles(
882 "node-root",
883 &["dev".to_string(), "reviewer".to_string()],
884 "0.1.0",
885 DEFAULT_NATIVE_GRAPH_TRAVERSAL_DEPTH,
886 )
887 .await
888 .expect("should succeed");
889 let bundles = bundles.expect("bundles should exist");
890
891 assert_eq!(bundles.len(), 2);
892 assert_eq!(*call_count.lock().await, 1);
894 assert_eq!(timing.role_count, 2);
895 assert!(timing.batch_size > 0);
896 }
897
898 struct NoDetailReader;
899
900 impl kmp_domain::NodeDetailReader for NoDetailReader {
901 async fn load_node_detail(
902 &self,
903 _node_id: &str,
904 ) -> Result<Option<NodeDetailProjection>, PortError> {
905 Ok(None)
906 }
907
908 async fn load_node_details_batch(
909 &self,
910 node_ids: Vec<String>,
911 ) -> Result<Vec<Option<NodeDetailProjection>>, PortError> {
912 Ok(vec![None; node_ids.len()])
913 }
914 }
915
916 #[tokio::test]
917 async fn load_bundle_with_no_details_returns_empty_details() {
918 let reader = NodeCentricProjectionReader::new(StubGraphReader, NoDetailReader);
919
920 let (bundle, _timing) = reader
921 .load_bundle("node-root", "developer", "0.1.0")
922 .await
923 .expect("should succeed");
924 let bundle = bundle.expect("bundle should exist");
925
926 assert_eq!(bundle.root_node().node_id(), "node-root");
927 assert_eq!(bundle.neighbor_nodes().len(), 1);
928 assert!(bundle.node_details().is_empty());
929 }
930}