Skip to main content

kmp_application/queries/
render_graph_bundle.rs

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    /// Source node or relationship ID that originated this section.
19    pub source_id: String,
20}
21
22/// A rendered tier with its sections and token count.
23#[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    /// SHA-256 hash of `content` — verifies the LLM received exactly what the kernel rendered.
35    pub content_hash: String,
36    pub token_count: u32,
37    pub sections: Vec<RenderedSection>,
38    pub truncation: Option<TruncationMetadata>,
39    /// Multi-resolution tiers (L0 Summary, L1 Causal Spine, L2 Evidence Pack).
40    pub tiers: Vec<RenderedTier>,
41    /// The mode that was actually used for tiered rendering.
42    pub resolved_mode: KmpMode,
43    /// Quality and efficiency metrics for this render.
44    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    // ── Resolve mode first (needed by both flat and tiered paths) ──
74    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    // ── Flat rendering (tier-aware truncation) ─────────────────────
83    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    // ── Tiered rendering ────────────────────────────────────────────
108    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    // ── Quality metrics (domain value object) ────────────────────────
117    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
189/// Deterministic SHA-256 hash of rendered content for audit trail.
190/// Stable across process restarts, machines, and Rust versions.
191fn 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    /// Budget so tight it forces truncation on any multi-section bundle.
208    const TINY_BUDGET: u32 = 10;
209    /// Generous budget that fits the sample bundle without truncation.
210    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        // Use a causal bundle so the planner stays ReasonPreserving at generous budget
450        // (structural bundles now trigger ResumeFocused which drops L2).
451        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        // Causal relationship must appear before structural in rendered output
510        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            // Whitespace count and cl100k_base count differ for most text.
531            // The important invariant: token_count is computed by cl100k_base,
532            // NOT by split_whitespace. For structured text like "Node Root (case):
533            // Root summary", cl100k_base produces fewer tokens than words.
534            let whitespace_count = section.content.split_whitespace().count() as u32;
535            // cl100k_base should differ from whitespace count for most sections
536            // (they're not equal for structured text). At minimum, token_count > 0.
537            assert!(
538                section.token_count > 0,
539                "section token_count should be positive"
540            );
541            // The key test: the section token_count should NOT equal whitespace count
542            // for sections with punctuation (which is most of ours)
543            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                    &section.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        // With budget=200, L0 gets ~100, L1 gets ~100 — L1 should be truncated
637        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    // ── Quality metrics tests ───────────────────────────────────────────
722
723    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        // 1 Causal out of 2 total → 0.5
810        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        // 1 noise node out of 3 total (root + 2 neighbors) → 1/3
822        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        // 1 detail (root) out of 3 nodes → 1/3
836        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}