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, 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    /// Loads the bundle for a request that already carries the axes the caller
69    /// resolved, so a store able to narrow on them does not have to be asked
70    /// for what will be thrown away.
71    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        // Batch is called once (shared load), not once per role.
908        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}