Skip to main content

datasynth_core/models/balance/
balance_relationship.rs

1//! Balance relationship rules and coherence validation.
2//!
3//! Defines rules for validating relationships between balance sheet and
4//! income statement items (DSO, DPO, gross margin, etc.).
5
6use rust_decimal::Decimal;
7use rust_decimal_macros::dec;
8use serde::{Deserialize, Serialize};
9
10use super::account_balance::BalanceSnapshot;
11
12/// Balance relationship rule types.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum RelationshipType {
16    /// Days Sales Outstanding (AR / Revenue * 365).
17    DaysSalesOutstanding,
18    /// Days Payable Outstanding (AP / COGS * 365).
19    DaysPayableOutstanding,
20    /// Days Inventory Outstanding (Inventory / COGS * 365).
21    DaysInventoryOutstanding,
22    /// Cash Conversion Cycle (DSO + DIO - DPO).
23    CashConversionCycle,
24    /// Gross Margin ((Revenue - COGS) / Revenue).
25    GrossMargin,
26    /// Operating Margin (Operating Income / Revenue).
27    OperatingMargin,
28    /// Net Margin (Net Income / Revenue).
29    NetMargin,
30    /// Current Ratio (Current Assets / Current Liabilities).
31    CurrentRatio,
32    /// Quick Ratio ((Current Assets - Inventory) / Current Liabilities).
33    QuickRatio,
34    /// Debt to Equity (Total Liabilities / Total Equity).
35    DebtToEquity,
36    /// Interest Coverage (EBIT / Interest Expense).
37    InterestCoverage,
38    /// Asset Turnover (Revenue / Total Assets).
39    AssetTurnover,
40    /// Return on Assets (Net Income / Total Assets).
41    ReturnOnAssets,
42    /// Return on Equity (Net Income / Total Equity).
43    ReturnOnEquity,
44    /// Depreciation to Fixed Assets (Annual Depreciation / Gross Fixed Assets).
45    DepreciationRate,
46    /// Balance Sheet Equation (Assets = Liabilities + Equity).
47    BalanceSheetEquation,
48    /// Retained Earnings Roll-forward.
49    RetainedEarningsRollForward,
50}
51
52impl RelationshipType {
53    /// Get the display name for this relationship type.
54    pub fn display_name(&self) -> &'static str {
55        match self {
56            Self::DaysSalesOutstanding => "Days Sales Outstanding",
57            Self::DaysPayableOutstanding => "Days Payable Outstanding",
58            Self::DaysInventoryOutstanding => "Days Inventory Outstanding",
59            Self::CashConversionCycle => "Cash Conversion Cycle",
60            Self::GrossMargin => "Gross Margin",
61            Self::OperatingMargin => "Operating Margin",
62            Self::NetMargin => "Net Margin",
63            Self::CurrentRatio => "Current Ratio",
64            Self::QuickRatio => "Quick Ratio",
65            Self::DebtToEquity => "Debt-to-Equity Ratio",
66            Self::InterestCoverage => "Interest Coverage Ratio",
67            Self::AssetTurnover => "Asset Turnover",
68            Self::ReturnOnAssets => "Return on Assets",
69            Self::ReturnOnEquity => "Return on Equity",
70            Self::DepreciationRate => "Depreciation Rate",
71            Self::BalanceSheetEquation => "Balance Sheet Equation",
72            Self::RetainedEarningsRollForward => "Retained Earnings Roll-forward",
73        }
74    }
75
76    /// Is this a critical validation (must pass)?
77    pub fn is_critical(&self) -> bool {
78        matches!(
79            self,
80            Self::BalanceSheetEquation | Self::RetainedEarningsRollForward
81        )
82    }
83}
84
85/// A balance relationship rule definition.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct BalanceRelationshipRule {
88    /// Rule identifier.
89    pub rule_id: String,
90    /// Rule name.
91    pub name: String,
92    /// Relationship type.
93    pub relationship_type: RelationshipType,
94    /// Target value (if applicable).
95    pub target_value: Option<Decimal>,
96    /// Minimum acceptable value.
97    pub min_value: Option<Decimal>,
98    /// Maximum acceptable value.
99    pub max_value: Option<Decimal>,
100    /// Tolerance for deviation from target.
101    pub tolerance: Decimal,
102    /// Is this rule enabled?
103    pub enabled: bool,
104    /// Severity if rule fails.
105    pub severity: RuleSeverity,
106    /// Account codes for numerator calculation.
107    pub numerator_accounts: Vec<String>,
108    /// Account codes for denominator calculation.
109    pub denominator_accounts: Vec<String>,
110    /// Multiplier (e.g., 365 for DSO).
111    pub multiplier: Decimal,
112}
113
114impl BalanceRelationshipRule {
115    /// Create a new DSO rule.
116    pub fn new_dso_rule(target_days: u32, tolerance_days: u32) -> Self {
117        Self {
118            rule_id: "DSO".to_string(),
119            name: "Days Sales Outstanding".to_string(),
120            relationship_type: RelationshipType::DaysSalesOutstanding,
121            target_value: Some(Decimal::from(target_days)),
122            min_value: Some(Decimal::from(target_days.saturating_sub(tolerance_days))),
123            max_value: Some(Decimal::from(target_days + tolerance_days)),
124            tolerance: Decimal::from(tolerance_days),
125            enabled: true,
126            severity: RuleSeverity::Warning,
127            numerator_accounts: vec!["1200".to_string()], // AR accounts
128            denominator_accounts: vec!["4100".to_string()], // Revenue accounts
129            multiplier: dec!(365),
130        }
131    }
132
133    /// Create a new DPO rule.
134    pub fn new_dpo_rule(target_days: u32, tolerance_days: u32) -> Self {
135        Self {
136            rule_id: "DPO".to_string(),
137            name: "Days Payable Outstanding".to_string(),
138            relationship_type: RelationshipType::DaysPayableOutstanding,
139            target_value: Some(Decimal::from(target_days)),
140            min_value: Some(Decimal::from(target_days.saturating_sub(tolerance_days))),
141            max_value: Some(Decimal::from(target_days + tolerance_days)),
142            tolerance: Decimal::from(tolerance_days),
143            enabled: true,
144            severity: RuleSeverity::Warning,
145            numerator_accounts: vec!["2100".to_string()], // AP accounts
146            denominator_accounts: vec!["5100".to_string()], // COGS accounts
147            multiplier: dec!(365),
148        }
149    }
150
151    /// Create a new gross margin rule.
152    pub fn new_gross_margin_rule(target_margin: Decimal, tolerance: Decimal) -> Self {
153        Self {
154            rule_id: "GROSS_MARGIN".to_string(),
155            name: "Gross Margin".to_string(),
156            relationship_type: RelationshipType::GrossMargin,
157            target_value: Some(target_margin),
158            min_value: Some(target_margin - tolerance),
159            max_value: Some(target_margin + tolerance),
160            tolerance,
161            enabled: true,
162            severity: RuleSeverity::Warning,
163            numerator_accounts: vec!["4100".to_string(), "5100".to_string()], // Revenue - COGS
164            denominator_accounts: vec!["4100".to_string()],                   // Revenue
165            multiplier: Decimal::ONE,
166        }
167    }
168
169    /// Create balance sheet equation rule.
170    pub fn new_balance_equation_rule() -> Self {
171        Self {
172            rule_id: "BS_EQUATION".to_string(),
173            name: "Balance Sheet Equation".to_string(),
174            relationship_type: RelationshipType::BalanceSheetEquation,
175            target_value: Some(Decimal::ZERO),
176            min_value: Some(dec!(-0.01)),
177            max_value: Some(dec!(0.01)),
178            tolerance: dec!(0.01),
179            enabled: true,
180            severity: RuleSeverity::Critical,
181            numerator_accounts: Vec::new(),
182            denominator_accounts: Vec::new(),
183            multiplier: Decimal::ONE,
184        }
185    }
186
187    /// Check if a calculated value is within acceptable range.
188    pub fn is_within_range(&self, value: Decimal) -> bool {
189        let within_min = self.min_value.is_none_or(|min| value >= min);
190        let within_max = self.max_value.is_none_or(|max| value <= max);
191        within_min && within_max
192    }
193
194    /// Get the deviation from target.
195    pub fn deviation_from_target(&self, value: Decimal) -> Option<Decimal> {
196        self.target_value.map(|target| value - target)
197    }
198}
199
200/// Severity level for rule violations.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
202#[serde(rename_all = "snake_case")]
203pub enum RuleSeverity {
204    /// Informational only.
205    Info,
206    /// Warning - should be investigated.
207    #[default]
208    Warning,
209    /// Error - significant issue.
210    Error,
211    /// Critical - must be resolved.
212    Critical,
213}
214
215/// Result of validating a balance relationship.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct ValidationResult {
218    /// Rule that was validated.
219    pub rule_id: String,
220    /// Rule name.
221    pub rule_name: String,
222    /// Relationship type.
223    pub relationship_type: RelationshipType,
224    /// Calculated value.
225    pub calculated_value: Decimal,
226    /// Target value.
227    pub target_value: Option<Decimal>,
228    /// Is the value within acceptable range?
229    pub is_valid: bool,
230    /// Deviation from target.
231    pub deviation: Option<Decimal>,
232    /// Deviation percentage.
233    pub deviation_percent: Option<Decimal>,
234    /// Severity.
235    pub severity: RuleSeverity,
236    /// Validation message.
237    pub message: String,
238}
239
240impl ValidationResult {
241    /// Create a passing result.
242    pub fn pass(rule: &BalanceRelationshipRule, calculated_value: Decimal) -> Self {
243        let deviation = rule.deviation_from_target(calculated_value);
244        let deviation_percent = rule.target_value.and_then(|target| {
245            if target != Decimal::ZERO {
246                Some((calculated_value - target) / target * dec!(100))
247            } else {
248                None
249            }
250        });
251
252        Self {
253            rule_id: rule.rule_id.clone(),
254            rule_name: rule.name.clone(),
255            relationship_type: rule.relationship_type,
256            calculated_value,
257            target_value: rule.target_value,
258            is_valid: true,
259            deviation,
260            deviation_percent,
261            severity: RuleSeverity::Info,
262            message: format!(
263                "{} = {:.2} (within acceptable range)",
264                rule.name, calculated_value
265            ),
266        }
267    }
268
269    /// Create a failing result.
270    pub fn fail(
271        rule: &BalanceRelationshipRule,
272        calculated_value: Decimal,
273        message: String,
274    ) -> Self {
275        let deviation = rule.deviation_from_target(calculated_value);
276        let deviation_percent = rule.target_value.and_then(|target| {
277            if target != Decimal::ZERO {
278                Some((calculated_value - target) / target * dec!(100))
279            } else {
280                None
281            }
282        });
283
284        Self {
285            rule_id: rule.rule_id.clone(),
286            rule_name: rule.name.clone(),
287            relationship_type: rule.relationship_type,
288            calculated_value,
289            target_value: rule.target_value,
290            is_valid: false,
291            deviation,
292            deviation_percent,
293            severity: rule.severity,
294            message,
295        }
296    }
297}
298
299/// Balance coherence validator.
300pub struct BalanceCoherenceValidator {
301    /// Rules to validate.
302    rules: Vec<BalanceRelationshipRule>,
303}
304
305impl BalanceCoherenceValidator {
306    /// Create a new validator with default rules.
307    pub fn new() -> Self {
308        Self { rules: Vec::new() }
309    }
310
311    /// Add a rule to the validator.
312    pub fn add_rule(&mut self, rule: BalanceRelationshipRule) {
313        self.rules.push(rule);
314    }
315
316    /// Add standard rules for an industry.
317    pub fn add_standard_rules(&mut self, target_dso: u32, target_dpo: u32, target_margin: Decimal) {
318        self.rules
319            .push(BalanceRelationshipRule::new_dso_rule(target_dso, 10));
320        self.rules
321            .push(BalanceRelationshipRule::new_dpo_rule(target_dpo, 10));
322        self.rules
323            .push(BalanceRelationshipRule::new_gross_margin_rule(
324                target_margin,
325                dec!(0.05),
326            ));
327        self.rules
328            .push(BalanceRelationshipRule::new_balance_equation_rule());
329    }
330
331    /// Validate a balance snapshot against all rules.
332    pub fn validate_snapshot(&self, snapshot: &BalanceSnapshot) -> Vec<ValidationResult> {
333        let mut results = Vec::new();
334
335        for rule in &self.rules {
336            if !rule.enabled {
337                continue;
338            }
339
340            let result = self.validate_rule(rule, snapshot);
341            results.push(result);
342        }
343
344        results
345    }
346
347    /// Validate a single rule against a snapshot.
348    fn validate_rule(
349        &self,
350        rule: &BalanceRelationshipRule,
351        snapshot: &BalanceSnapshot,
352    ) -> ValidationResult {
353        match rule.relationship_type {
354            RelationshipType::BalanceSheetEquation => {
355                // A = L + E + Net Income
356                let equation_diff = snapshot.balance_difference;
357                if snapshot.is_balanced {
358                    ValidationResult::pass(rule, equation_diff)
359                } else {
360                    ValidationResult::fail(
361                        rule,
362                        equation_diff,
363                        format!("Balance sheet is out of balance by {equation_diff:.2}"),
364                    )
365                }
366            }
367            RelationshipType::CurrentRatio => {
368                // Current assets = accounts with GL prefix 10xx (cash), 11xx (AR),
369                // 12xx (inventory), 13xx (prepaid/other current). Non-current assets
370                // (14xx–19xx: PP&E, intangibles) are excluded because they cannot be
371                // liquidated within 12 months. When the balances map is empty (no
372                // account-level detail), we fall back to total_assets as a conservative
373                // approximation and note the limitation in the failure message.
374                let current_assets: Decimal = if snapshot.balances.is_empty() {
375                    snapshot.total_assets // Fallback: no account detail available
376                } else {
377                    snapshot
378                        .balances
379                        .values()
380                        .filter(|b| {
381                            let code = b
382                                .account_code
383                                .trim_start_matches(|c: char| !c.is_ascii_digit());
384                            let digits: String =
385                                code.chars().filter(|c| c.is_ascii_digit()).collect();
386                            if digits.len() >= 2 {
387                                matches!(&digits[..2], "10" | "11" | "12" | "13")
388                            } else {
389                                false
390                            }
391                        })
392                        .map(|b| b.closing_balance)
393                        .sum()
394                };
395                let current_liabilities = snapshot.total_liabilities;
396
397                if current_liabilities == Decimal::ZERO {
398                    ValidationResult::fail(
399                        rule,
400                        Decimal::ZERO,
401                        "No current liabilities".to_string(),
402                    )
403                } else {
404                    let ratio = current_assets / current_liabilities;
405                    if rule.is_within_range(ratio) {
406                        ValidationResult::pass(rule, ratio)
407                    } else {
408                        ValidationResult::fail(
409                            rule,
410                            ratio,
411                            format!("Current ratio {ratio:.2} is outside acceptable range"),
412                        )
413                    }
414                }
415            }
416            RelationshipType::DebtToEquity => {
417                if snapshot.total_equity == Decimal::ZERO {
418                    ValidationResult::fail(rule, Decimal::ZERO, "No equity".to_string())
419                } else {
420                    let ratio = snapshot.total_liabilities / snapshot.total_equity;
421                    if rule.is_within_range(ratio) {
422                        ValidationResult::pass(rule, ratio)
423                    } else {
424                        ValidationResult::fail(
425                            rule,
426                            ratio,
427                            format!("Debt-to-equity ratio {ratio:.2} is outside acceptable range"),
428                        )
429                    }
430                }
431            }
432            RelationshipType::GrossMargin => {
433                if snapshot.total_revenue == Decimal::ZERO {
434                    ValidationResult::pass(rule, Decimal::ZERO) // No revenue is technically valid
435                } else {
436                    let gross_profit = snapshot.total_revenue - snapshot.total_expenses; // Simplified
437                    let margin = gross_profit / snapshot.total_revenue;
438                    if rule.is_within_range(margin) {
439                        ValidationResult::pass(rule, margin)
440                    } else {
441                        ValidationResult::fail(
442                            rule,
443                            margin,
444                            format!(
445                                "Gross margin {:.1}% is outside target range",
446                                margin * dec!(100)
447                            ),
448                        )
449                    }
450                }
451            }
452            _ => {
453                // Default calculation using rule accounts
454                let numerator: Decimal = rule
455                    .numerator_accounts
456                    .iter()
457                    .filter_map(|code| snapshot.get_balance(code))
458                    .map(|b| b.closing_balance)
459                    .sum();
460
461                let denominator: Decimal = rule
462                    .denominator_accounts
463                    .iter()
464                    .filter_map(|code| snapshot.get_balance(code))
465                    .map(|b| b.closing_balance)
466                    .sum();
467
468                if denominator == Decimal::ZERO {
469                    ValidationResult::fail(rule, Decimal::ZERO, "Denominator is zero".to_string())
470                } else {
471                    let value = numerator / denominator * rule.multiplier;
472                    if rule.is_within_range(value) {
473                        ValidationResult::pass(rule, value)
474                    } else {
475                        ValidationResult::fail(
476                            rule,
477                            value,
478                            format!("{} = {:.2} is outside acceptable range", rule.name, value),
479                        )
480                    }
481                }
482            }
483        }
484    }
485
486    /// Get a summary of validation results.
487    pub fn summarize_results(results: &[ValidationResult]) -> ValidationSummary {
488        let total = results.len();
489        let passed = results.iter().filter(|r| r.is_valid).count();
490        let failed = total - passed;
491
492        let critical_failures = results
493            .iter()
494            .filter(|r| !r.is_valid && r.severity == RuleSeverity::Critical)
495            .count();
496
497        let error_failures = results
498            .iter()
499            .filter(|r| !r.is_valid && r.severity == RuleSeverity::Error)
500            .count();
501
502        let warning_failures = results
503            .iter()
504            .filter(|r| !r.is_valid && r.severity == RuleSeverity::Warning)
505            .count();
506
507        ValidationSummary {
508            total_rules: total,
509            passed,
510            failed,
511            critical_failures,
512            error_failures,
513            warning_failures,
514            is_coherent: critical_failures == 0,
515        }
516    }
517}
518
519impl Default for BalanceCoherenceValidator {
520    fn default() -> Self {
521        Self::new()
522    }
523}
524
525/// Summary of validation results.
526#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct ValidationSummary {
528    /// Total number of rules validated.
529    pub total_rules: usize,
530    /// Rules that passed.
531    pub passed: usize,
532    /// Rules that failed.
533    pub failed: usize,
534    /// Critical failures.
535    pub critical_failures: usize,
536    /// Error failures.
537    pub error_failures: usize,
538    /// Warning failures.
539    pub warning_failures: usize,
540    /// Overall coherence (no critical failures).
541    pub is_coherent: bool,
542}
543
544/// Account groupings for ratio calculations.
545#[derive(Debug, Clone, Default)]
546pub struct AccountGroups {
547    /// Current asset accounts.
548    pub current_assets: Vec<String>,
549    /// Non-current asset accounts.
550    pub non_current_assets: Vec<String>,
551    /// Current liability accounts.
552    pub current_liabilities: Vec<String>,
553    /// Non-current liability accounts.
554    pub non_current_liabilities: Vec<String>,
555    /// Equity accounts.
556    pub equity: Vec<String>,
557    /// Revenue accounts.
558    pub revenue: Vec<String>,
559    /// COGS accounts.
560    pub cogs: Vec<String>,
561    /// Operating expense accounts.
562    pub operating_expenses: Vec<String>,
563    /// AR accounts.
564    pub accounts_receivable: Vec<String>,
565    /// AP accounts.
566    pub accounts_payable: Vec<String>,
567    /// Inventory accounts.
568    pub inventory: Vec<String>,
569    /// Fixed asset accounts.
570    pub fixed_assets: Vec<String>,
571    /// Accumulated depreciation accounts.
572    pub accumulated_depreciation: Vec<String>,
573}
574
575/// Calculate DSO from balances.
576pub fn calculate_dso(ar_balance: Decimal, annual_revenue: Decimal) -> Option<Decimal> {
577    if annual_revenue == Decimal::ZERO {
578        None
579    } else {
580        Some(ar_balance / annual_revenue * dec!(365))
581    }
582}
583
584/// Calculate DPO from balances.
585pub fn calculate_dpo(ap_balance: Decimal, annual_cogs: Decimal) -> Option<Decimal> {
586    if annual_cogs == Decimal::ZERO {
587        None
588    } else {
589        Some(ap_balance / annual_cogs * dec!(365))
590    }
591}
592
593/// Calculate DIO from balances.
594pub fn calculate_dio(inventory_balance: Decimal, annual_cogs: Decimal) -> Option<Decimal> {
595    if annual_cogs == Decimal::ZERO {
596        None
597    } else {
598        Some(inventory_balance / annual_cogs * dec!(365))
599    }
600}
601
602/// Calculate cash conversion cycle.
603pub fn calculate_ccc(dso: Decimal, dio: Decimal, dpo: Decimal) -> Decimal {
604    dso + dio - dpo
605}
606
607/// Calculate gross margin.
608pub fn calculate_gross_margin(revenue: Decimal, cogs: Decimal) -> Option<Decimal> {
609    if revenue == Decimal::ZERO {
610        None
611    } else {
612        Some((revenue - cogs) / revenue)
613    }
614}
615
616/// Calculate operating margin.
617pub fn calculate_operating_margin(revenue: Decimal, operating_income: Decimal) -> Option<Decimal> {
618    if revenue == Decimal::ZERO {
619        None
620    } else {
621        Some(operating_income / revenue)
622    }
623}
624
625#[cfg(test)]
626#[allow(clippy::unwrap_used)]
627mod tests {
628    use super::*;
629
630    #[test]
631    fn test_dso_calculation() {
632        let ar = dec!(123288); // AR balance
633        let revenue = dec!(1000000); // Annual revenue
634
635        let dso = calculate_dso(ar, revenue).unwrap();
636        // 123288 / 1000000 * 365 = 45.0
637        assert!((dso - dec!(45)).abs() < dec!(1));
638    }
639
640    #[test]
641    fn test_dpo_calculation() {
642        let ap = dec!(58904); // AP balance
643        let cogs = dec!(650000); // Annual COGS
644
645        let dpo = calculate_dpo(ap, cogs).unwrap();
646        // 58904 / 650000 * 365 ≈ 33.1
647        assert!((dpo - dec!(33)).abs() < dec!(2));
648    }
649
650    #[test]
651    fn test_gross_margin_calculation() {
652        let revenue = dec!(1000000);
653        let cogs = dec!(650000);
654
655        let margin = calculate_gross_margin(revenue, cogs).unwrap();
656        // (1000000 - 650000) / 1000000 = 0.35
657        assert_eq!(margin, dec!(0.35));
658    }
659
660    #[test]
661    fn test_ccc_calculation() {
662        let dso = dec!(45);
663        let dio = dec!(60);
664        let dpo = dec!(30);
665
666        let ccc = calculate_ccc(dso, dio, dpo);
667        // 45 + 60 - 30 = 75 days
668        assert_eq!(ccc, dec!(75));
669    }
670
671    #[test]
672    fn test_dso_rule() {
673        let rule = BalanceRelationshipRule::new_dso_rule(45, 10);
674
675        assert!(rule.is_within_range(dec!(45)));
676        assert!(rule.is_within_range(dec!(35)));
677        assert!(rule.is_within_range(dec!(55)));
678        assert!(!rule.is_within_range(dec!(30)));
679        assert!(!rule.is_within_range(dec!(60)));
680    }
681
682    #[test]
683    fn test_gross_margin_rule() {
684        let rule = BalanceRelationshipRule::new_gross_margin_rule(dec!(0.35), dec!(0.05));
685
686        assert!(rule.is_within_range(dec!(0.35)));
687        assert!(rule.is_within_range(dec!(0.30)));
688        assert!(rule.is_within_range(dec!(0.40)));
689        assert!(!rule.is_within_range(dec!(0.25)));
690        assert!(!rule.is_within_range(dec!(0.45)));
691    }
692
693    #[test]
694    fn test_validation_summary() {
695        let rule1 = BalanceRelationshipRule::new_balance_equation_rule();
696        let rule2 = BalanceRelationshipRule::new_dso_rule(45, 10);
697
698        let results = vec![
699            ValidationResult::pass(&rule1, Decimal::ZERO),
700            ValidationResult::fail(&rule2, dec!(60), "DSO too high".to_string()),
701        ];
702
703        let summary = BalanceCoherenceValidator::summarize_results(&results);
704
705        assert_eq!(summary.total_rules, 2);
706        assert_eq!(summary.passed, 1);
707        assert_eq!(summary.failed, 1);
708        assert!(summary.is_coherent); // No critical failures
709    }
710}