Skip to main content

datasynth_eval/coherence/
project_accounting.rs

1//! Project accounting coherence evaluator.
2//!
3//! Validates percentage-of-completion revenue recognition, earned value
4//! management (EVM) metrics, and retainage balance calculations.
5
6use crate::error::EvalResult;
7use serde::{Deserialize, Serialize};
8
9/// Thresholds for project accounting evaluation.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ProjectAccountingThresholds {
12    /// Minimum accuracy for EVM metric calculations.
13    pub min_evm_accuracy: f64,
14    /// Minimum accuracy for PoC completion percentage.
15    pub min_poc_accuracy: f64,
16    /// Tolerance for EVM comparisons.
17    pub evm_tolerance: f64,
18}
19
20impl Default for ProjectAccountingThresholds {
21    fn default() -> Self {
22        Self {
23            min_evm_accuracy: 0.999,
24            min_poc_accuracy: 0.99,
25            evm_tolerance: 0.01,
26        }
27    }
28}
29
30/// Project revenue data for PoC validation.
31#[derive(Debug, Clone)]
32pub struct ProjectRevenueData {
33    /// Project identifier.
34    pub project_id: String,
35    /// Costs incurred to date.
36    pub costs_to_date: f64,
37    /// Estimated total cost at completion.
38    pub estimated_total_cost: f64,
39    /// Reported completion percentage.
40    pub completion_pct: f64,
41    /// Total contract value.
42    pub contract_value: f64,
43    /// Cumulative revenue recognized.
44    pub cumulative_revenue: f64,
45    /// Amount billed to date.
46    pub billed_to_date: f64,
47    /// Unbilled revenue balance.
48    pub unbilled_revenue: f64,
49}
50
51/// Earned value management data for EVM validation.
52#[derive(Debug, Clone)]
53pub struct EarnedValueData {
54    /// Project identifier.
55    pub project_id: String,
56    /// Planned value (BCWS).
57    pub planned_value: f64,
58    /// Earned value (BCWP).
59    pub earned_value: f64,
60    /// Actual cost (ACWP).
61    pub actual_cost: f64,
62    /// Budget at completion.
63    pub bac: f64,
64    /// Schedule variance (SV = EV - PV).
65    pub schedule_variance: f64,
66    /// Cost variance (CV = EV - AC).
67    pub cost_variance: f64,
68    /// Schedule performance index (SPI = EV / PV).
69    pub spi: f64,
70    /// Cost performance index (CPI = EV / AC).
71    pub cpi: f64,
72}
73
74/// Retainage data for balance validation.
75#[derive(Debug, Clone)]
76pub struct RetainageData {
77    /// Retainage identifier.
78    pub retainage_id: String,
79    /// Total amount held.
80    pub total_held: f64,
81    /// Amount released.
82    pub released_amount: f64,
83    /// Current balance held.
84    pub balance_held: f64,
85}
86
87/// Results of project accounting coherence evaluation.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ProjectAccountingEvaluation {
90    /// Fraction of projects with correct completion_pct.
91    pub poc_accuracy: f64,
92    /// Fraction of projects with correct cumulative_revenue.
93    pub revenue_accuracy: f64,
94    /// Fraction of projects with correct unbilled_revenue.
95    pub unbilled_accuracy: f64,
96    /// Fraction of EVM records with correct SV, CV, SPI, CPI.
97    pub evm_accuracy: f64,
98    /// Fraction of retainage records with correct balance.
99    pub retainage_accuracy: f64,
100    /// Total projects evaluated.
101    pub total_projects: usize,
102    /// Total EVM records evaluated.
103    pub total_evm_records: usize,
104    /// Total retainage records evaluated.
105    pub total_retainage: usize,
106    /// Overall pass/fail.
107    pub passes: bool,
108    /// Issues found.
109    pub issues: Vec<String>,
110}
111
112/// Evaluator for project accounting coherence.
113pub struct ProjectAccountingEvaluator {
114    thresholds: ProjectAccountingThresholds,
115}
116
117impl ProjectAccountingEvaluator {
118    /// Create a new evaluator with default thresholds.
119    pub fn new() -> Self {
120        Self {
121            thresholds: ProjectAccountingThresholds::default(),
122        }
123    }
124
125    /// Create with custom thresholds.
126    pub fn with_thresholds(thresholds: ProjectAccountingThresholds) -> Self {
127        Self { thresholds }
128    }
129
130    /// Evaluate project accounting data coherence.
131    pub fn evaluate(
132        &self,
133        projects: &[ProjectRevenueData],
134        evm_records: &[EarnedValueData],
135        retainage: &[RetainageData],
136    ) -> EvalResult<ProjectAccountingEvaluation> {
137        let mut issues = Vec::new();
138        let tolerance = self.thresholds.evm_tolerance;
139
140        // 1. PoC: completion_pct ≈ costs_to_date / estimated_total_cost
141        let poc_ok = projects
142            .iter()
143            .filter(|p| {
144                if p.estimated_total_cost <= 0.0 {
145                    return true;
146                }
147                let expected = p.costs_to_date / p.estimated_total_cost;
148                (p.completion_pct - expected).abs() <= tolerance
149            })
150            .count();
151        let poc_accuracy = if projects.is_empty() {
152            1.0
153        } else {
154            poc_ok as f64 / projects.len() as f64
155        };
156
157        // 2. Revenue: cumulative_revenue ≈ contract_value * completion_pct
158        let rev_ok = projects
159            .iter()
160            .filter(|p| {
161                let expected = p.contract_value * p.completion_pct;
162                (p.cumulative_revenue - expected).abs()
163                    <= tolerance * p.contract_value.abs().max(1.0)
164            })
165            .count();
166        let revenue_accuracy = if projects.is_empty() {
167            1.0
168        } else {
169            rev_ok as f64 / projects.len() as f64
170        };
171
172        // 3. Unbilled: unbilled_revenue ≈ cumulative_revenue - billed_to_date
173        let unbilled_ok = projects
174            .iter()
175            .filter(|p| {
176                let expected = p.cumulative_revenue - p.billed_to_date;
177                (p.unbilled_revenue - expected).abs()
178                    <= tolerance * p.cumulative_revenue.abs().max(1.0)
179            })
180            .count();
181        let unbilled_accuracy = if projects.is_empty() {
182            1.0
183        } else {
184            unbilled_ok as f64 / projects.len() as f64
185        };
186
187        // 4. EVM: SV = EV - PV, CV = EV - AC, SPI = EV/PV, CPI = EV/AC
188        let evm_ok = evm_records
189            .iter()
190            .filter(|e| {
191                let sv_expected = e.earned_value - e.planned_value;
192                let cv_expected = e.earned_value - e.actual_cost;
193                let sv_ok = (e.schedule_variance - sv_expected).abs()
194                    <= tolerance * e.earned_value.abs().max(1.0);
195                let cv_ok = (e.cost_variance - cv_expected).abs()
196                    <= tolerance * e.earned_value.abs().max(1.0);
197
198                let spi_ok = if e.planned_value > 0.0 {
199                    let expected = e.earned_value / e.planned_value;
200                    (e.spi - expected).abs() <= tolerance
201                } else {
202                    true
203                };
204                let cpi_ok = if e.actual_cost > 0.0 {
205                    let expected = e.earned_value / e.actual_cost;
206                    (e.cpi - expected).abs() <= tolerance
207                } else {
208                    true
209                };
210
211                sv_ok && cv_ok && spi_ok && cpi_ok
212            })
213            .count();
214        let evm_accuracy = if evm_records.is_empty() {
215            1.0
216        } else {
217            evm_ok as f64 / evm_records.len() as f64
218        };
219
220        // 5. Retainage: balance_held ≈ total_held - released_amount
221        let ret_ok = retainage
222            .iter()
223            .filter(|r| {
224                let expected = r.total_held - r.released_amount;
225                (r.balance_held - expected).abs() <= tolerance * r.total_held.abs().max(1.0)
226            })
227            .count();
228        let retainage_accuracy = if retainage.is_empty() {
229            1.0
230        } else {
231            ret_ok as f64 / retainage.len() as f64
232        };
233
234        // Check thresholds
235        if poc_accuracy < self.thresholds.min_poc_accuracy {
236            issues.push(format!(
237                "PoC completion accuracy {:.4} < {:.4}",
238                poc_accuracy, self.thresholds.min_poc_accuracy
239            ));
240        }
241        if revenue_accuracy < self.thresholds.min_poc_accuracy {
242            issues.push(format!(
243                "Revenue recognition accuracy {:.4} < {:.4}",
244                revenue_accuracy, self.thresholds.min_poc_accuracy
245            ));
246        }
247        if unbilled_accuracy < self.thresholds.min_poc_accuracy {
248            issues.push(format!(
249                "Unbilled revenue accuracy {:.4} < {:.4}",
250                unbilled_accuracy, self.thresholds.min_poc_accuracy
251            ));
252        }
253        if evm_accuracy < self.thresholds.min_evm_accuracy {
254            issues.push(format!(
255                "EVM metric accuracy {:.4} < {:.4}",
256                evm_accuracy, self.thresholds.min_evm_accuracy
257            ));
258        }
259        if retainage_accuracy < self.thresholds.min_evm_accuracy {
260            issues.push(format!(
261                "Retainage balance accuracy {:.4} < {:.4}",
262                retainage_accuracy, self.thresholds.min_evm_accuracy
263            ));
264        }
265
266        let passes = issues.is_empty();
267
268        Ok(ProjectAccountingEvaluation {
269            poc_accuracy,
270            revenue_accuracy,
271            unbilled_accuracy,
272            evm_accuracy,
273            retainage_accuracy,
274            total_projects: projects.len(),
275            total_evm_records: evm_records.len(),
276            total_retainage: retainage.len(),
277            passes,
278            issues,
279        })
280    }
281}
282
283impl Default for ProjectAccountingEvaluator {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289#[cfg(test)]
290#[allow(clippy::unwrap_used)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn test_valid_project_accounting() {
296        let evaluator = ProjectAccountingEvaluator::new();
297        let projects = vec![ProjectRevenueData {
298            project_id: "PRJ001".to_string(),
299            costs_to_date: 500_000.0,
300            estimated_total_cost: 1_000_000.0,
301            completion_pct: 0.50,
302            contract_value: 1_200_000.0,
303            cumulative_revenue: 600_000.0,
304            billed_to_date: 550_000.0,
305            unbilled_revenue: 50_000.0,
306        }];
307        let evm = vec![EarnedValueData {
308            project_id: "PRJ001".to_string(),
309            planned_value: 600_000.0,
310            earned_value: 500_000.0,
311            actual_cost: 520_000.0,
312            bac: 1_000_000.0,
313            schedule_variance: -100_000.0,
314            cost_variance: -20_000.0,
315            spi: 500_000.0 / 600_000.0,
316            cpi: 500_000.0 / 520_000.0,
317        }];
318        let retainage = vec![RetainageData {
319            retainage_id: "RET001".to_string(),
320            total_held: 60_000.0,
321            released_amount: 10_000.0,
322            balance_held: 50_000.0,
323        }];
324
325        let result = evaluator.evaluate(&projects, &evm, &retainage).unwrap();
326        assert!(result.passes);
327        assert_eq!(result.total_projects, 1);
328        assert_eq!(result.total_evm_records, 1);
329    }
330
331    #[test]
332    fn test_wrong_completion_pct() {
333        let evaluator = ProjectAccountingEvaluator::new();
334        let projects = vec![ProjectRevenueData {
335            project_id: "PRJ001".to_string(),
336            costs_to_date: 500_000.0,
337            estimated_total_cost: 1_000_000.0,
338            completion_pct: 0.80, // Wrong: should be 0.50
339            contract_value: 1_200_000.0,
340            cumulative_revenue: 960_000.0,
341            billed_to_date: 900_000.0,
342            unbilled_revenue: 60_000.0,
343        }];
344
345        let result = evaluator.evaluate(&projects, &[], &[]).unwrap();
346        assert!(!result.passes);
347        assert!(result.issues.iter().any(|i| i.contains("PoC completion")));
348    }
349
350    #[test]
351    fn test_wrong_evm_metrics() {
352        let evaluator = ProjectAccountingEvaluator::new();
353        let evm = vec![EarnedValueData {
354            project_id: "PRJ001".to_string(),
355            planned_value: 600_000.0,
356            earned_value: 500_000.0,
357            actual_cost: 520_000.0,
358            bac: 1_000_000.0,
359            schedule_variance: 0.0, // Wrong: should be -100,000
360            cost_variance: -20_000.0,
361            spi: 500_000.0 / 600_000.0,
362            cpi: 500_000.0 / 520_000.0,
363        }];
364
365        let result = evaluator.evaluate(&[], &evm, &[]).unwrap();
366        assert!(!result.passes);
367        assert!(result.issues.iter().any(|i| i.contains("EVM metric")));
368    }
369
370    #[test]
371    fn test_wrong_retainage_balance() {
372        let evaluator = ProjectAccountingEvaluator::new();
373        let retainage = vec![RetainageData {
374            retainage_id: "RET001".to_string(),
375            total_held: 60_000.0,
376            released_amount: 10_000.0,
377            balance_held: 60_000.0, // Wrong: should be 50,000
378        }];
379
380        let result = evaluator.evaluate(&[], &[], &retainage).unwrap();
381        assert!(!result.passes);
382        assert!(result.issues.iter().any(|i| i.contains("Retainage")));
383    }
384
385    #[test]
386    fn test_wrong_cumulative_revenue() {
387        let evaluator = ProjectAccountingEvaluator::new();
388        let projects = vec![ProjectRevenueData {
389            project_id: "PRJ001".to_string(),
390            costs_to_date: 500_000.0,
391            estimated_total_cost: 1_000_000.0,
392            completion_pct: 0.50,
393            contract_value: 1_200_000.0,
394            cumulative_revenue: 900_000.0, // Wrong: should be 600,000
395            billed_to_date: 550_000.0,
396            unbilled_revenue: 350_000.0,
397        }];
398
399        let result = evaluator.evaluate(&projects, &[], &[]).unwrap();
400        assert!(!result.passes);
401        assert!(result
402            .issues
403            .iter()
404            .any(|i| i.contains("Revenue recognition")));
405    }
406
407    #[test]
408    fn test_wrong_unbilled_revenue() {
409        let evaluator = ProjectAccountingEvaluator::new();
410        let projects = vec![ProjectRevenueData {
411            project_id: "PRJ001".to_string(),
412            costs_to_date: 500_000.0,
413            estimated_total_cost: 1_000_000.0,
414            completion_pct: 0.50,
415            contract_value: 1_200_000.0,
416            cumulative_revenue: 600_000.0,
417            billed_to_date: 550_000.0,
418            unbilled_revenue: 200_000.0, // Wrong: should be 50,000
419        }];
420
421        let result = evaluator.evaluate(&projects, &[], &[]).unwrap();
422        assert!(!result.passes);
423        assert!(result.issues.iter().any(|i| i.contains("Unbilled revenue")));
424    }
425
426    #[test]
427    fn test_empty_data() {
428        let evaluator = ProjectAccountingEvaluator::new();
429        let result = evaluator.evaluate(&[], &[], &[]).unwrap();
430        assert!(result.passes);
431    }
432}