Skip to main content

datasynth_generators/anomaly/context/
entity_aware.rs

1//! Entity-aware anomaly injection.
2//!
3//! Provides rules for injecting anomalies based on entity characteristics,
4//! such as vendor tenure, employee experience, and account types.
5
6use chrono::NaiveDate;
7use rand::Rng;
8use rust_decimal::Decimal;
9use rust_decimal_macros::dec;
10use serde::{Deserialize, Serialize};
11
12use datasynth_core::models::{AnomalyType, ErrorType, FraudType, ProcessIssueType};
13
14/// Configuration for entity-aware anomaly injection.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct EntityAwareConfig {
17    /// Enable entity-aware injection.
18    pub enabled: bool,
19    /// Vendor-specific rules.
20    pub vendor_rules: VendorAnomalyRules,
21    /// Employee-specific rules.
22    pub employee_rules: EmployeeAnomalyRules,
23    /// Account-specific rules.
24    pub account_rules: AccountAnomalyRules,
25}
26
27impl Default for EntityAwareConfig {
28    fn default() -> Self {
29        Self {
30            enabled: true,
31            vendor_rules: VendorAnomalyRules::default(),
32            employee_rules: EmployeeAnomalyRules::default(),
33            account_rules: AccountAnomalyRules::default(),
34        }
35    }
36}
37
38/// Rules for vendor-specific anomaly patterns.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct VendorAnomalyRules {
41    /// Threshold for "new" vendor in days.
42    pub new_vendor_threshold_days: u32,
43    /// Error rate multiplier for new vendors.
44    pub new_vendor_error_multiplier: f64,
45    /// Anomaly types more common with new vendors.
46    pub new_vendor_error_types: Vec<AnomalyType>,
47    /// Threshold for "strategic" vendor by total spend.
48    pub strategic_vendor_spend_threshold: Decimal,
49    /// Anomaly types for strategic vendors (typically fraud).
50    pub strategic_vendor_types: Vec<AnomalyType>,
51    /// International vendor FX/tax error types.
52    pub international_error_types: Vec<AnomalyType>,
53    /// Dormant vendor threshold in days.
54    pub dormant_vendor_threshold_days: u32,
55    /// Error multiplier for dormant vendor reactivation.
56    pub dormant_reactivation_multiplier: f64,
57}
58
59impl Default for VendorAnomalyRules {
60    fn default() -> Self {
61        Self {
62            new_vendor_threshold_days: 90,
63            new_vendor_error_multiplier: 2.5,
64            new_vendor_error_types: vec![
65                AnomalyType::Error(ErrorType::MissingField),
66                AnomalyType::Error(ErrorType::MisclassifiedAccount),
67                AnomalyType::Error(ErrorType::MissingField),
68                AnomalyType::ProcessIssue(ProcessIssueType::MissingDocumentation),
69            ],
70            strategic_vendor_spend_threshold: dec!(1000000),
71            strategic_vendor_types: vec![
72                AnomalyType::Fraud(FraudType::Kickback),
73                AnomalyType::Fraud(FraudType::InvoiceManipulation),
74                AnomalyType::Fraud(FraudType::SplitTransaction),
75            ],
76            international_error_types: vec![
77                AnomalyType::Error(ErrorType::CurrencyError),
78                AnomalyType::Error(ErrorType::TaxCalculationError),
79                AnomalyType::Error(ErrorType::WrongPeriod),
80            ],
81            dormant_vendor_threshold_days: 180,
82            dormant_reactivation_multiplier: 1.8,
83        }
84    }
85}
86
87impl VendorAnomalyRules {
88    /// Checks if a vendor is considered "new".
89    pub fn is_new_vendor(&self, creation_date: NaiveDate, current_date: NaiveDate) -> bool {
90        let days = (current_date - creation_date).num_days();
91        days >= 0 && days < self.new_vendor_threshold_days as i64
92    }
93
94    /// Checks if a vendor is "dormant" (no activity for threshold period).
95    pub fn is_dormant_vendor(&self, last_activity: NaiveDate, current_date: NaiveDate) -> bool {
96        let days = (current_date - last_activity).num_days();
97        days >= self.dormant_vendor_threshold_days as i64
98    }
99
100    /// Checks if a vendor is "strategic" based on total spend.
101    pub fn is_strategic_vendor(&self, total_spend: Decimal) -> bool {
102        total_spend >= self.strategic_vendor_spend_threshold
103    }
104
105    /// Gets the error rate multiplier for a vendor.
106    pub fn get_multiplier(&self, context: &VendorContext) -> f64 {
107        let mut multiplier = 1.0;
108
109        if context.is_new {
110            multiplier *= self.new_vendor_error_multiplier;
111        }
112
113        if context.is_dormant_reactivation {
114            multiplier *= self.dormant_reactivation_multiplier;
115        }
116
117        multiplier
118    }
119
120    /// Gets applicable anomaly types for a vendor context.
121    pub fn get_applicable_types(&self, context: &VendorContext) -> Vec<AnomalyType> {
122        let mut types = Vec::new();
123
124        if context.is_new {
125            types.extend(self.new_vendor_error_types.clone());
126        }
127
128        if context.is_strategic {
129            types.extend(self.strategic_vendor_types.clone());
130        }
131
132        if context.is_international {
133            types.extend(self.international_error_types.clone());
134        }
135
136        types
137    }
138}
139
140/// Context information about a vendor.
141#[derive(Debug, Clone, Default)]
142pub struct VendorContext {
143    /// Vendor ID.
144    pub vendor_id: String,
145    /// Whether vendor is new (< threshold days).
146    pub is_new: bool,
147    /// Whether vendor is strategic (high spend).
148    pub is_strategic: bool,
149    /// Whether vendor is international.
150    pub is_international: bool,
151    /// Whether this is a dormant vendor reactivation.
152    pub is_dormant_reactivation: bool,
153    /// Total spend with this vendor.
154    pub total_spend: Decimal,
155    /// Days since vendor creation.
156    pub days_since_creation: i64,
157    /// Days since last activity.
158    pub days_since_last_activity: i64,
159}
160
161/// Rules for employee-specific anomaly patterns.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct EmployeeAnomalyRules {
164    /// Threshold for "new" employee in days.
165    pub new_employee_threshold_days: u32,
166    /// Error rate for new employees.
167    pub new_employee_error_rate: f64,
168    /// Transaction volume threshold for fatigue.
169    pub volume_fatigue_threshold: u32,
170    /// Error multiplier when volume exceeds threshold.
171    pub volume_fatigue_multiplier: f64,
172    /// Error multiplier when covering for absent approver.
173    pub coverage_error_multiplier: f64,
174    /// Overtime hours threshold for error spike.
175    pub overtime_hours_threshold: f64,
176    /// Error multiplier during overtime.
177    pub overtime_error_multiplier: f64,
178    /// Error types more common with new employees.
179    pub new_employee_error_types: Vec<AnomalyType>,
180    /// Error types from fatigue.
181    pub fatigue_error_types: Vec<AnomalyType>,
182}
183
184impl Default for EmployeeAnomalyRules {
185    fn default() -> Self {
186        Self {
187            new_employee_threshold_days: 180,
188            new_employee_error_rate: 0.05,
189            volume_fatigue_threshold: 50,
190            volume_fatigue_multiplier: 1.5,
191            coverage_error_multiplier: 1.3,
192            overtime_hours_threshold: 45.0,
193            overtime_error_multiplier: 1.4,
194            new_employee_error_types: vec![
195                AnomalyType::Error(ErrorType::MisclassifiedAccount),
196                AnomalyType::Error(ErrorType::WrongPeriod),
197                AnomalyType::Error(ErrorType::MissingField),
198                AnomalyType::ProcessIssue(ProcessIssueType::IncompleteApprovalChain),
199            ],
200            fatigue_error_types: vec![
201                AnomalyType::Error(ErrorType::DuplicateEntry),
202                AnomalyType::Error(ErrorType::TransposedDigits),
203                AnomalyType::Error(ErrorType::ReversedAmount),
204                AnomalyType::ProcessIssue(ProcessIssueType::SkippedApproval),
205            ],
206        }
207    }
208}
209
210impl EmployeeAnomalyRules {
211    /// Checks if an employee is considered "new".
212    pub fn is_new_employee(&self, hire_date: NaiveDate, current_date: NaiveDate) -> bool {
213        let days = (current_date - hire_date).num_days();
214        days >= 0 && days < self.new_employee_threshold_days as i64
215    }
216
217    /// Checks if employee is experiencing volume fatigue.
218    pub fn is_volume_fatigue(&self, daily_transaction_count: u32) -> bool {
219        daily_transaction_count > self.volume_fatigue_threshold
220    }
221
222    /// Checks if employee is in overtime.
223    pub fn is_overtime(&self, weekly_hours: f64) -> bool {
224        weekly_hours > self.overtime_hours_threshold
225    }
226
227    /// Gets the error rate multiplier for an employee.
228    pub fn get_multiplier(&self, context: &EmployeeContext) -> f64 {
229        let mut multiplier = 1.0;
230
231        if context.is_new {
232            multiplier *= 1.0 + self.new_employee_error_rate * 10.0;
233        }
234
235        if context.is_volume_fatigued {
236            multiplier *= self.volume_fatigue_multiplier;
237        }
238
239        if context.is_covering {
240            multiplier *= self.coverage_error_multiplier;
241        }
242
243        if context.is_overtime {
244            multiplier *= self.overtime_error_multiplier;
245        }
246
247        multiplier
248    }
249
250    /// Gets applicable anomaly types for an employee context.
251    pub fn get_applicable_types(&self, context: &EmployeeContext) -> Vec<AnomalyType> {
252        let mut types = Vec::new();
253
254        if context.is_new {
255            types.extend(self.new_employee_error_types.clone());
256        }
257
258        if context.is_volume_fatigued || context.is_overtime {
259            types.extend(self.fatigue_error_types.clone());
260        }
261
262        types
263    }
264}
265
266/// Context information about an employee.
267#[derive(Debug, Clone, Default)]
268pub struct EmployeeContext {
269    /// Employee ID.
270    pub employee_id: String,
271    /// Whether employee is new (< threshold days).
272    pub is_new: bool,
273    /// Whether employee is experiencing volume fatigue.
274    pub is_volume_fatigued: bool,
275    /// Whether employee is covering for an absent approver.
276    pub is_covering: bool,
277    /// Whether employee is in overtime.
278    pub is_overtime: bool,
279    /// Daily transaction count.
280    pub daily_transaction_count: u32,
281    /// Weekly hours worked.
282    pub weekly_hours: f64,
283    /// Days since hire.
284    pub days_since_hire: i64,
285}
286
287/// Rules for account-specific anomaly patterns.
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct AccountAnomalyRules {
290    /// High-risk account prefixes (e.g., "8" for suspense).
291    pub high_risk_prefixes: Vec<String>,
292    /// Error multiplier for high-risk accounts.
293    pub high_risk_multiplier: f64,
294    /// Reconciliation account patterns.
295    pub reconciliation_account_patterns: Vec<String>,
296    /// Error types for reconciliation accounts.
297    pub reconciliation_error_types: Vec<AnomalyType>,
298    /// Revenue account patterns.
299    pub revenue_account_patterns: Vec<String>,
300    /// Fraud types for revenue accounts.
301    pub revenue_fraud_types: Vec<AnomalyType>,
302    /// Intercompany account patterns.
303    pub intercompany_account_patterns: Vec<String>,
304    /// Error types for intercompany accounts.
305    pub intercompany_error_types: Vec<AnomalyType>,
306}
307
308impl Default for AccountAnomalyRules {
309    fn default() -> Self {
310        Self {
311            high_risk_prefixes: vec!["8".to_string(), "9".to_string()],
312            high_risk_multiplier: 2.0,
313            reconciliation_account_patterns: vec![
314                "1290".to_string(), // Clearing accounts
315                "2990".to_string(), // Suspense accounts
316            ],
317            reconciliation_error_types: vec![
318                AnomalyType::Error(ErrorType::UnbalancedEntry),
319                AnomalyType::Error(ErrorType::MisclassifiedAccount),
320            ],
321            revenue_account_patterns: vec![
322                "4".to_string(), // Revenue accounts typically start with 4
323            ],
324            revenue_fraud_types: vec![
325                AnomalyType::Fraud(FraudType::RevenueManipulation),
326                AnomalyType::Fraud(FraudType::PrematureRevenue),
327                AnomalyType::Fraud(FraudType::ChannelStuffing),
328            ],
329            intercompany_account_patterns: vec![
330                "1310".to_string(), // IC receivables
331                "2310".to_string(), // IC payables
332            ],
333            intercompany_error_types: vec![
334                AnomalyType::Error(ErrorType::WrongCompanyCode),
335                AnomalyType::Error(ErrorType::WrongPeriod),
336            ],
337        }
338    }
339}
340
341impl AccountAnomalyRules {
342    /// Checks if an account is high-risk.
343    pub fn is_high_risk(&self, account_code: &str) -> bool {
344        self.high_risk_prefixes
345            .iter()
346            .any(|prefix| account_code.starts_with(prefix))
347    }
348
349    /// Checks if an account is a reconciliation account.
350    pub fn is_reconciliation_account(&self, account_code: &str) -> bool {
351        self.reconciliation_account_patterns
352            .iter()
353            .any(|pattern| account_code.starts_with(pattern))
354    }
355
356    /// Checks if an account is a revenue account.
357    pub fn is_revenue_account(&self, account_code: &str) -> bool {
358        self.revenue_account_patterns
359            .iter()
360            .any(|pattern| account_code.starts_with(pattern))
361    }
362
363    /// Checks if an account is an intercompany account.
364    pub fn is_intercompany_account(&self, account_code: &str) -> bool {
365        self.intercompany_account_patterns
366            .iter()
367            .any(|pattern| account_code.starts_with(pattern))
368    }
369
370    /// Gets the error rate multiplier for an account.
371    pub fn get_multiplier(&self, context: &AccountContext) -> f64 {
372        let mut multiplier = 1.0;
373
374        if context.is_high_risk {
375            multiplier *= self.high_risk_multiplier;
376        }
377
378        multiplier
379    }
380
381    /// Gets applicable anomaly types for an account context.
382    pub fn get_applicable_types(&self, context: &AccountContext) -> Vec<AnomalyType> {
383        let mut types = Vec::new();
384
385        if context.is_reconciliation {
386            types.extend(self.reconciliation_error_types.clone());
387        }
388
389        if context.is_revenue {
390            types.extend(self.revenue_fraud_types.clone());
391        }
392
393        if context.is_intercompany {
394            types.extend(self.intercompany_error_types.clone());
395        }
396
397        types
398    }
399}
400
401/// Context information about an account.
402#[derive(Debug, Clone, Default)]
403pub struct AccountContext {
404    /// Account code.
405    pub account_code: String,
406    /// Whether account is high-risk.
407    pub is_high_risk: bool,
408    /// Whether account is a reconciliation account.
409    pub is_reconciliation: bool,
410    /// Whether account is a revenue account.
411    pub is_revenue: bool,
412    /// Whether account is an intercompany account.
413    pub is_intercompany: bool,
414}
415
416/// Entity-aware anomaly injector.
417pub struct EntityAwareInjector {
418    config: EntityAwareConfig,
419}
420
421impl Default for EntityAwareInjector {
422    fn default() -> Self {
423        Self::new(EntityAwareConfig::default())
424    }
425}
426
427impl EntityAwareInjector {
428    /// Creates a new entity-aware injector.
429    pub fn new(config: EntityAwareConfig) -> Self {
430        Self { config }
431    }
432
433    /// Gets the combined rate multiplier for a transaction context.
434    pub fn get_rate_multiplier(
435        &self,
436        vendor_ctx: Option<&VendorContext>,
437        employee_ctx: Option<&EmployeeContext>,
438        account_ctx: Option<&AccountContext>,
439    ) -> f64 {
440        if !self.config.enabled {
441            return 1.0;
442        }
443
444        let mut multiplier = 1.0;
445
446        if let Some(ctx) = vendor_ctx {
447            multiplier *= self.config.vendor_rules.get_multiplier(ctx);
448        }
449
450        if let Some(ctx) = employee_ctx {
451            multiplier *= self.config.employee_rules.get_multiplier(ctx);
452        }
453
454        if let Some(ctx) = account_ctx {
455            multiplier *= self.config.account_rules.get_multiplier(ctx);
456        }
457
458        multiplier
459    }
460
461    /// Gets applicable anomaly types for a transaction context.
462    pub fn get_applicable_types(
463        &self,
464        vendor_ctx: Option<&VendorContext>,
465        employee_ctx: Option<&EmployeeContext>,
466        account_ctx: Option<&AccountContext>,
467    ) -> Vec<AnomalyType> {
468        if !self.config.enabled {
469            return Vec::new();
470        }
471
472        let mut types = Vec::new();
473
474        if let Some(ctx) = vendor_ctx {
475            types.extend(self.config.vendor_rules.get_applicable_types(ctx));
476        }
477
478        if let Some(ctx) = employee_ctx {
479            types.extend(self.config.employee_rules.get_applicable_types(ctx));
480        }
481
482        if let Some(ctx) = account_ctx {
483            types.extend(self.config.account_rules.get_applicable_types(ctx));
484        }
485
486        // Remove duplicates
487        types.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b)));
488        types.dedup();
489
490        types
491    }
492
493    /// Determines if an anomaly should be injected based on context.
494    pub fn should_inject<R: Rng>(
495        &self,
496        base_rate: f64,
497        vendor_ctx: Option<&VendorContext>,
498        employee_ctx: Option<&EmployeeContext>,
499        account_ctx: Option<&AccountContext>,
500        rng: &mut R,
501    ) -> bool {
502        let multiplier = self.get_rate_multiplier(vendor_ctx, employee_ctx, account_ctx);
503        let adjusted_rate = (base_rate * multiplier).min(1.0);
504        rng.gen::<f64>() < adjusted_rate
505    }
506
507    /// Builds a vendor context from entity data.
508    pub fn build_vendor_context(
509        &self,
510        vendor_id: impl Into<String>,
511        creation_date: NaiveDate,
512        last_activity: NaiveDate,
513        current_date: NaiveDate,
514        total_spend: Decimal,
515        is_international: bool,
516    ) -> VendorContext {
517        let is_new = self
518            .config
519            .vendor_rules
520            .is_new_vendor(creation_date, current_date);
521        let is_dormant_reactivation = self
522            .config
523            .vendor_rules
524            .is_dormant_vendor(last_activity, current_date);
525        let is_strategic = self.config.vendor_rules.is_strategic_vendor(total_spend);
526
527        VendorContext {
528            vendor_id: vendor_id.into(),
529            is_new,
530            is_strategic,
531            is_international,
532            is_dormant_reactivation,
533            total_spend,
534            days_since_creation: (current_date - creation_date).num_days(),
535            days_since_last_activity: (current_date - last_activity).num_days(),
536        }
537    }
538
539    /// Builds an employee context from entity data.
540    pub fn build_employee_context(
541        &self,
542        employee_id: impl Into<String>,
543        hire_date: NaiveDate,
544        current_date: NaiveDate,
545        daily_transaction_count: u32,
546        weekly_hours: f64,
547        is_covering: bool,
548    ) -> EmployeeContext {
549        let is_new = self
550            .config
551            .employee_rules
552            .is_new_employee(hire_date, current_date);
553        let is_volume_fatigued = self
554            .config
555            .employee_rules
556            .is_volume_fatigue(daily_transaction_count);
557        let is_overtime = self.config.employee_rules.is_overtime(weekly_hours);
558
559        EmployeeContext {
560            employee_id: employee_id.into(),
561            is_new,
562            is_volume_fatigued,
563            is_covering,
564            is_overtime,
565            daily_transaction_count,
566            weekly_hours,
567            days_since_hire: (current_date - hire_date).num_days(),
568        }
569    }
570
571    /// Builds an account context from account code.
572    pub fn build_account_context(&self, account_code: impl Into<String>) -> AccountContext {
573        let code = account_code.into();
574        let is_high_risk = self.config.account_rules.is_high_risk(&code);
575        let is_reconciliation = self.config.account_rules.is_reconciliation_account(&code);
576        let is_revenue = self.config.account_rules.is_revenue_account(&code);
577        let is_intercompany = self.config.account_rules.is_intercompany_account(&code);
578
579        AccountContext {
580            account_code: code,
581            is_high_risk,
582            is_reconciliation,
583            is_revenue,
584            is_intercompany,
585        }
586    }
587
588    /// Returns the configuration.
589    pub fn config(&self) -> &EntityAwareConfig {
590        &self.config
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn test_vendor_rules_new_vendor() {
600        let rules = VendorAnomalyRules::default();
601        let creation = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
602        let current = NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(); // 31 days
603
604        assert!(rules.is_new_vendor(creation, current));
605
606        let current_later = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(); // 152 days
607        assert!(!rules.is_new_vendor(creation, current_later));
608    }
609
610    #[test]
611    fn test_vendor_rules_strategic() {
612        let rules = VendorAnomalyRules::default();
613
614        assert!(!rules.is_strategic_vendor(dec!(500000)));
615        assert!(rules.is_strategic_vendor(dec!(1500000)));
616    }
617
618    #[test]
619    fn test_employee_rules_new_employee() {
620        let rules = EmployeeAnomalyRules::default();
621        let hire = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
622        let current = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(); // 60 days
623
624        assert!(rules.is_new_employee(hire, current));
625
626        let current_later = NaiveDate::from_ymd_opt(2024, 9, 1).unwrap(); // 244 days
627        assert!(!rules.is_new_employee(hire, current_later));
628    }
629
630    #[test]
631    fn test_employee_rules_fatigue() {
632        let rules = EmployeeAnomalyRules::default();
633
634        assert!(!rules.is_volume_fatigue(30));
635        assert!(rules.is_volume_fatigue(60));
636    }
637
638    #[test]
639    fn test_account_rules() {
640        let rules = AccountAnomalyRules::default();
641
642        assert!(rules.is_high_risk("8100"));
643        assert!(rules.is_high_risk("9000"));
644        assert!(!rules.is_high_risk("4100"));
645
646        assert!(rules.is_revenue_account("4100"));
647        assert!(!rules.is_revenue_account("5100"));
648
649        assert!(rules.is_intercompany_account("1310"));
650        assert!(rules.is_intercompany_account("2310"));
651    }
652
653    #[test]
654    fn test_entity_aware_injector() {
655        let injector = EntityAwareInjector::default();
656
657        let vendor_ctx = VendorContext {
658            vendor_id: "V001".to_string(),
659            is_new: true,
660            is_strategic: false,
661            is_international: false,
662            is_dormant_reactivation: false,
663            total_spend: dec!(50000),
664            days_since_creation: 30,
665            days_since_last_activity: 5,
666        };
667
668        let multiplier = injector.get_rate_multiplier(Some(&vendor_ctx), None, None);
669        assert!(multiplier > 1.0); // New vendor should increase rate
670    }
671
672    #[test]
673    fn test_build_vendor_context() {
674        let injector = EntityAwareInjector::default();
675
676        let ctx = injector.build_vendor_context(
677            "V001",
678            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
679            NaiveDate::from_ymd_opt(2024, 5, 1).unwrap(),
680            NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
681            dec!(2000000),
682            true,
683        );
684
685        assert!(!ctx.is_new); // 152 days > 90 threshold
686        assert!(ctx.is_strategic); // 2M > 1M threshold
687        assert!(ctx.is_international);
688        assert!(!ctx.is_dormant_reactivation); // 31 days < 180 threshold
689    }
690
691    #[test]
692    fn test_combined_multiplier() {
693        let injector = EntityAwareInjector::default();
694
695        let vendor_ctx = VendorContext {
696            is_new: true,
697            ..Default::default()
698        };
699
700        let employee_ctx = EmployeeContext {
701            is_volume_fatigued: true,
702            ..Default::default()
703        };
704
705        let multiplier = injector.get_rate_multiplier(Some(&vendor_ctx), Some(&employee_ctx), None);
706
707        // Should be new_vendor_multiplier * volume_fatigue_multiplier
708        // 2.5 * 1.5 = 3.75
709        assert!((multiplier - 3.75).abs() < 0.01);
710    }
711}