1use std::collections::BTreeMap;
2
3use kmp_domain::{
4 BundleQualityMetrics, KmpBundle, KmpMode, ResolutionTier, TierBudget, TokenEstimator,
5};
6
7use crate::queries::ContextRenderOptions;
8use crate::queries::bundle_section_renderer::ordered_sections;
9use crate::queries::bundle_truncator::{TruncationMetadata, limit_sections_by_tier_budget};
10use crate::queries::cl100k_estimator::Cl100kEstimator;
11use crate::queries::mode_heuristic::resolve_mode;
12use crate::queries::tier_section_classifier::classify_into_tiers;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct RenderedSection {
16 pub content: String,
17 pub token_count: u32,
18 pub source_id: String,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct RenderedTier {
25 pub tier: ResolutionTier,
26 pub content: String,
27 pub token_count: u32,
28 pub sections: Vec<RenderedSection>,
29}
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct RenderedContext {
33 pub content: String,
34 pub content_hash: String,
36 pub token_count: u32,
37 pub sections: Vec<RenderedSection>,
38 pub truncation: Option<TruncationMetadata>,
39 pub tiers: Vec<RenderedTier>,
41 pub resolved_mode: KmpMode,
43 pub quality: BundleQualityMetrics,
45}
46
47pub fn render_graph_bundle(bundle: &KmpBundle) -> RenderedContext {
48 render_graph_bundle_with_estimator(
49 bundle,
50 &ContextRenderOptions::default(),
51 &Cl100kEstimator::new(),
52 )
53}
54
55pub fn render_graph_bundle_with_options(
56 bundle: &KmpBundle,
57 options: &ContextRenderOptions,
58) -> RenderedContext {
59 render_graph_bundle_with_estimator(bundle, options, &Cl100kEstimator::new())
60}
61
62pub fn render_graph_bundle_with_estimator(
63 bundle: &KmpBundle,
64 options: &ContextRenderOptions,
65 estimator: &dyn TokenEstimator,
66) -> RenderedContext {
67 let detail_by_node_id = bundle
68 .node_details()
69 .iter()
70 .map(|detail| (detail.node_id(), detail))
71 .collect::<BTreeMap<_, _>>();
72
73 let resolved_mode = resolve_mode(
75 options.rehydration_mode,
76 bundle,
77 options.token_budget,
78 options.focus_node_id.as_deref(),
79 options.endpoint_hint,
80 );
81
82 let all_sections = ordered_sections(bundle, &detail_by_node_id, options);
84
85 let (section_pairs, truncation) =
86 limit_sections_by_tier_budget(all_sections, options.token_budget, resolved_mode, estimator);
87
88 let content = section_pairs
89 .iter()
90 .map(|(s, _)| s.as_str())
91 .collect::<Vec<_>>()
92 .join("\n\n");
93 let token_count = estimator.estimate_tokens(&content);
94
95 let sections = section_pairs
96 .into_iter()
97 .map(|(s, source_id)| {
98 let tc = estimator.estimate_tokens(&s);
99 RenderedSection {
100 content: s,
101 token_count: tc,
102 source_id,
103 }
104 })
105 .collect();
106
107 let tiered_sections = classify_into_tiers(bundle, &detail_by_node_id, options, resolved_mode);
109 let tier_budget = options
110 .token_budget
111 .map(|total| TierBudget::from_total_with_mode(total, resolved_mode))
112 .unwrap_or_else(TierBudget::unlimited);
113
114 let tiers = build_rendered_tiers(tiered_sections, &tier_budget, estimator);
115
116 let quality = BundleQualityMetrics::compute(bundle, token_count, estimator);
118
119 let content_hash = render_content_hash(&content);
120
121 RenderedContext {
122 content,
123 content_hash,
124 token_count,
125 sections,
126 truncation,
127 tiers,
128 resolved_mode,
129 quality,
130 }
131}
132
133fn build_rendered_tiers(
134 tiered_sections: Vec<crate::queries::tier_section_classifier::TieredSection>,
135 budget: &TierBudget,
136 estimator: &dyn TokenEstimator,
137) -> Vec<RenderedTier> {
138 let mut tiers = Vec::new();
139
140 for &tier in ResolutionTier::all() {
141 let tier_budget = match tier {
142 ResolutionTier::L0Summary => budget.l0,
143 ResolutionTier::L1CausalSpine => budget.l1,
144 ResolutionTier::L2EvidencePack => budget.l2,
145 };
146
147 let mut tier_sections = Vec::new();
148 let mut tier_tokens = 0u32;
149
150 for ts in &tiered_sections {
151 if ts.tier != tier {
152 continue;
153 }
154 let section_tokens = estimator.estimate_tokens(&ts.content);
155 if tier_budget < u32::MAX
156 && !tier_sections.is_empty()
157 && tier_tokens + section_tokens > tier_budget
158 {
159 break;
160 }
161 tier_tokens += section_tokens;
162 tier_sections.push(RenderedSection {
163 content: ts.content.clone(),
164 token_count: section_tokens,
165 source_id: format!("tier:{}", ts.tier.as_str()),
166 });
167 }
168
169 if !tier_sections.is_empty() {
170 let tier_content = tier_sections
171 .iter()
172 .map(|s| s.content.as_str())
173 .collect::<Vec<_>>()
174 .join("\n\n");
175 let actual_tokens = estimator.estimate_tokens(&tier_content);
176
177 tiers.push(RenderedTier {
178 tier,
179 content: tier_content,
180 token_count: actual_tokens,
181 sections: tier_sections,
182 });
183 }
184 }
185
186 tiers
187}
188
189fn render_content_hash(content: &str) -> String {
192 use sha2::{Digest, Sha256};
193 let hash = Sha256::digest(content.as_bytes());
194 format!("render:{:064x}", hash)
195}
196
197#[cfg(test)]
198mod tests {
199 use std::collections::BTreeMap;
200
201 use kmp_domain::{
202 BundleMetadata, BundleNode, BundleNodeDetail, BundleQualityMetrics, BundleRelationship,
203 CaseId, KmpBundle, Provenance, RelationExplanation, RelationSemanticClass, ResolutionTier,
204 Role, SourceKind,
205 };
206
207 const TINY_BUDGET: u32 = 10;
209 const GENEROUS_BUDGET: u32 = 1000;
211
212 use crate::queries::ContextRenderOptions;
213
214 use super::{render_graph_bundle, render_graph_bundle_with_options};
215
216 #[test]
217 fn render_graph_bundle_orders_root_relationships_neighbors_and_details() {
218 let bundle = KmpBundle::new(
219 CaseId::new("case-123").expect("case id is valid"),
220 Role::new("developer").expect("role is valid"),
221 BundleNode::new(
222 "case-123",
223 "case",
224 "Root",
225 "Root summary",
226 "ACTIVE",
227 vec![],
228 BTreeMap::new(),
229 ),
230 vec![BundleNode::new(
231 "node-1",
232 "decision",
233 "Neighbor",
234 "Neighbor summary",
235 "ACTIVE",
236 vec![],
237 BTreeMap::new(),
238 )],
239 vec![BundleRelationship::new(
240 "case-123",
241 "node-1",
242 "RELATES_TO",
243 RelationExplanation::new(RelationSemanticClass::Structural),
244 )],
245 vec![BundleNodeDetail::new(
246 "case-123",
247 "Expanded detail",
248 "hash-1",
249 2,
250 )],
251 BundleMetadata::initial("0.1.0"),
252 )
253 .expect("bundle should be valid");
254
255 let rendered = render_graph_bundle(&bundle);
256
257 assert_eq!(rendered.sections.len(), 4);
258 assert!(rendered.sections[0].content.starts_with("Node Root"));
259 assert!(rendered.sections[1].content.starts_with("Relationship"));
260 assert!(rendered.sections[2].content.starts_with("Node Neighbor"));
261 assert!(rendered.sections[3].content.starts_with("Detail case-123"));
262 }
263
264 #[test]
265 fn render_graph_bundle_prioritizes_focused_node_sections() {
266 let bundle = sample_bundle();
267
268 let rendered = render_graph_bundle_with_options(
269 &bundle,
270 &ContextRenderOptions {
271 focus_node_id: Some("node-2".to_string()),
272 token_budget: None,
273 ..Default::default()
274 },
275 );
276
277 assert!(rendered.sections[0].content.starts_with("Node Root"));
278 assert!(rendered.sections[1].content.starts_with("Node Focused"));
279 assert!(rendered.sections[2].content.contains("node-2"));
280 }
281
282 #[test]
283 fn render_graph_bundle_respects_token_budget_after_reordering() {
284 let bundle = sample_bundle();
285
286 let rendered = render_graph_bundle_with_options(
287 &bundle,
288 &ContextRenderOptions {
289 focus_node_id: Some("node-2".to_string()),
290 token_budget: Some(TINY_BUDGET),
291 ..Default::default()
292 },
293 );
294
295 assert!(
296 rendered.sections.len() < 7,
297 "budget should truncate sections"
298 );
299 assert!(rendered.content.starts_with("Node Root"));
300 let truncation = rendered
301 .truncation
302 .as_ref()
303 .expect("should have truncation metadata");
304 assert_eq!(truncation.budget_requested, TINY_BUDGET);
305 assert!(truncation.sections_dropped > 0);
306 assert_eq!(truncation.token_estimator, "cl100k_base");
307 }
308
309 #[test]
310 fn render_graph_bundle_uses_cl100k_base_estimator() {
311 let bundle = KmpBundle::new(
312 CaseId::new("case-1").expect("case id is valid"),
313 Role::new("dev").expect("role is valid"),
314 BundleNode::new(
315 "case-1",
316 "case",
317 "Root",
318 "",
319 "ACTIVE",
320 vec![],
321 BTreeMap::new(),
322 ),
323 Vec::new(),
324 Vec::new(),
325 Vec::new(),
326 BundleMetadata::initial("0.1.0"),
327 )
328 .expect("bundle should be valid");
329
330 let rendered = render_graph_bundle(&bundle);
331 assert!(rendered.token_count > 0);
332 assert!(rendered.token_count < 20);
333 }
334
335 fn sample_bundle() -> KmpBundle {
336 KmpBundle::new(
337 CaseId::new("case-123").expect("case id is valid"),
338 Role::new("developer").expect("role is valid"),
339 BundleNode::new(
340 "case-123",
341 "case",
342 "Root",
343 "Root summary",
344 "ACTIVE",
345 vec![],
346 BTreeMap::new(),
347 ),
348 vec![
349 BundleNode::new(
350 "node-1",
351 "decision",
352 "Neighbor",
353 "Neighbor summary",
354 "ACTIVE",
355 vec![],
356 BTreeMap::new(),
357 ),
358 BundleNode::new(
359 "node-2",
360 "task",
361 "Focused",
362 "Focused summary",
363 "READY",
364 vec![],
365 BTreeMap::new(),
366 ),
367 ],
368 vec![
369 BundleRelationship::new(
370 "case-123",
371 "node-1",
372 "RELATES_TO",
373 RelationExplanation::new(RelationSemanticClass::Structural),
374 ),
375 BundleRelationship::new(
376 "case-123",
377 "node-2",
378 "HAS_TASK",
379 RelationExplanation::new(RelationSemanticClass::Structural),
380 ),
381 ],
382 vec![
383 BundleNodeDetail::new("case-123", "Expanded detail", "hash-1", 2),
384 BundleNodeDetail::new("node-2", "Focused detail", "hash-2", 3),
385 ],
386 BundleMetadata::initial("0.1.0"),
387 )
388 .expect("bundle should be valid")
389 }
390
391 #[test]
392 fn render_graph_bundle_includes_explanatory_relation_metadata() {
393 let bundle = KmpBundle::new(
394 CaseId::new("case-123").expect("case id is valid"),
395 Role::new("developer").expect("role is valid"),
396 BundleNode::new(
397 "case-123",
398 "case",
399 "Root",
400 "Root summary",
401 "ACTIVE",
402 vec![],
403 BTreeMap::new(),
404 ),
405 vec![BundleNode::new(
406 "node-1",
407 "task",
408 "Neighbor",
409 "Neighbor summary",
410 "ACTIVE",
411 vec![],
412 BTreeMap::new(),
413 )],
414 vec![BundleRelationship::new(
415 "case-123",
416 "node-1",
417 "AUTHORIZES",
418 RelationExplanation::new(RelationSemanticClass::Motivational)
419 .with_rationale("reserve power must be diverted before repair")
420 .with_decision_id("decision-1")
421 .with_sequence(1),
422 )],
423 Vec::new(),
424 BundleMetadata::initial("0.1.0"),
425 )
426 .expect("bundle should be valid");
427
428 let rendered = render_graph_bundle(&bundle);
429
430 assert!(rendered.content.contains("[motivational]"));
431 assert!(
432 rendered
433 .content
434 .contains("because reserve power must be diverted before repair")
435 );
436 assert!(rendered.content.contains("decision=decision-1"));
437 assert!(rendered.content.contains("step=1"));
438 }
439
440 #[test]
441 fn render_without_budget_has_no_truncation_metadata() {
442 let bundle = sample_bundle();
443 let rendered = render_graph_bundle(&bundle);
444 assert!(rendered.truncation.is_none());
445 }
446
447 #[test]
448 fn render_with_budget_reports_truncation_metadata() {
449 let bundle = quality_bundle();
452 let rendered = render_graph_bundle_with_options(
453 &bundle,
454 &ContextRenderOptions {
455 focus_node_id: None,
456 token_budget: Some(GENEROUS_BUDGET),
457 ..Default::default()
458 },
459 );
460 let truncation = rendered
461 .truncation
462 .expect("budget should produce truncation");
463 assert_eq!(truncation.budget_requested, GENEROUS_BUDGET);
464 assert_eq!(truncation.sections_dropped, 0);
465 assert_eq!(truncation.token_estimator, "cl100k_base");
466 assert!(truncation.budget_used <= GENEROUS_BUDGET);
467 }
468
469 #[test]
470 fn causal_relationships_render_before_structural() {
471 let bundle = KmpBundle::new(
472 CaseId::new("root").expect("valid"),
473 Role::new("dev").expect("valid"),
474 BundleNode::new(
475 "root",
476 "case",
477 "Root",
478 "",
479 "ACTIVE",
480 vec![],
481 BTreeMap::new(),
482 ),
483 vec![
484 BundleNode::new("a", "task", "A", "", "ACTIVE", vec![], BTreeMap::new()),
485 BundleNode::new("b", "task", "B", "", "ACTIVE", vec![], BTreeMap::new()),
486 ],
487 vec![
488 BundleRelationship::new(
489 "root",
490 "a",
491 "CONTAINS",
492 RelationExplanation::new(RelationSemanticClass::Structural),
493 ),
494 BundleRelationship::new(
495 "root",
496 "b",
497 "CAUSED",
498 RelationExplanation::new(RelationSemanticClass::Causal)
499 .with_rationale("failure triggered reroute"),
500 ),
501 ],
502 Vec::new(),
503 BundleMetadata::initial("0.1.0"),
504 )
505 .expect("valid");
506
507 let rendered = render_graph_bundle(&bundle);
508
509 let causal_pos = rendered
511 .content
512 .find("[causal]")
513 .expect("causal should be present");
514 let structural_pos = rendered
515 .content
516 .find("[structural]")
517 .expect("structural should be present");
518 assert!(
519 causal_pos < structural_pos,
520 "causal ({causal_pos}) must render before structural ({structural_pos})"
521 );
522 }
523
524 #[test]
525 fn section_token_counts_use_cl100k_base_not_whitespace() {
526 let bundle = sample_bundle();
527 let rendered = render_graph_bundle(&bundle);
528
529 for section in &rendered.sections {
530 let whitespace_count = section.content.split_whitespace().count() as u32;
535 assert!(
538 section.token_count > 0,
539 "section token_count should be positive"
540 );
541 if section.content.contains('(') || section.content.contains('[') {
544 assert_ne!(
545 section.token_count,
546 whitespace_count,
547 "section '{}' token_count {} should differ from whitespace count {} \
548 (proves cl100k_base, not split_whitespace)",
549 §ion.content[..section.content.len().min(40)],
550 section.token_count,
551 whitespace_count
552 );
553 }
554 }
555 }
556
557 #[test]
558 fn render_produces_three_tiers() {
559 let bundle = sample_bundle();
560 let rendered = render_graph_bundle(&bundle);
561
562 assert!(
563 rendered.tiers.len() >= 2,
564 "should have at least L0 and L1 tiers, got {}",
565 rendered.tiers.len()
566 );
567 assert_eq!(rendered.tiers[0].tier, ResolutionTier::L0Summary);
568 assert!(rendered.tiers[0].content.contains("Objective:"));
569 }
570
571 #[test]
572 fn tiers_and_flat_content_are_both_populated() {
573 let bundle = sample_bundle();
574 let rendered = render_graph_bundle(&bundle);
575
576 assert!(!rendered.content.is_empty());
577 assert!(!rendered.tiers.is_empty());
578 assert!(rendered.token_count > 0);
579 for tier in &rendered.tiers {
580 assert!(tier.token_count > 0);
581 assert!(!tier.sections.is_empty());
582 }
583 }
584
585 #[test]
586 fn max_tier_l0_only_produces_single_tier() {
587 let bundle = sample_bundle();
588 let rendered = render_graph_bundle_with_options(
589 &bundle,
590 &ContextRenderOptions {
591 max_tier: Some(ResolutionTier::L0Summary),
592 ..Default::default()
593 },
594 );
595
596 let tier_types: Vec<_> = rendered.tiers.iter().map(|t| t.tier).collect();
597 assert_eq!(tier_types, vec![ResolutionTier::L0Summary]);
598 }
599
600 #[test]
601 fn max_tier_l1_excludes_evidence_pack() {
602 let bundle = sample_bundle();
603 let rendered = render_graph_bundle_with_options(
604 &bundle,
605 &ContextRenderOptions {
606 max_tier: Some(ResolutionTier::L1CausalSpine),
607 ..Default::default()
608 },
609 );
610
611 assert!(
612 rendered
613 .tiers
614 .iter()
615 .all(|t| t.tier != ResolutionTier::L2EvidencePack)
616 );
617 assert!(
618 rendered
619 .tiers
620 .iter()
621 .any(|t| t.tier == ResolutionTier::L1CausalSpine)
622 );
623 }
624
625 #[test]
626 fn tier_budget_constrains_l1_token_count() {
627 let bundle = sample_bundle();
628 let rendered = render_graph_bundle_with_options(
629 &bundle,
630 &ContextRenderOptions {
631 token_budget: Some(200),
632 ..Default::default()
633 },
634 );
635
636 if let Some(l1) = rendered
638 .tiers
639 .iter()
640 .find(|t| t.tier == ResolutionTier::L1CausalSpine)
641 {
642 assert!(
643 l1.token_count <= 120,
644 "L1 should be constrained by tier budget, got {} tokens",
645 l1.token_count
646 );
647 }
648 }
649
650 #[test]
651 fn render_includes_provenance_when_present() {
652 let bundle = KmpBundle::new(
653 CaseId::new("case-1").expect("valid"),
654 Role::new("dev").expect("valid"),
655 BundleNode::new(
656 "case-1",
657 "incident",
658 "Root",
659 "Outage",
660 "ACTIVE",
661 vec![],
662 BTreeMap::new(),
663 )
664 .with_provenance(
665 Provenance::new(SourceKind::Agent)
666 .with_source_agent("diagnostics-agent")
667 .with_observed_at("2026-03-25T14:00:00Z"),
668 ),
669 Vec::new(),
670 Vec::new(),
671 Vec::new(),
672 BundleMetadata::initial("0.1.0"),
673 )
674 .expect("valid");
675
676 let rendered = render_graph_bundle(&bundle);
677
678 assert!(
679 rendered.content.contains("[source:agent"),
680 "rendered should include provenance source kind"
681 );
682 assert!(
683 rendered.content.contains("agent=diagnostics-agent"),
684 "rendered should include source agent"
685 );
686 assert!(
687 rendered.content.contains("observed=2026-03-25T14:00:00Z"),
688 "rendered should include observed_at"
689 );
690 }
691
692 #[test]
693 fn render_omits_provenance_when_absent() {
694 let bundle = KmpBundle::new(
695 CaseId::new("case-1").expect("valid"),
696 Role::new("dev").expect("valid"),
697 BundleNode::new(
698 "case-1",
699 "incident",
700 "Root",
701 "Outage",
702 "ACTIVE",
703 vec![],
704 BTreeMap::new(),
705 ),
706 Vec::new(),
707 Vec::new(),
708 Vec::new(),
709 BundleMetadata::initial("0.1.0"),
710 )
711 .expect("valid");
712
713 let rendered = render_graph_bundle(&bundle);
714
715 assert!(
716 !rendered.content.contains("[source:"),
717 "rendered should NOT include provenance bracket when absent"
718 );
719 }
720
721 fn quality_bundle() -> KmpBundle {
724 KmpBundle::new(
725 CaseId::new("root").expect("valid"),
726 Role::new("dev").expect("valid"),
727 BundleNode::new(
728 "root",
729 "incident",
730 "Root",
731 "Root summary",
732 "ACTIVE",
733 vec![],
734 BTreeMap::new(),
735 ),
736 vec![
737 BundleNode::new(
738 "node-a",
739 "decision",
740 "Decision A",
741 "Decision summary",
742 "ACTIVE",
743 vec![],
744 BTreeMap::new(),
745 ),
746 BundleNode::new(
747 "noise-1",
748 "task",
749 "Noise node",
750 "Distractor summary",
751 "ACTIVE",
752 vec![],
753 BTreeMap::new(),
754 ),
755 ],
756 vec![
757 BundleRelationship::new(
758 "root",
759 "node-a",
760 "CAUSED",
761 RelationExplanation::new(RelationSemanticClass::Causal)
762 .with_rationale("failure triggered reroute")
763 .with_caused_by_node_id("root"),
764 ),
765 BundleRelationship::new(
766 "root",
767 "noise-1",
768 "CONTAINS",
769 RelationExplanation::new(RelationSemanticClass::Structural),
770 ),
771 ],
772 vec![BundleNodeDetail::new(
773 "root",
774 "Extended root detail",
775 "hash-r",
776 1,
777 )],
778 BundleMetadata::initial("0.1.0"),
779 )
780 .expect("valid")
781 }
782
783 #[test]
784 fn quality_raw_equivalent_tokens_is_positive() {
785 let rendered = render_graph_bundle(&quality_bundle());
786 assert!(
787 rendered.quality.raw_equivalent_tokens() > 0,
788 "raw_equivalent_tokens should be positive"
789 );
790 }
791
792 #[test]
793 fn quality_compression_ratio_reflects_raw_vs_rendered() {
794 let rendered = render_graph_bundle(&quality_bundle());
795 let expected =
796 rendered.quality.raw_equivalent_tokens() as f64 / rendered.token_count as f64;
797 let diff = (rendered.quality.compression_ratio() - expected).abs();
798 assert!(
799 diff < 0.001,
800 "compression_ratio {:.4} should equal raw/rendered {:.4}",
801 rendered.quality.compression_ratio(),
802 expected
803 );
804 }
805
806 #[test]
807 fn quality_causal_density_counts_explanatory_relations() {
808 let rendered = render_graph_bundle(&quality_bundle());
809 let diff = (rendered.quality.causal_density() - 0.5).abs();
811 assert!(
812 diff < 0.001,
813 "causal_density should be 0.5, got {:.4}",
814 rendered.quality.causal_density()
815 );
816 }
817
818 #[test]
819 fn quality_noise_ratio_detects_noise_nodes() {
820 let rendered = render_graph_bundle(&quality_bundle());
821 let expected = 1.0 / 3.0;
823 let diff = (rendered.quality.noise_ratio() - expected).abs();
824 assert!(
825 diff < 0.001,
826 "noise_ratio should be {:.4}, got {:.4}",
827 expected,
828 rendered.quality.noise_ratio()
829 );
830 }
831
832 #[test]
833 fn quality_detail_coverage_tracks_detail_presence() {
834 let rendered = render_graph_bundle(&quality_bundle());
835 let expected = 1.0 / 3.0;
837 let diff = (rendered.quality.detail_coverage() - expected).abs();
838 assert!(
839 diff < 0.001,
840 "detail_coverage should be {:.4}, got {:.4}",
841 expected,
842 rendered.quality.detail_coverage()
843 );
844 }
845
846 #[test]
847 fn quality_raw_text_includes_caused_by_node_id() {
848 use crate::queries::cl100k_estimator::Cl100kEstimator;
849
850 let bundle = quality_bundle();
851 let estimator = Cl100kEstimator::new();
852 let metrics = BundleQualityMetrics::compute(&bundle, 100, &estimator);
853
854 let bundle_no_caused_by = KmpBundle::new(
855 CaseId::new("root").expect("valid"),
856 Role::new("dev").expect("valid"),
857 BundleNode::new(
858 "root",
859 "incident",
860 "Root",
861 "Root summary",
862 "ACTIVE",
863 vec![],
864 BTreeMap::new(),
865 ),
866 vec![BundleNode::new(
867 "node-a",
868 "decision",
869 "Decision A",
870 "Decision summary",
871 "ACTIVE",
872 vec![],
873 BTreeMap::new(),
874 )],
875 vec![BundleRelationship::new(
876 "root",
877 "node-a",
878 "CAUSED",
879 RelationExplanation::new(RelationSemanticClass::Causal)
880 .with_rationale("failure triggered reroute"),
881 )],
882 vec![BundleNodeDetail::new(
883 "root",
884 "Extended root detail",
885 "hash-r",
886 1,
887 )],
888 BundleMetadata::initial("0.1.0"),
889 )
890 .expect("valid");
891
892 let metrics2 = BundleQualityMetrics::compute(&bundle_no_caused_by, 100, &estimator);
893
894 assert!(
895 metrics.raw_equivalent_tokens() > metrics2.raw_equivalent_tokens(),
896 "caused_by_node_id should increase raw tokens: with={} without={}",
897 metrics.raw_equivalent_tokens(),
898 metrics2.raw_equivalent_tokens()
899 );
900 }
901
902 #[test]
903 fn quality_all_causal_density_is_one() {
904 let bundle = KmpBundle::new(
905 CaseId::new("root").expect("valid"),
906 Role::new("dev").expect("valid"),
907 BundleNode::new(
908 "root",
909 "case",
910 "Root",
911 "",
912 "ACTIVE",
913 vec![],
914 BTreeMap::new(),
915 ),
916 vec![
917 BundleNode::new("a", "task", "A", "", "ACTIVE", vec![], BTreeMap::new()),
918 BundleNode::new("b", "task", "B", "", "ACTIVE", vec![], BTreeMap::new()),
919 ],
920 vec![
921 BundleRelationship::new(
922 "root",
923 "a",
924 "CAUSED",
925 RelationExplanation::new(RelationSemanticClass::Causal),
926 ),
927 BundleRelationship::new(
928 "root",
929 "b",
930 "JUSTIFIED",
931 RelationExplanation::new(RelationSemanticClass::Evidential),
932 ),
933 ],
934 Vec::new(),
935 BundleMetadata::initial("0.1.0"),
936 )
937 .expect("valid");
938
939 let rendered = render_graph_bundle(&bundle);
940 let diff = (rendered.quality.causal_density() - 1.0).abs();
941 assert!(diff < 0.001, "all-causal density should be 1.0");
942 }
943
944 #[test]
945 fn quality_no_relationships_has_zero_causal_density() {
946 let bundle = KmpBundle::new(
947 CaseId::new("root").expect("valid"),
948 Role::new("dev").expect("valid"),
949 BundleNode::new(
950 "root",
951 "case",
952 "Root",
953 "",
954 "ACTIVE",
955 vec![],
956 BTreeMap::new(),
957 ),
958 Vec::new(),
959 Vec::new(),
960 Vec::new(),
961 BundleMetadata::initial("0.1.0"),
962 )
963 .expect("valid");
964
965 let rendered = render_graph_bundle(&bundle);
966 assert!(
967 rendered.quality.causal_density().abs() < 0.001,
968 "no-relationship bundle should have 0 causal density"
969 );
970 }
971}