Skip to main content

solverforge_scoring/api/
analysis.rs

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