Skip to main content

solverforge_scoring/api/
analysis.rs

1/* Score analysis types for detailed constraint tracking.
2
3This module provides types for analyzing constraint matches in detail,
4including which entities are involved in each match, score explanations,
5and entity-level indictments.
6*/
7
8use std::any::Any;
9use std::collections::HashMap;
10use std::fmt::Debug;
11use std::hash::{Hash, Hasher};
12use std::sync::Arc;
13
14use solverforge_core::score::Score;
15use solverforge_core::ConstraintRef;
16
17/* Reference to an entity involved in a constraint match.
18Uses type erasure to allow storing references to different entity types
19in a single collection.
20*/
21#[derive(Clone)]
22pub struct EntityRef {
23    // Type name of the entity (e.g., "Shift", "Employee").
24    pub type_name: String,
25    // String representation for display.
26    pub display: String,
27    // Type-erased entity for programmatic access.
28    entity: Arc<dyn Any + Send + Sync>,
29}
30
31impl EntityRef {
32    // Creates a new entity reference from a concrete entity.
33    pub fn new<T: Clone + Debug + Send + Sync + 'static>(entity: &T) -> Self {
34        Self {
35            type_name: std::any::type_name::<T>().to_string(),
36            display: format!("{:?}", entity),
37            entity: Arc::new(entity.clone()),
38        }
39    }
40
41    // Creates an entity reference with a custom display string.
42    pub fn with_display<T: Clone + Send + Sync + 'static>(entity: &T, display: String) -> Self {
43        Self {
44            type_name: std::any::type_name::<T>().to_string(),
45            display,
46            entity: Arc::new(entity.clone()),
47        }
48    }
49
50    // Attempts to downcast to the concrete entity type.
51    pub fn as_entity<T: 'static>(&self) -> Option<&T> {
52        self.entity.downcast_ref::<T>()
53    }
54
55    // Returns the short type name (without module path).
56    pub fn short_type_name(&self) -> &str {
57        self.type_name
58            .rsplit("::")
59            .next()
60            .unwrap_or(&self.type_name)
61    }
62}
63
64impl Debug for EntityRef {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("EntityRef")
67            .field("type", &self.short_type_name())
68            .field("display", &self.display)
69            .finish()
70    }
71}
72
73impl PartialEq for EntityRef {
74    fn eq(&self, other: &Self) -> bool {
75        self.type_name == other.type_name && self.display == other.display
76    }
77}
78
79impl Eq for EntityRef {}
80
81impl Hash for EntityRef {
82    fn hash<H: Hasher>(&self, state: &mut H) {
83        self.type_name.hash(state);
84        self.display.hash(state);
85    }
86}
87
88// Justification for why a constraint matched.
89#[derive(Debug, Clone)]
90pub struct ConstraintJustification {
91    // Entities involved in the match.
92    pub entities: Vec<EntityRef>,
93    // Human-readable description of why the constraint matched.
94    pub description: String,
95}
96
97impl ConstraintJustification {
98    // Creates a justification from entities, auto-generating description.
99    pub fn new(entities: Vec<EntityRef>) -> Self {
100        let description = if entities.is_empty() {
101            "No entities".to_string()
102        } else {
103            entities
104                .iter()
105                .map(|e| e.display.as_str())
106                .collect::<Vec<_>>()
107                .join(", ")
108        };
109        Self {
110            entities,
111            description,
112        }
113    }
114
115    // Creates a justification with a custom description.
116    pub fn with_description(entities: Vec<EntityRef>, description: String) -> Self {
117        Self {
118            entities,
119            description,
120        }
121    }
122}
123
124// A detailed constraint match with entity information.
125#[derive(Debug, Clone)]
126pub struct DetailedConstraintMatch<Sc: Score> {
127    // Reference to the constraint that matched.
128    pub constraint_ref: ConstraintRef,
129    // Score impact of this match.
130    pub score: Sc,
131    // Justification with involved entities.
132    pub justification: ConstraintJustification,
133}
134
135impl<Sc: Score> DetailedConstraintMatch<Sc> {
136    // Creates a new detailed constraint match.
137    pub fn new(
138        constraint_ref: ConstraintRef,
139        score: Sc,
140        justification: ConstraintJustification,
141    ) -> Self {
142        Self {
143            constraint_ref,
144            score,
145            justification,
146        }
147    }
148}
149
150// Extended constraint evaluation with detailed match information.
151#[derive(Debug, Clone)]
152pub struct DetailedConstraintEvaluation<Sc: Score> {
153    // Total score impact from all matches.
154    pub total_score: Sc,
155    // Number of matches found.
156    pub match_count: usize,
157    // Detailed information for each match.
158    pub matches: Vec<DetailedConstraintMatch<Sc>>,
159}
160
161impl<Sc: Score> DetailedConstraintEvaluation<Sc> {
162    // Creates a new detailed evaluation.
163    pub fn new(total_score: Sc, matches: Vec<DetailedConstraintMatch<Sc>>) -> Self {
164        let match_count = matches.len();
165        Self {
166            total_score,
167            match_count,
168            matches,
169        }
170    }
171
172    // Creates an empty evaluation (no matches).
173    pub fn empty() -> Self {
174        Self {
175            total_score: Sc::zero(),
176            match_count: 0,
177            matches: Vec::new(),
178        }
179    }
180}
181
182// Per-constraint breakdown in a score explanation.
183#[derive(Debug, Clone)]
184pub struct ConstraintAnalysis<Sc: Score> {
185    // Constraint reference.
186    pub constraint_ref: ConstraintRef,
187    // Constraint weight (score per match).
188    pub weight: Sc,
189    // Total score from this constraint.
190    pub score: Sc,
191    // All matches for this constraint.
192    pub matches: Vec<DetailedConstraintMatch<Sc>>,
193    // Whether this is a hard constraint.
194    pub is_hard: bool,
195}
196
197impl<Sc: Score> ConstraintAnalysis<Sc> {
198    // Creates a new constraint analysis.
199    pub fn new(
200        constraint_ref: ConstraintRef,
201        weight: Sc,
202        score: Sc,
203        matches: Vec<DetailedConstraintMatch<Sc>>,
204        is_hard: bool,
205    ) -> Self {
206        Self {
207            constraint_ref,
208            weight,
209            score,
210            matches,
211            is_hard,
212        }
213    }
214
215    // Returns the number of matches.
216    pub fn match_count(&self) -> usize {
217        self.matches.len()
218    }
219
220    // Returns the constraint name.
221    pub fn name(&self) -> &str {
222        &self.constraint_ref.name
223    }
224}
225
226// Complete score explanation with per-constraint breakdown.
227#[derive(Debug, Clone)]
228pub struct ScoreExplanation<Sc: Score> {
229    // The total score.
230    pub score: Sc,
231    // Per-constraint breakdown.
232    pub constraint_analyses: Vec<ConstraintAnalysis<Sc>>,
233}
234
235impl<Sc: Score> ScoreExplanation<Sc> {
236    // Creates a new score explanation.
237    pub fn new(score: Sc, constraint_analyses: Vec<ConstraintAnalysis<Sc>>) -> Self {
238        Self {
239            score,
240            constraint_analyses,
241        }
242    }
243
244    // Returns the total match count across all constraints.
245    pub fn total_match_count(&self) -> usize {
246        self.constraint_analyses
247            .iter()
248            .map(|a| a.match_count())
249            .sum()
250    }
251
252    // Returns constraints with non-zero scores.
253    pub fn non_zero_constraints(&self) -> Vec<&ConstraintAnalysis<Sc>> {
254        self.constraint_analyses
255            .iter()
256            .filter(|a| a.score != Sc::zero())
257            .collect()
258    }
259
260    // Returns all detailed matches across all constraints.
261    pub fn all_matches(&self) -> Vec<&DetailedConstraintMatch<Sc>> {
262        self.constraint_analyses
263            .iter()
264            .flat_map(|a| &a.matches)
265            .collect()
266    }
267}
268
269// Analysis of how a single entity impacts the score.
270#[derive(Debug, Clone)]
271pub struct Indictment<Sc: Score> {
272    // The entity being analyzed.
273    pub entity: EntityRef,
274    // Total score impact from this entity.
275    pub score: Sc,
276    // Matches involving this entity, grouped by constraint.
277    pub constraint_matches: HashMap<ConstraintRef, Vec<DetailedConstraintMatch<Sc>>>,
278}
279
280impl<Sc: Score> Indictment<Sc> {
281    // Creates a new indictment for an entity.
282    pub fn new(entity: EntityRef) -> Self {
283        Self {
284            entity,
285            score: Sc::zero(),
286            constraint_matches: HashMap::new(),
287        }
288    }
289
290    // Adds a match to this indictment.
291    pub fn add_match(&mut self, constraint_match: DetailedConstraintMatch<Sc>) {
292        self.score = self.score + constraint_match.score;
293        self.constraint_matches
294            .entry(constraint_match.constraint_ref.clone())
295            .or_default()
296            .push(constraint_match);
297    }
298
299    // Returns the total number of constraint violations.
300    pub fn match_count(&self) -> usize {
301        self.constraint_matches
302            .values()
303            .map(|v| v.len())
304            .sum::<usize>()
305    }
306
307    // Returns the constraint refs for all violated constraints.
308    pub fn violated_constraints(&self) -> Vec<&ConstraintRef> {
309        self.constraint_matches.keys().collect()
310    }
311
312    // Returns the number of distinct constraints violated.
313    pub fn constraint_count(&self) -> usize {
314        self.constraint_matches.len()
315    }
316}
317
318// Map of entity indictments for analyzing which entities cause violations.
319#[derive(Debug, Clone)]
320pub struct IndictmentMap<Sc: Score> {
321    // Indictments keyed by entity reference.
322    pub indictments: HashMap<EntityRef, Indictment<Sc>>,
323}
324
325impl<Sc: Score> IndictmentMap<Sc> {
326    // Creates an empty indictment map.
327    pub fn new() -> Self {
328        Self {
329            indictments: HashMap::new(),
330        }
331    }
332
333    // Builds an indictment map from a collection of detailed matches.
334    pub fn from_matches(matches: Vec<DetailedConstraintMatch<Sc>>) -> Self {
335        let mut map = Self::new();
336        for m in matches {
337            for entity in &m.justification.entities {
338                map.indictments
339                    .entry(entity.clone())
340                    .or_insert_with(|| Indictment::new(entity.clone()))
341                    .add_match(m.clone());
342            }
343        }
344        map
345    }
346
347    // Gets the indictment for a specific entity.
348    pub fn get(&self, entity: &EntityRef) -> Option<&Indictment<Sc>> {
349        self.indictments.get(entity)
350    }
351
352    // Returns all indicted entities.
353    pub fn entities(&self) -> impl Iterator<Item = &EntityRef> {
354        self.indictments.keys()
355    }
356
357    // Returns entities sorted by worst score impact (most negative first).
358    pub fn worst_entities(&self) -> Vec<&EntityRef> {
359        let mut entities: Vec<_> = self.indictments.keys().collect();
360        entities.sort_by(|a, b| {
361            let score_a = &self.indictments[*a].score;
362            let score_b = &self.indictments[*b].score;
363            score_a.cmp(score_b)
364        });
365        entities
366    }
367
368    // Returns the number of indicted entities.
369    pub fn len(&self) -> usize {
370        self.indictments.len()
371    }
372
373    // Returns true if no entities are indicted.
374    pub fn is_empty(&self) -> bool {
375        self.indictments.is_empty()
376    }
377}
378
379impl<Sc: Score> Default for IndictmentMap<Sc> {
380    fn default() -> Self {
381        Self::new()
382    }
383}