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}