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