datasynth_eval/coherence/
hr_payroll.rs1use crate::error::EvalResult;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct HrPayrollThresholds {
12 pub min_calculation_accuracy: f64,
14 pub tolerance: f64,
16}
17
18impl Default for HrPayrollThresholds {
19 fn default() -> Self {
20 Self {
21 min_calculation_accuracy: 0.999,
22 tolerance: 0.01,
23 }
24 }
25}
26
27#[derive(Debug, Clone)]
29pub struct PayrollLineItemData {
30 pub employee_id: String,
32 pub gross_pay: f64,
34 pub base_pay: f64,
36 pub overtime_pay: f64,
38 pub bonus_pay: f64,
40 pub net_pay: f64,
42 pub total_deductions: f64,
44 pub tax_deduction: f64,
46 pub social_security: f64,
48 pub health_insurance: f64,
50 pub retirement: f64,
52 pub other_deductions: f64,
54}
55
56#[derive(Debug, Clone)]
58pub struct PayrollRunData {
59 pub run_id: String,
61 pub total_net_pay: f64,
63 pub line_items: Vec<PayrollLineItemData>,
65}
66
67#[derive(Debug, Clone)]
69pub struct TimeEntryData {
70 pub employee_id: String,
72 pub total_hours: f64,
74}
75
76#[derive(Debug, Clone)]
78pub struct PayrollHoursData {
79 pub employee_id: String,
81 pub payroll_hours: f64,
83}
84
85#[derive(Debug, Clone)]
87pub struct ExpenseReportData {
88 pub report_id: String,
90 pub total_amount: f64,
92 pub line_items_sum: f64,
94 pub is_approved: bool,
96 pub has_approver: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct HrPayrollEvaluation {
103 pub gross_to_net_accuracy: f64,
105 pub component_sum_accuracy: f64,
107 pub deduction_sum_accuracy: f64,
109 pub run_sum_accuracy: f64,
111 pub time_to_payroll_mapping_rate: f64,
113 pub expense_line_item_sum_accuracy: f64,
115 pub expense_approval_consistency: f64,
117 pub total_line_items: usize,
119 pub total_runs: usize,
121 pub passes: bool,
123 pub issues: Vec<String>,
125}
126
127pub struct HrPayrollEvaluator {
129 thresholds: HrPayrollThresholds,
130}
131
132impl HrPayrollEvaluator {
133 pub fn new() -> Self {
135 Self {
136 thresholds: HrPayrollThresholds::default(),
137 }
138 }
139
140 pub fn with_thresholds(thresholds: HrPayrollThresholds) -> Self {
142 Self { thresholds }
143 }
144
145 pub fn evaluate(
147 &self,
148 runs: &[PayrollRunData],
149 time_entries: &[TimeEntryData],
150 payroll_hours: &[PayrollHoursData],
151 expense_reports: &[ExpenseReportData],
152 ) -> EvalResult<HrPayrollEvaluation> {
153 let mut issues = Vec::new();
154 let tol = self.thresholds.tolerance;
155
156 let all_items: Vec<&PayrollLineItemData> =
158 runs.iter().flat_map(|r| r.line_items.iter()).collect();
159 let total_line_items = all_items.len();
160
161 let gross_to_net_ok = all_items
163 .iter()
164 .filter(|li| (li.net_pay - (li.gross_pay - li.total_deductions)).abs() <= tol)
165 .count();
166 let gross_to_net_accuracy = if total_line_items > 0 {
167 gross_to_net_ok as f64 / total_line_items as f64
168 } else {
169 1.0
170 };
171
172 let component_ok = all_items
174 .iter()
175 .filter(|li| {
176 (li.gross_pay - (li.base_pay + li.overtime_pay + li.bonus_pay)).abs() <= tol
177 })
178 .count();
179 let component_sum_accuracy = if total_line_items > 0 {
180 component_ok as f64 / total_line_items as f64
181 } else {
182 1.0
183 };
184
185 let deduction_ok = all_items
187 .iter()
188 .filter(|li| {
189 let computed = li.tax_deduction
190 + li.social_security
191 + li.health_insurance
192 + li.retirement
193 + li.other_deductions;
194 (li.total_deductions - computed).abs() <= tol
195 })
196 .count();
197 let deduction_sum_accuracy = if total_line_items > 0 {
198 deduction_ok as f64 / total_line_items as f64
199 } else {
200 1.0
201 };
202
203 let total_runs = runs.len();
205 let run_ok = runs
206 .iter()
207 .filter(|run| {
208 let computed_total: f64 = run.line_items.iter().map(|li| li.net_pay).sum();
209 (run.total_net_pay - computed_total).abs() <= tol
210 })
211 .count();
212 let run_sum_accuracy = if total_runs > 0 {
213 run_ok as f64 / total_runs as f64
214 } else {
215 1.0
216 };
217
218 let time_map: std::collections::HashMap<&str, f64> = time_entries
220 .iter()
221 .map(|te| (te.employee_id.as_str(), te.total_hours))
222 .collect();
223 let mapped_count = payroll_hours
224 .iter()
225 .filter(|ph| {
226 time_map
227 .get(ph.employee_id.as_str())
228 .map(|&hours| (hours - ph.payroll_hours).abs() <= 1.0)
229 .unwrap_or(false)
230 })
231 .count();
232 let time_to_payroll_mapping_rate = if payroll_hours.is_empty() {
233 1.0
234 } else {
235 mapped_count as f64 / payroll_hours.len() as f64
236 };
237
238 let expense_sum_ok = expense_reports
240 .iter()
241 .filter(|er| (er.total_amount - er.line_items_sum).abs() <= tol)
242 .count();
243 let expense_line_item_sum_accuracy = if expense_reports.is_empty() {
244 1.0
245 } else {
246 expense_sum_ok as f64 / expense_reports.len() as f64
247 };
248
249 let approved_reports: Vec<&ExpenseReportData> =
250 expense_reports.iter().filter(|er| er.is_approved).collect();
251 let approval_consistent = approved_reports.iter().filter(|er| er.has_approver).count();
252 let expense_approval_consistency = if approved_reports.is_empty() {
253 1.0
254 } else {
255 approval_consistent as f64 / approved_reports.len() as f64
256 };
257
258 let min_acc = self.thresholds.min_calculation_accuracy;
260 if gross_to_net_accuracy < min_acc {
261 issues.push(format!(
262 "Gross-to-net accuracy {gross_to_net_accuracy:.4} < {min_acc:.4}"
263 ));
264 }
265 if component_sum_accuracy < min_acc {
266 issues.push(format!(
267 "Component sum accuracy {component_sum_accuracy:.4} < {min_acc:.4}"
268 ));
269 }
270 if deduction_sum_accuracy < min_acc {
271 issues.push(format!(
272 "Deduction sum accuracy {deduction_sum_accuracy:.4} < {min_acc:.4}"
273 ));
274 }
275 if run_sum_accuracy < min_acc {
276 issues.push(format!(
277 "Run sum accuracy {run_sum_accuracy:.4} < {min_acc:.4}"
278 ));
279 }
280
281 let passes = issues.is_empty();
282
283 Ok(HrPayrollEvaluation {
284 gross_to_net_accuracy,
285 component_sum_accuracy,
286 deduction_sum_accuracy,
287 run_sum_accuracy,
288 time_to_payroll_mapping_rate,
289 expense_line_item_sum_accuracy,
290 expense_approval_consistency,
291 total_line_items,
292 total_runs,
293 passes,
294 issues,
295 })
296 }
297}
298
299impl Default for HrPayrollEvaluator {
300 fn default() -> Self {
301 Self::new()
302 }
303}
304
305#[cfg(test)]
306#[allow(clippy::unwrap_used)]
307mod tests {
308 use super::*;
309
310 fn valid_line_item() -> PayrollLineItemData {
311 PayrollLineItemData {
312 employee_id: "EMP001".to_string(),
313 gross_pay: 5000.0,
314 base_pay: 4000.0,
315 overtime_pay: 500.0,
316 bonus_pay: 500.0,
317 net_pay: 3500.0,
318 total_deductions: 1500.0,
319 tax_deduction: 800.0,
320 social_security: 300.0,
321 health_insurance: 200.0,
322 retirement: 150.0,
323 other_deductions: 50.0,
324 }
325 }
326
327 #[test]
328 fn test_valid_payroll() {
329 let evaluator = HrPayrollEvaluator::new();
330 let runs = vec![PayrollRunData {
331 run_id: "PR001".to_string(),
332 total_net_pay: 3500.0,
333 line_items: vec![valid_line_item()],
334 }];
335
336 let result = evaluator.evaluate(&runs, &[], &[], &[]).unwrap();
337 assert!(result.passes);
338 assert_eq!(result.gross_to_net_accuracy, 1.0);
339 assert_eq!(result.component_sum_accuracy, 1.0);
340 assert_eq!(result.run_sum_accuracy, 1.0);
341 }
342
343 #[test]
344 fn test_broken_gross_to_net() {
345 let evaluator = HrPayrollEvaluator::new();
346 let mut item = valid_line_item();
347 item.net_pay = 4000.0; let runs = vec![PayrollRunData {
350 run_id: "PR001".to_string(),
351 total_net_pay: 4000.0,
352 line_items: vec![item],
353 }];
354
355 let result = evaluator.evaluate(&runs, &[], &[], &[]).unwrap();
356 assert!(!result.passes);
357 assert!(result.gross_to_net_accuracy < 1.0);
358 }
359
360 #[test]
361 fn test_empty_data() {
362 let evaluator = HrPayrollEvaluator::new();
363 let result = evaluator.evaluate(&[], &[], &[], &[]).unwrap();
364 assert!(result.passes);
365 }
366
367 #[test]
368 fn test_expense_report_consistency() {
369 let evaluator = HrPayrollEvaluator::new();
370 let expenses = vec![
371 ExpenseReportData {
372 report_id: "ER001".to_string(),
373 total_amount: 500.0,
374 line_items_sum: 500.0,
375 is_approved: true,
376 has_approver: true,
377 },
378 ExpenseReportData {
379 report_id: "ER002".to_string(),
380 total_amount: 300.0,
381 line_items_sum: 300.0,
382 is_approved: true,
383 has_approver: false, },
385 ];
386
387 let result = evaluator.evaluate(&[], &[], &[], &expenses).unwrap();
388 assert_eq!(result.expense_line_item_sum_accuracy, 1.0);
389 assert_eq!(result.expense_approval_consistency, 0.5);
390 }
391}