Skip to main content

kmp_application/queries/
node_centric_projection_reader.rs

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        // Batch is called once (shared load), not once per role.
893        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}