Skip to main content

dpp_calc/repairability/
thresholds.rs

1//! Threshold tables and product-category rulesets for the **simplified
2//! repairability heuristic**.
3//!
4//! ⚠️ This is **not** the enacted EU 2023/1669 (smartphones/tablets) Annex IV
5//! repairability index. That methodology uses a different parameter set
6//! (incl. Fasteners & Tools), a 1–5 per-class scale, a priority-part dimension,
7//! and its own class boundaries. The model here is a transparent six-factor
8//! 0–2 heuristic; its output is a heuristic band, not a regulatory class.
9
10use chrono::NaiveDate;
11use serde::{Deserialize, Serialize};
12
13use crate::error::CalcError;
14use crate::repairability::parameters::RepairabilityInputs;
15use crate::ruleset::{EffectiveDateBound, RegulatoryBasis, Ruleset, RulesetId, RulesetVersion};
16
17/// Weight coefficient for each heuristic parameter.
18///
19/// Weights must sum to 1.0. Each parameter score (0–2) is multiplied by its
20/// weight and by 5.0 to produce a contribution to the 0–10 numeric score.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct RepairabilityWeights {
23    pub disassembly: f64,
24    pub spare_parts: f64,
25    pub repair_info: f64,
26    pub diagnostic_tools: f64,
27    pub software_updatability: f64,
28    pub customer_support: f64,
29}
30
31/// Minimum numeric score (out of 10) required for each letter grade.
32///
33/// Grade E is assigned when the score is below `d`.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct RepairabilityThresholds {
36    /// Minimum score for grade A (highest).
37    pub a: f64,
38    pub b: f64,
39    pub c: f64,
40    /// Minimum score for grade D. Below this value → grade E.
41    pub d: f64,
42}
43
44/// Ruleset for the simplified repairability heuristic.
45///
46/// Extends [`Ruleset`]; `regulatory_basis()` records the provenance, which for
47/// the heuristic is explicitly non-regulatory. The heuristic-specific data
48/// (weights and A–E band thresholds) is in the two additional methods.
49///
50/// ## Canonical pattern for future methodology traits
51/// ```rust,ignore
52/// pub trait PEFRuleset: Ruleset { ... }
53/// pub trait CfbRuleset: Ruleset { ... }
54/// ```
55pub trait RepairabilityRuleset: Ruleset {
56    fn weights(&self) -> &RepairabilityWeights;
57    fn thresholds(&self) -> &RepairabilityThresholds;
58
59    /// Validate parameter combinations that are incoherent per this ruleset.
60    ///
61    /// Called after range validation. Default: no cross-field constraints.
62    /// Override to enforce coherence rules (e.g. the disassembly/spare-parts
63    /// dependency).
64    fn validate_cross_fields(&self, _inputs: &RepairabilityInputs) -> Result<(), CalcError> {
65        Ok(())
66    }
67}
68
69// ---------------------------------------------------------------------------
70// Simplified repairability heuristic (applied to smartphones/tablets today)
71// ---------------------------------------------------------------------------
72//
73// NON-REGULATORY. This is a transparent six-factor 0–2 indicator, NOT the
74// enacted EU 2023/1669 Annex IV repairability index (which uses a different
75// parameter set incl. Fasteners & Tools, a 1–5 per-class scale, a priority-part
76// dimension, and its own class boundaries — see docs/audit H-1). The weights and
77// band thresholds below are heuristic design choices; they are deliberately
78// NOT pinned to any OJ annex, and the output must not be presented as a
79// regulatory repairability class.
80// ---------------------------------------------------------------------------
81static SMARTPHONE_WEIGHTS: RepairabilityWeights = RepairabilityWeights {
82    disassembly: 0.25,
83    spare_parts: 0.15,
84    repair_info: 0.15,
85    diagnostic_tools: 0.15,
86    software_updatability: 0.15,
87    customer_support: 0.15,
88};
89
90// Heuristic band boundaries (a=8.5, b=7.0, c=5.5, d=4.0) — design choices, not a
91// regulatory grade table. Below `d` ⇒ band E.
92static SMARTPHONE_THRESHOLDS: RepairabilityThresholds = RepairabilityThresholds {
93    a: 8.5,
94    b: 7.0,
95    c: 5.5,
96    d: 4.0,
97};
98
99static SMARTPHONE_BASIS: RegulatoryBasis = RegulatoryBasis {
100    regulation: "Non-regulatory: simplified repairability heuristic (NOT EU 2023/1669 Annex IV)",
101    article: "Six-factor heuristic — disassembly, spare parts, repair info, \
102              diagnostic tools, software updates, customer support",
103    standard: None,
104    technical_study: None,
105    source_url: None,
106    superseded_by: None,
107};
108
109static SMARTPHONE_RULESET_ID: std::sync::OnceLock<RulesetId> = std::sync::OnceLock::new();
110static SMARTPHONE_RULESET_VERSION: std::sync::OnceLock<RulesetVersion> = std::sync::OnceLock::new();
111static SMARTPHONE_EFFECTIVE_DATES: std::sync::OnceLock<EffectiveDateBound> =
112    std::sync::OnceLock::new();
113
114/// Simplified, non-regulatory repairability heuristic — a transparent six-factor
115/// 0–2 indicator, applied to smartphones/tablets today. **Not** the enacted EU
116/// 2023/1669 Annex IV index; the output is a heuristic band, not a regulatory
117/// class. Available from 2025-06-20 (when the heuristic was introduced).
118pub struct SimplifiedRepairabilityHeuristic;
119
120impl Ruleset for SimplifiedRepairabilityHeuristic {
121    fn id(&self) -> &RulesetId {
122        SMARTPHONE_RULESET_ID.get_or_init(|| RulesetId("repairability-heuristic-v1".into()))
123    }
124
125    fn version(&self) -> &RulesetVersion {
126        SMARTPHONE_RULESET_VERSION.get_or_init(|| RulesetVersion("1.0.0".into()))
127    }
128
129    fn effective_dates(&self) -> &EffectiveDateBound {
130        SMARTPHONE_EFFECTIVE_DATES.get_or_init(|| {
131            EffectiveDateBound::open(NaiveDate::from_ymd_opt(2025, 6, 20).expect("valid date"))
132        })
133    }
134
135    fn regulatory_basis(&self) -> &RegulatoryBasis {
136        &SMARTPHONE_BASIS
137    }
138}
139
140impl RepairabilityRuleset for SimplifiedRepairabilityHeuristic {
141    fn weights(&self) -> &RepairabilityWeights {
142        &SMARTPHONE_WEIGHTS
143    }
144
145    fn thresholds(&self) -> &RepairabilityThresholds {
146        &SMARTPHONE_THRESHOLDS
147    }
148
149    fn validate_cross_fields(&self, inputs: &RepairabilityInputs) -> Result<(), CalcError> {
150        // Coherence rule: spare-parts availability presupposes disassembly.
151        // A score of 0 for disassembly combined with any spare-parts score > 0 is
152        // incoherent — the product cannot be repaired if it cannot be opened.
153        if inputs.disassembly == 0 && inputs.spare_parts > 0 {
154            return Err(CalcError::CrossFieldViolation(
155                "spare_parts requires disassembly ≥ 1: parts are inaccessible \
156                 without disassembly instructions"
157                    .into(),
158            ));
159        }
160        Ok(())
161    }
162}
163
164// ---------------------------------------------------------------------------
165// Laptops — reserved; product-specific delegated act expected ~2027
166// ---------------------------------------------------------------------------
167
168/// EN 45554 ruleset for laptops. **Not yet in force.**
169///
170/// Exists as a named stub so code can reference it at compile time, gated
171/// behind a `RegulatoryStatus::Provisional` check or the registry (which
172/// returns `None` for dates before 2100).
173pub struct LaptopRuleset;
174
175static LAPTOP_WEIGHTS: RepairabilityWeights = RepairabilityWeights {
176    disassembly: 0.20,
177    spare_parts: 0.25,
178    repair_info: 0.20,
179    diagnostic_tools: 0.10,
180    software_updatability: 0.15,
181    customer_support: 0.10,
182};
183
184static LAPTOP_THRESHOLDS: RepairabilityThresholds = RepairabilityThresholds {
185    a: 8.5,
186    b: 7.0,
187    c: 5.5,
188    d: 4.0,
189};
190
191static LAPTOP_BASIS: RegulatoryBasis = RegulatoryBasis {
192    regulation: "pending — ESPR laptop repairability delegated act (expected ~2027)",
193    article: "TBD",
194    standard: Some("EN 45554:2021"),
195    technical_study: None,
196    source_url: None,
197    superseded_by: None,
198};
199
200static LAPTOP_RULESET_ID: std::sync::OnceLock<RulesetId> = std::sync::OnceLock::new();
201static LAPTOP_RULESET_VERSION: std::sync::OnceLock<RulesetVersion> = std::sync::OnceLock::new();
202static LAPTOP_EFFECTIVE_DATES: std::sync::OnceLock<EffectiveDateBound> = std::sync::OnceLock::new();
203
204impl Ruleset for LaptopRuleset {
205    fn id(&self) -> &RulesetId {
206        LAPTOP_RULESET_ID.get_or_init(|| RulesetId("laptop-repairability".into()))
207    }
208
209    fn version(&self) -> &RulesetVersion {
210        LAPTOP_RULESET_VERSION.get_or_init(|| RulesetVersion("0.0.0-stub".into()))
211    }
212
213    fn effective_dates(&self) -> &EffectiveDateBound {
214        // Year 2100 sentinel: effective-date guard blocks runtime use while
215        // keeping the type compile-visible for future wiring.
216        LAPTOP_EFFECTIVE_DATES.get_or_init(|| {
217            EffectiveDateBound::open(NaiveDate::from_ymd_opt(2100, 1, 1).expect("valid date"))
218        })
219    }
220
221    fn regulatory_basis(&self) -> &RegulatoryBasis {
222        &LAPTOP_BASIS
223    }
224}
225
226impl RepairabilityRuleset for LaptopRuleset {
227    fn weights(&self) -> &RepairabilityWeights {
228        &LAPTOP_WEIGHTS
229    }
230
231    fn thresholds(&self) -> &RepairabilityThresholds {
232        &LAPTOP_THRESHOLDS
233    }
234}
235
236// ---------------------------------------------------------------------------
237// Electronic displays (TVs, monitors) — stub; ESPR delegated act expected
238// ---------------------------------------------------------------------------
239//
240// EU 2019/2021 covers ecodesign for electronic displays. An ESPR-era repairability
241// delegated act is expected. Weights below are placeholder (uniform) pending the
242// official annex. Effective-date sentinel: 2100-01-01 blocks runtime use.
243
244pub struct DisplaysRuleset;
245
246static DISPLAYS_WEIGHTS: RepairabilityWeights = RepairabilityWeights {
247    disassembly: 0.20,
248    spare_parts: 0.20,
249    repair_info: 0.20,
250    diagnostic_tools: 0.15,
251    software_updatability: 0.15,
252    customer_support: 0.10,
253};
254
255static DISPLAYS_THRESHOLDS: RepairabilityThresholds = RepairabilityThresholds {
256    a: 8.5,
257    b: 7.0,
258    c: 5.5,
259    d: 4.0,
260};
261
262static DISPLAYS_BASIS: RegulatoryBasis = RegulatoryBasis {
263    regulation: "pending — ESPR electronic displays repairability delegated act",
264    article: "TBD",
265    standard: Some("EN 45554:2021"),
266    technical_study: None,
267    source_url: None,
268    superseded_by: None,
269};
270
271static DISPLAYS_RULESET_ID: std::sync::OnceLock<RulesetId> = std::sync::OnceLock::new();
272static DISPLAYS_RULESET_VERSION: std::sync::OnceLock<RulesetVersion> = std::sync::OnceLock::new();
273static DISPLAYS_EFFECTIVE_DATES: std::sync::OnceLock<EffectiveDateBound> =
274    std::sync::OnceLock::new();
275
276impl Ruleset for DisplaysRuleset {
277    fn id(&self) -> &RulesetId {
278        DISPLAYS_RULESET_ID.get_or_init(|| RulesetId("displays-repairability".into()))
279    }
280
281    fn version(&self) -> &RulesetVersion {
282        DISPLAYS_RULESET_VERSION.get_or_init(|| RulesetVersion("0.0.0-stub".into()))
283    }
284
285    fn effective_dates(&self) -> &EffectiveDateBound {
286        DISPLAYS_EFFECTIVE_DATES.get_or_init(|| {
287            EffectiveDateBound::open(NaiveDate::from_ymd_opt(2100, 1, 1).expect("valid date"))
288        })
289    }
290
291    fn regulatory_basis(&self) -> &RegulatoryBasis {
292        &DISPLAYS_BASIS
293    }
294}
295
296impl RepairabilityRuleset for DisplaysRuleset {
297    fn weights(&self) -> &RepairabilityWeights {
298        &DISPLAYS_WEIGHTS
299    }
300
301    fn thresholds(&self) -> &RepairabilityThresholds {
302        &DISPLAYS_THRESHOLDS
303    }
304}
305
306// ---------------------------------------------------------------------------
307// Washing machines / washer-dryers — stub; ESPR delegated act expected
308// ---------------------------------------------------------------------------
309//
310// EU 2021/341 includes repairability requirements for washing machines. An
311// ESPR-era repairability index delegated act is expected ~2026. Weights and
312// thresholds below are placeholder pending the official annex.
313
314pub struct WashingMachineRuleset;
315
316static WASHING_WEIGHTS: RepairabilityWeights = RepairabilityWeights {
317    disassembly: 0.25,
318    spare_parts: 0.20,
319    repair_info: 0.20,
320    diagnostic_tools: 0.15,
321    software_updatability: 0.10,
322    customer_support: 0.10,
323};
324
325static WASHING_THRESHOLDS: RepairabilityThresholds = RepairabilityThresholds {
326    a: 8.5,
327    b: 7.0,
328    c: 5.5,
329    d: 4.0,
330};
331
332static WASHING_BASIS: RegulatoryBasis = RegulatoryBasis {
333    regulation: "pending — ESPR washing machine repairability delegated act (expected ~2026)",
334    article: "TBD",
335    standard: Some("EN 45554:2021"),
336    technical_study: None,
337    source_url: None,
338    superseded_by: None,
339};
340
341static WASHING_RULESET_ID: std::sync::OnceLock<RulesetId> = std::sync::OnceLock::new();
342static WASHING_RULESET_VERSION: std::sync::OnceLock<RulesetVersion> = std::sync::OnceLock::new();
343static WASHING_EFFECTIVE_DATES: std::sync::OnceLock<EffectiveDateBound> =
344    std::sync::OnceLock::new();
345
346impl Ruleset for WashingMachineRuleset {
347    fn id(&self) -> &RulesetId {
348        WASHING_RULESET_ID.get_or_init(|| RulesetId("washing-machine-repairability".into()))
349    }
350
351    fn version(&self) -> &RulesetVersion {
352        WASHING_RULESET_VERSION.get_or_init(|| RulesetVersion("0.0.0-stub".into()))
353    }
354
355    fn effective_dates(&self) -> &EffectiveDateBound {
356        WASHING_EFFECTIVE_DATES.get_or_init(|| {
357            EffectiveDateBound::open(NaiveDate::from_ymd_opt(2100, 1, 1).expect("valid date"))
358        })
359    }
360
361    fn regulatory_basis(&self) -> &RegulatoryBasis {
362        &WASHING_BASIS
363    }
364}
365
366impl RepairabilityRuleset for WashingMachineRuleset {
367    fn weights(&self) -> &RepairabilityWeights {
368        &WASHING_WEIGHTS
369    }
370
371    fn thresholds(&self) -> &RepairabilityThresholds {
372        &WASHING_THRESHOLDS
373    }
374}
375
376#[cfg(test)]
377mod stub_ruleset_tests {
378    use super::*;
379    use crate::error::CalcError;
380    use crate::repairability::calculate;
381
382    fn valid_inputs() -> RepairabilityInputs {
383        RepairabilityInputs {
384            disassembly: 2,
385            spare_parts: 2,
386            repair_info: 2,
387            diagnostic_tools: 2,
388            software_updatability: 2,
389            customer_support: 2,
390        }
391    }
392
393    #[test]
394    fn stub_rulesets_expose_consistent_metadata() {
395        let rulesets: [&dyn RepairabilityRuleset; 3] =
396            [&LaptopRuleset, &DisplaysRuleset, &WashingMachineRuleset];
397        for rs in rulesets {
398            let w = rs.weights();
399            let sum = w.disassembly
400                + w.spare_parts
401                + w.repair_info
402                + w.diagnostic_tools
403                + w.software_updatability
404                + w.customer_support;
405            assert!((sum - 1.0).abs() < 1e-9, "weights must sum to 1.0");
406            assert_eq!(rs.thresholds().a, 8.5);
407            assert!(!rs.id().0.is_empty());
408            assert!(!rs.version().0.is_empty());
409            assert!(!rs.regulatory_basis().regulation.is_empty());
410            // 2100 sentinel: these acts are not yet in force.
411            assert!(
412                !rs.effective_dates()
413                    .is_active_on(chrono::Utc::now().date_naive())
414            );
415        }
416    }
417
418    #[test]
419    fn calculating_with_a_not_yet_in_force_ruleset_is_rejected() {
420        // Laptop/Displays/Washing all carry the 2100 effective-date sentinel,
421        // so calculate() must refuse them via the RulesetExpired guard.
422        for result in [
423            calculate(&valid_inputs(), &LaptopRuleset),
424            calculate(&valid_inputs(), &DisplaysRuleset),
425            calculate(&valid_inputs(), &WashingMachineRuleset),
426        ] {
427            assert!(matches!(result, Err(CalcError::RulesetExpired { .. })));
428        }
429    }
430}