Skip to main content

seshat_core/
knowledge.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::ParseEnumError;
4use crate::ids::{BranchId, NodeId};
5
6/// A node in the knowledge graph.
7///
8/// Each node has a two-dimensional type: `nature` (what kind of knowledge)
9/// crossed with `weight` (how authoritative). Confidence is computed from
10/// adoption metrics: `adoption_count / total_count`.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub struct KnowledgeNode {
14    pub id: NodeId,
15    pub branch_id: BranchId,
16    pub nature: KnowledgeNature,
17    pub weight: KnowledgeWeight,
18    pub confidence: f64,
19    pub adoption_count: u32,
20    pub total_count: u32,
21    pub description: String,
22    /// JSON-encoded type-specific data (e.g., `reasoning` for Decision,
23    /// `adoption_rate` for Convention).
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub ext_data: Option<serde_json::Value>,
26}
27
28/// The nature of a knowledge node — what kind of knowledge it represents.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum KnowledgeNature {
32    /// A verifiable fact about the codebase.
33    Fact,
34    /// A detected coding convention.
35    Convention,
36    /// A pattern observed in code without enough adoption to be a convention.
37    Observation,
38    /// An explicit architectural or design decision.
39    Decision,
40    /// A user-confirmed preference.
41    Preference,
42}
43
44/// Trend indicator for a convention — whether it is being adopted or abandoned.
45///
46/// Computed from the P90 percentile of file commit dates associated with a
47/// convention group. See [`crate::DetectionConfig`] for the configurable
48/// thresholds (`trend_rising_days`, `trend_stable_days`).
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum Trend {
52    /// Convention is being actively adopted (P90 date within `trend_rising_days`).
53    Rising,
54    /// Convention adoption is neither growing nor shrinking.
55    Stable,
56    /// Convention is falling out of use (P90 date older than `trend_stable_days`).
57    Declining,
58    /// Not enough data to determine trend (no valid file dates).
59    Unknown,
60}
61
62impl Trend {
63    /// Return the canonical snake_case representation.
64    pub fn as_str(&self) -> &'static str {
65        match self {
66            Self::Rising => "rising",
67            Self::Stable => "stable",
68            Self::Declining => "declining",
69            Self::Unknown => "unknown",
70        }
71    }
72}
73
74impl std::fmt::Display for Trend {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            Self::Rising => write!(f, "Rising"),
78            Self::Stable => write!(f, "Stable"),
79            Self::Declining => write!(f, "Declining"),
80            Self::Unknown => write!(f, "Unknown"),
81        }
82    }
83}
84
85impl std::str::FromStr for Trend {
86    type Err = ParseEnumError;
87
88    fn from_str(s: &str) -> Result<Self, Self::Err> {
89        match s {
90            "rising" => Ok(Self::Rising),
91            "stable" => Ok(Self::Stable),
92            "declining" => Ok(Self::Declining),
93            "unknown" => Ok(Self::Unknown),
94            _ => Err(ParseEnumError {
95                type_name: "Trend",
96                value: s.to_owned(),
97            }),
98        }
99    }
100}
101
102/// The weight (authoritativeness) of a knowledge node.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum KnowledgeWeight {
106    /// Must follow. Surfaced by `validate_approach` in `relevant_rules`.
107    Rule,
108    /// Strongly recommended (confidence > 0.85).
109    Strong,
110    /// Moderately recommended (confidence 0.50–0.85).
111    Moderate,
112    /// Weakly recommended (confidence 0.20–0.50).
113    Weak,
114    /// Informational only (confidence < 0.20).
115    Info,
116}
117
118impl KnowledgeNature {
119    /// Return the canonical snake_case representation.
120    pub fn as_str(&self) -> &'static str {
121        match self {
122            Self::Fact => "fact",
123            Self::Convention => "convention",
124            Self::Observation => "observation",
125            Self::Decision => "decision",
126            Self::Preference => "preference",
127        }
128    }
129}
130
131impl std::fmt::Display for KnowledgeNature {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        match self {
134            Self::Fact => write!(f, "Fact"),
135            Self::Convention => write!(f, "Convention"),
136            Self::Observation => write!(f, "Observation"),
137            Self::Decision => write!(f, "Decision"),
138            Self::Preference => write!(f, "Preference"),
139        }
140    }
141}
142
143impl std::str::FromStr for KnowledgeNature {
144    type Err = ParseEnumError;
145
146    fn from_str(s: &str) -> Result<Self, Self::Err> {
147        match s {
148            "fact" => Ok(Self::Fact),
149            "convention" => Ok(Self::Convention),
150            "observation" => Ok(Self::Observation),
151            "decision" => Ok(Self::Decision),
152            "preference" => Ok(Self::Preference),
153            _ => Err(ParseEnumError {
154                type_name: "KnowledgeNature",
155                value: s.to_owned(),
156            }),
157        }
158    }
159}
160
161impl KnowledgeWeight {
162    /// Return the canonical snake_case representation.
163    pub fn as_str(&self) -> &'static str {
164        match self {
165            Self::Rule => "rule",
166            Self::Strong => "strong",
167            Self::Moderate => "moderate",
168            Self::Weak => "weak",
169            Self::Info => "info",
170        }
171    }
172}
173
174impl std::fmt::Display for KnowledgeWeight {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        match self {
177            Self::Rule => write!(f, "Rule"),
178            Self::Strong => write!(f, "Strong"),
179            Self::Moderate => write!(f, "Moderate"),
180            Self::Weak => write!(f, "Weak"),
181            Self::Info => write!(f, "Info"),
182        }
183    }
184}
185
186impl std::str::FromStr for KnowledgeWeight {
187    type Err = ParseEnumError;
188
189    fn from_str(s: &str) -> Result<Self, Self::Err> {
190        match s {
191            "rule" => Ok(Self::Rule),
192            "strong" => Ok(Self::Strong),
193            "moderate" => Ok(Self::Moderate),
194            "weak" => Ok(Self::Weak),
195            "info" => Ok(Self::Info),
196            _ => Err(ParseEnumError {
197                type_name: "KnowledgeWeight",
198                value: s.to_owned(),
199            }),
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::ids::{BranchId, NodeId};
208
209    #[test]
210    fn knowledge_node_serialization_roundtrip() {
211        let node = KnowledgeNode {
212            id: NodeId(42),
213            branch_id: BranchId::from("main"),
214            nature: KnowledgeNature::Convention,
215            weight: KnowledgeWeight::Strong,
216            confidence: 0.92,
217            adoption_count: 23,
218            total_count: 25,
219            description: "Use thiserror for error types".to_owned(),
220            ext_data: None,
221        };
222
223        let json = serde_json::to_string(&node).expect("serialize");
224        assert!(!json.contains("ext_data"), "None fields should be skipped");
225
226        let deserialized: KnowledgeNode = serde_json::from_str(&json).expect("deserialize");
227        assert_eq!(deserialized.id, node.id);
228        assert_eq!(deserialized.nature, KnowledgeNature::Convention);
229        assert_eq!(deserialized.weight, KnowledgeWeight::Strong);
230        assert!((deserialized.confidence - 0.92).abs() < f64::EPSILON);
231    }
232
233    #[test]
234    fn knowledge_node_with_ext_data() {
235        let node = KnowledgeNode {
236            id: NodeId(1),
237            branch_id: BranchId::from("feature"),
238            nature: KnowledgeNature::Decision,
239            weight: KnowledgeWeight::Rule,
240            confidence: 1.0,
241            adoption_count: 1,
242            total_count: 1,
243            description: "Use SQLite for storage".to_owned(),
244            ext_data: Some(serde_json::json!({"reasoning": "Embedded, no runtime deps"})),
245        };
246
247        let json = serde_json::to_string(&node).expect("serialize");
248        assert!(json.contains("ext_data"));
249        assert!(json.contains("reasoning"));
250    }
251
252    #[test]
253    fn nature_and_weight_display() {
254        assert_eq!(KnowledgeNature::Convention.to_string(), "Convention");
255        assert_eq!(KnowledgeWeight::Strong.to_string(), "Strong");
256    }
257
258    #[test]
259    fn nature_roundtrip_str() {
260        let natures = [
261            KnowledgeNature::Fact,
262            KnowledgeNature::Convention,
263            KnowledgeNature::Observation,
264            KnowledgeNature::Decision,
265            KnowledgeNature::Preference,
266        ];
267        for n in natures {
268            let parsed: KnowledgeNature = n.as_str().parse().unwrap();
269            assert_eq!(parsed, n);
270        }
271    }
272
273    #[test]
274    fn weight_roundtrip_str() {
275        let weights = [
276            KnowledgeWeight::Rule,
277            KnowledgeWeight::Strong,
278            KnowledgeWeight::Moderate,
279            KnowledgeWeight::Weak,
280            KnowledgeWeight::Info,
281        ];
282        for w in weights {
283            let parsed: KnowledgeWeight = w.as_str().parse().unwrap();
284            assert_eq!(parsed, w);
285        }
286    }
287
288    #[test]
289    fn all_nature_variants() {
290        let natures = [
291            KnowledgeNature::Fact,
292            KnowledgeNature::Convention,
293            KnowledgeNature::Observation,
294            KnowledgeNature::Decision,
295            KnowledgeNature::Preference,
296        ];
297        assert_eq!(natures.len(), 5);
298    }
299
300    #[test]
301    fn all_weight_variants() {
302        let weights = [
303            KnowledgeWeight::Rule,
304            KnowledgeWeight::Strong,
305            KnowledgeWeight::Moderate,
306            KnowledgeWeight::Weak,
307            KnowledgeWeight::Info,
308        ];
309        assert_eq!(weights.len(), 5);
310    }
311
312    #[test]
313    fn trend_roundtrip_str() {
314        let trends = [
315            Trend::Rising,
316            Trend::Stable,
317            Trend::Declining,
318            Trend::Unknown,
319        ];
320        for t in trends {
321            let parsed: Trend = t.as_str().parse().unwrap();
322            assert_eq!(parsed, t);
323        }
324    }
325
326    #[test]
327    fn trend_display() {
328        assert_eq!(Trend::Rising.to_string(), "Rising");
329        assert_eq!(Trend::Stable.to_string(), "Stable");
330        assert_eq!(Trend::Declining.to_string(), "Declining");
331        assert_eq!(Trend::Unknown.to_string(), "Unknown");
332    }
333
334    #[test]
335    fn trend_serde_roundtrip() {
336        let trend = Trend::Rising;
337        let json = serde_json::to_string(&trend).expect("serialize");
338        assert_eq!(json, r#""rising""#);
339        let deserialized: Trend = serde_json::from_str(&json).expect("deserialize");
340        assert_eq!(deserialized, trend);
341    }
342
343    #[test]
344    fn all_trend_variants() {
345        let trends = [
346            Trend::Rising,
347            Trend::Stable,
348            Trend::Declining,
349            Trend::Unknown,
350        ];
351        assert_eq!(trends.len(), 4);
352    }
353}