Skip to main content

git_lore/lore/
entropy.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use super::merge::{MergeConflictKind, MergeOutcome};
6use super::{AtomState, LoreAtom, Workspace, WorkspaceState};
7
8#[derive(Clone, Debug, Serialize, Deserialize)]
9pub struct Contradiction {
10    pub key: String,
11    pub kind: MergeConflictKind,
12    pub message: String,
13    pub atoms: Vec<LoreAtom>,
14}
15
16#[derive(Clone, Debug, Serialize, Deserialize)]
17pub struct EntropyReport {
18    pub score: u8,
19    pub total_atoms: usize,
20    pub draft_atoms: usize,
21    pub proposed_atoms: usize,
22    pub accepted_atoms: usize,
23    pub deprecated_atoms: usize,
24    pub open_question_atoms: usize,
25    pub signal_atoms: usize,
26    pub distinct_locations: usize,
27    pub contradictions: Vec<Contradiction>,
28    pub notes: Vec<String>,
29}
30
31pub fn analyze_workspace(state: &WorkspaceState) -> EntropyReport {
32    build_report(&state.atoms, Vec::new())
33}
34
35pub fn analyze_merge_outcome(outcome: &MergeOutcome) -> EntropyReport {
36    let merge_contradictions = outcome
37        .conflicts
38        .iter()
39        .map(contradiction_from_merge_conflict)
40        .collect::<Vec<_>>();
41
42    build_report(&outcome.merged, merge_contradictions)
43}
44
45impl Workspace {
46    pub fn contradiction_report(&self) -> anyhow::Result<EntropyReport> {
47        self.entropy_report()
48    }
49}
50
51fn build_report(atoms: &[LoreAtom], extra_contradictions: Vec<Contradiction>) -> EntropyReport {
52    let counts = atom_counts(atoms);
53    let mut contradictions = locate_contradictions(atoms);
54    contradictions.extend(extra_contradictions);
55    contradictions = deduplicate_contradictions(contradictions);
56
57    let distinct_locations = distinct_location_count(atoms);
58    let score = entropy_score(&counts, distinct_locations, contradictions.len());
59    let mut notes = entropy_notes(score, contradictions.len(), counts.accepted_atoms, counts.proposed_atoms);
60
61    if contradictions.is_empty() {
62        notes.push("No contradictions detected.".to_string());
63    } else {
64        notes.push(format!("{} contradiction(s) reported.", contradictions.len()));
65    }
66
67    EntropyReport {
68        score,
69        total_atoms: atoms.len(),
70        draft_atoms: counts.draft_atoms,
71        proposed_atoms: counts.proposed_atoms,
72        accepted_atoms: counts.accepted_atoms,
73        deprecated_atoms: counts.deprecated_atoms,
74        open_question_atoms: counts.open_question_atoms,
75        signal_atoms: counts.signal_atoms,
76        distinct_locations,
77        contradictions,
78        notes,
79    }
80}
81
82#[derive(Default)]
83struct AtomCounts {
84    draft_atoms: usize,
85    proposed_atoms: usize,
86    accepted_atoms: usize,
87    deprecated_atoms: usize,
88    open_question_atoms: usize,
89    signal_atoms: usize,
90}
91
92fn atom_counts(atoms: &[LoreAtom]) -> AtomCounts {
93    let mut counts = AtomCounts::default();
94
95    for atom in atoms {
96        match atom.state {
97            AtomState::Draft => counts.draft_atoms += 1,
98            AtomState::Proposed => counts.proposed_atoms += 1,
99            AtomState::Accepted => counts.accepted_atoms += 1,
100            AtomState::Deprecated => counts.deprecated_atoms += 1,
101        }
102
103        match atom.kind {
104            super::LoreKind::OpenQuestion => counts.open_question_atoms += 1,
105            super::LoreKind::Signal => counts.signal_atoms += 1,
106            _ => {}
107        }
108    }
109
110    counts
111}
112
113fn entropy_score(counts: &AtomCounts, distinct_locations: usize, contradiction_count: usize) -> u8 {
114    let unresolved = counts.draft_atoms + counts.proposed_atoms + counts.open_question_atoms + counts.signal_atoms;
115    let mut score = 0i32;
116
117    score += (unresolved as i32) * 12;
118    score += (distinct_locations as i32) * 2;
119    score += (contradiction_count as i32) * 18;
120    score -= (counts.accepted_atoms as i32) * 6;
121    score -= (counts.deprecated_atoms as i32) * 4;
122
123    score.clamp(0, 100) as u8
124}
125
126fn entropy_notes(score: u8, contradiction_count: usize, accepted_atoms: usize, proposed_atoms: usize) -> Vec<String> {
127    let mut notes = Vec::new();
128
129    if score >= 70 {
130        notes.push("High entropy: unresolved rationale dominates the current state.".to_string());
131    } else if score >= 35 {
132        notes.push("Moderate entropy: active rationale still competes with finalized decisions.".to_string());
133    } else {
134        notes.push("Low entropy: accepted decisions dominate the current state.".to_string());
135    }
136
137    if contradiction_count > 0 {
138        notes.push(format!("{} contradiction(s) contribute to the score.", contradiction_count));
139    }
140
141    if accepted_atoms > proposed_atoms {
142        notes.push("Decision-heavy state detected.".to_string());
143    }
144
145    notes
146}
147
148fn distinct_location_count(atoms: &[LoreAtom]) -> usize {
149    atoms
150        .iter()
151        .map(location_key)
152        .collect::<BTreeSet<_>>()
153        .len()
154}
155
156fn locate_contradictions(atoms: &[LoreAtom]) -> Vec<Contradiction> {
157    let mut grouped: BTreeMap<String, Vec<LoreAtom>> = BTreeMap::new();
158
159    for atom in atoms {
160        if atom.state != AtomState::Deprecated {
161            grouped.entry(location_key(atom)).or_default().push(atom.clone());
162        }
163    }
164
165    let mut contradictions = Vec::new();
166
167    for (key, group) in grouped {
168        if group.len() < 2 {
169            continue;
170        }
171
172        if let Some(contradiction) = type_contradiction(&key, &group) {
173            contradictions.push(contradiction);
174        }
175    }
176
177    contradictions
178}
179
180fn type_contradiction(key: &str, atoms: &[LoreAtom]) -> Option<Contradiction> {
181    let first = &atoms[0];
182    let mut variations = atoms.iter().filter(|atom| !same_lore_content(first, atom));
183
184    if variations.next().is_some() {
185        Some(Contradiction {
186            key: key.to_string(),
187            kind: MergeConflictKind::TypeConflict,
188            message: format!("Type conflict at {key}: multiple rationale variants exist for the same location"),
189            atoms: atoms.to_vec(),
190        })
191    } else {
192        None
193    }
194}
195
196fn contradiction_from_merge_conflict(conflict: &super::merge::MergeConflict) -> Contradiction {
197    let mut atoms = Vec::new();
198    if let Some(atom) = conflict.base.clone() {
199        atoms.push(atom);
200    }
201    if let Some(atom) = conflict.left.clone() {
202        atoms.push(atom);
203    }
204    if let Some(atom) = conflict.right.clone() {
205        atoms.push(atom);
206    }
207
208    Contradiction {
209        key: conflict.key.clone(),
210        kind: conflict.kind.clone(),
211        message: conflict.message.clone(),
212        atoms,
213    }
214}
215
216fn deduplicate_contradictions(contradictions: Vec<Contradiction>) -> Vec<Contradiction> {
217    let mut seen = BTreeSet::new();
218    let mut deduped = Vec::new();
219
220    for contradiction in contradictions {
221        let mut atom_ids = contradiction
222            .atoms
223            .iter()
224            .map(|atom| atom.id.as_str())
225            .collect::<Vec<_>>();
226        atom_ids.sort_unstable();
227        let signature = format!("{}::{:?}::{}", contradiction.key, contradiction.kind, atom_ids.join(","));
228
229        if seen.insert(signature) {
230            deduped.push(contradiction);
231        }
232    }
233
234    deduped.sort_by(|left, right| {
235        left.key
236            .cmp(&right.key)
237            .then(format!("{:?}", left.kind).cmp(&format!("{:?}", right.kind)))
238    });
239    deduped
240}
241
242fn location_key(atom: &LoreAtom) -> String {
243    let path = atom
244        .path
245        .as_ref()
246        .map(|value| value.to_string_lossy().replace('\\', "/"))
247        .unwrap_or_else(|| "<no-path>".to_string());
248    let scope = atom.scope.as_deref().unwrap_or("<no-scope>");
249    format!("{path}::{scope}")
250}
251
252fn same_lore_content(left: &LoreAtom, right: &LoreAtom) -> bool {
253    left.kind == right.kind
254        && left.title == right.title
255        && left.body == right.body
256        && left.scope == right.scope
257        && left.path == right.path
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::lore::{AtomState, LoreKind};
264    use std::path::PathBuf;
265
266    fn atom(id: &str, kind: LoreKind, state: AtomState, title: &str, body: Option<&str>) -> LoreAtom {
267        LoreAtom {
268            id: id.to_string(),
269            kind,
270            state,
271            title: title.to_string(),
272            body: body.map(str::to_string),
273            scope: Some("compute".to_string()),
274            path: Some(PathBuf::from("src/lib.rs")),
275            validation_script: None,
276            created_unix_seconds: 1,
277        }
278    }
279
280    #[test]
281    fn entropy_report_flags_conflicting_state() {
282        let state = WorkspaceState {
283            version: 1,
284            atoms: vec![
285                atom("accepted-1", LoreKind::Decision, AtomState::Accepted, "Use SQLite", None),
286                atom("proposed-1", LoreKind::Decision, AtomState::Proposed, "Use PostgreSQL", None),
287            ],
288        };
289
290        let report = analyze_workspace(&state);
291        assert!(report.score > 0);
292        assert_eq!(report.contradictions.len(), 1);
293        assert_eq!(report.contradictions[0].kind, MergeConflictKind::TypeConflict);
294    }
295
296    #[test]
297    fn merge_outcome_contradictions_feed_entropy_reports() {
298        let conflict = super::super::merge::MergeConflict {
299            key: "src/lib.rs::compute".to_string(),
300            kind: MergeConflictKind::DependencyConflict,
301            message: "Dependency conflict at src/lib.rs::compute".to_string(),
302            base: Some(atom("base", LoreKind::Decision, AtomState::Accepted, "Keep helper", None)),
303            left: Some(atom("left", LoreKind::Decision, AtomState::Deprecated, "Remove helper", None)),
304            right: Some(atom("right", LoreKind::Decision, AtomState::Accepted, "Keep helper", None)),
305        };
306
307        let outcome = MergeOutcome {
308            merged: vec![atom("merged", LoreKind::Decision, AtomState::Accepted, "Keep helper", None)],
309            conflicts: vec![conflict],
310            notes: vec![],
311        };
312
313        let report = analyze_merge_outcome(&outcome);
314        assert_eq!(report.contradictions.len(), 1);
315        assert_eq!(report.contradictions[0].kind, MergeConflictKind::DependencyConflict);
316    }
317}