Skip to main content

dpp_calc/repairability/
mod.rs

1//! Simplified repairability heuristic — six-parameter, 0–2 each, A–E band.
2//!
3//! ⚠️ **Non-regulatory.** This is a transparent six-factor indicator
4//! (disassembly, spare parts, repair info, diagnostic tools, software updates,
5//! customer support), each scored 0–2. It is **not** the enacted EU 2023/1669
6//! (smartphones/tablets) Annex IV repairability index, which uses a different
7//! parameter set (incl. Fasteners & Tools), a 1–5 per-class scale, a
8//! priority-part dimension, and its own class boundaries. The A–E result is a
9//! heuristic band and must not be presented as a regulatory repairability class.
10//! A faithful EU 2023/1669 Annex IV implementation is tracked separately.
11//!
12//! **Usage:**
13//! ```rust
14//! use dpp_calc::repairability::{
15//!     calculate, parameters::RepairabilityInputs,
16//!     thresholds::SimplifiedRepairabilityHeuristic,
17//! };
18//!
19//! let inputs = RepairabilityInputs {
20//!     disassembly: 2,
21//!     spare_parts: 2,
22//!     repair_info: 2,
23//!     diagnostic_tools: 1,
24//!     software_updatability: 2,
25//!     customer_support: 1,
26//! };
27//! let result = calculate(&inputs, &SimplifiedRepairabilityHeuristic).unwrap();
28//! println!("{:?}  ({:.2}/10)", result.class, result.numeric_score);
29//! ```
30
31pub mod parameters;
32pub mod thresholds;
33
34#[cfg(test)]
35mod golden_vectors;
36
37use chrono::Utc;
38use serde::{Deserialize, Serialize};
39
40use crate::error::CalcError;
41use crate::receipt::{CalculationReceipt, input_hash, jcs_hash};
42
43pub use parameters::RepairabilityInputs;
44pub use thresholds::{
45    DisplaysRuleset, LaptopRuleset, RepairabilityRuleset, SimplifiedRepairabilityHeuristic,
46    WashingMachineRuleset,
47};
48
49/// A–E heuristic band from the simplified repairability indicator.
50///
51/// Not a regulatory repairability class — see the module-level note.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53pub enum RepairabilityClass {
54    A,
55    B,
56    C,
57    D,
58    E,
59}
60
61impl RepairabilityClass {
62    /// Numeric ordinal for interop (A=5, B=4, C=3, D=2, E=1).
63    pub fn as_ordinal(self) -> u8 {
64        match self {
65            Self::A => 5,
66            Self::B => 4,
67            Self::C => 3,
68            Self::D => 2,
69            Self::E => 1,
70        }
71    }
72}
73
74impl std::fmt::Display for RepairabilityClass {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        write!(
77            f,
78            "{}",
79            match self {
80                Self::A => "A",
81                Self::B => "B",
82                Self::C => "C",
83                Self::D => "D",
84                Self::E => "E",
85            }
86        )
87    }
88}
89
90/// Weighted contribution of each heuristic parameter to the final 0–10 score.
91///
92/// Each field equals `parameter_value × weight × 5.0`. The sum of all six fields
93/// equals [`RepairabilityResult::numeric_score`], allowing an auditor to trace
94/// any individual parameter's share of the band without re-running the calculation.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ParameterContributions {
97    pub disassembly: f64,
98    pub spare_parts: f64,
99    pub repair_info: f64,
100    pub diagnostic_tools: f64,
101    pub software_updatability: f64,
102    pub customer_support: f64,
103}
104
105/// Output of the simplified repairability heuristic.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct RepairabilityResult {
108    /// A–E heuristic band (not a regulatory class).
109    pub class: RepairabilityClass,
110    /// Continuous numeric score in `[0.0, 10.0]`.
111    pub numeric_score: f64,
112    /// Per-parameter weighted contributions — sum equals `numeric_score`.
113    pub contributions: ParameterContributions,
114    /// Proof-of-calculation receipt.
115    pub receipt: CalculationReceipt,
116}
117
118/// Calculate the simplified repairability heuristic band for one product.
119///
120/// The result is a non-regulatory heuristic, not the EU 2023/1669 class.
121/// Returns `Err` if any parameter value is outside `[0, 2]` or if the
122/// ruleset's effective period does not cover today's date.
123pub fn calculate(
124    inputs: &RepairabilityInputs,
125    ruleset: &dyn RepairabilityRuleset,
126) -> Result<RepairabilityResult, CalcError> {
127    validate_inputs(inputs)?;
128    ruleset.validate_cross_fields(inputs)?;
129
130    let today = Utc::now().date_naive();
131    if !ruleset.effective_dates().is_active_on(today) {
132        return Err(CalcError::RulesetExpired {
133            id: ruleset.id().0.clone(),
134            until: ruleset
135                .effective_dates()
136                .until
137                .map(|d| d.to_string())
138                .unwrap_or_else(|| "open-ended".into()),
139        });
140    }
141
142    let w = ruleset.weights();
143    let scale = 5.0; // scale 0–2 ordinals to 0–10
144
145    let contributions = ParameterContributions {
146        disassembly: f64::from(inputs.disassembly) * w.disassembly * scale,
147        spare_parts: f64::from(inputs.spare_parts) * w.spare_parts * scale,
148        repair_info: f64::from(inputs.repair_info) * w.repair_info * scale,
149        diagnostic_tools: f64::from(inputs.diagnostic_tools) * w.diagnostic_tools * scale,
150        software_updatability: f64::from(inputs.software_updatability)
151            * w.software_updatability
152            * scale,
153        customer_support: f64::from(inputs.customer_support) * w.customer_support * scale,
154    };
155
156    let numeric_score = contributions.disassembly
157        + contributions.spare_parts
158        + contributions.repair_info
159        + contributions.diagnostic_tools
160        + contributions.software_updatability
161        + contributions.customer_support;
162
163    let t = ruleset.thresholds();
164    let class = if numeric_score >= t.a {
165        RepairabilityClass::A
166    } else if numeric_score >= t.b {
167        RepairabilityClass::B
168    } else if numeric_score >= t.c {
169        RepairabilityClass::C
170    } else if numeric_score >= t.d {
171        RepairabilityClass::D
172    } else {
173        RepairabilityClass::E
174    };
175
176    let output_hash = jcs_hash(&(numeric_score, class.as_ordinal()))?;
177
178    let receipt = CalculationReceipt::new(
179        input_hash(inputs)?,
180        ruleset.id().0.as_str(),
181        ruleset.version().0.as_str(),
182    )
183    .with_output_hash(output_hash);
184
185    Ok(RepairabilityResult {
186        class,
187        numeric_score,
188        contributions,
189        receipt,
190    })
191}
192
193fn validate_inputs(inputs: &RepairabilityInputs) -> Result<(), CalcError> {
194    for (name, val) in [
195        ("disassembly", inputs.disassembly),
196        ("spare_parts", inputs.spare_parts),
197        ("repair_info", inputs.repair_info),
198        ("diagnostic_tools", inputs.diagnostic_tools),
199        ("software_updatability", inputs.software_updatability),
200        ("customer_support", inputs.customer_support),
201    ] {
202        if val > 2 {
203            return Err(CalcError::InvalidInput(format!(
204                "{name} must be 0, 1, or 2; got {val}"
205            )));
206        }
207    }
208    Ok(())
209}
210
211#[cfg(test)]
212mod class_tests {
213    use super::*;
214
215    #[test]
216    fn class_display_and_ordinal_for_all_grades() {
217        for (class, letter, ordinal) in [
218            (RepairabilityClass::A, "A", 5),
219            (RepairabilityClass::B, "B", 4),
220            (RepairabilityClass::C, "C", 3),
221            (RepairabilityClass::D, "D", 2),
222            (RepairabilityClass::E, "E", 1),
223        ] {
224            assert_eq!(class.to_string(), letter);
225            assert_eq!(class.as_ordinal(), ordinal);
226        }
227    }
228
229    #[test]
230    fn out_of_range_parameter_is_rejected() {
231        let inputs = RepairabilityInputs {
232            disassembly: 3, // out of [0, 2]
233            spare_parts: 1,
234            repair_info: 1,
235            diagnostic_tools: 1,
236            software_updatability: 1,
237            customer_support: 1,
238        };
239        assert!(matches!(
240            calculate(&inputs, &SimplifiedRepairabilityHeuristic),
241            Err(CalcError::InvalidInput(_))
242        ));
243    }
244}