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