Skip to main content

magi_core/
schema.rs

1// Author: Julian Bolivar
2// Version: 1.0.0
3// Date: 2026-04-05
4
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7use std::cmp::Ordering;
8use std::fmt;
9use std::sync::LazyLock;
10
11/// Zero-width Unicode character pattern (category Cf) shared across modules.
12///
13/// Matches soft hyphens, Arabic markers, zero-width spaces, directional marks,
14/// byte order marks, and other invisible formatting characters. Used by
15/// [`Finding::stripped_title`] and [`crate::validate::Validator`].
16pub static ZERO_WIDTH_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
17    Regex::new(
18        "[\u{00AD}\u{0600}-\u{0605}\u{061C}\u{06DD}\u{070F}\u{08E2}\u{180E}\
19         \u{200B}-\u{200F}\u{202A}-\u{202E}\u{2060}-\u{2064}\u{2066}-\u{206F}\
20         \u{FEFF}\u{FFF9}-\u{FFFB}]",
21    )
22    .expect("zero-width regex is valid")
23});
24
25/// An agent's judgment on the analyzed content.
26///
27/// Serializes as lowercase (`"approve"`, `"reject"`, `"conditional"`).
28/// Display outputs uppercase (`"APPROVE"`, `"REJECT"`, `"CONDITIONAL"`).
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum Verdict {
32    /// The agent approves the content.
33    Approve,
34    /// The agent rejects the content.
35    Reject,
36    /// The agent approves with conditions; counts as approval for majority.
37    Conditional,
38}
39
40impl Verdict {
41    /// Returns the numeric weight for consensus score computation.
42    ///
43    /// - `Approve` => `+1.0`
44    /// - `Reject` => `-1.0`
45    /// - `Conditional` => `+0.5`
46    pub fn weight(&self) -> f64 {
47        match self {
48            Verdict::Approve => 1.0,
49            Verdict::Reject => -1.0,
50            Verdict::Conditional => 0.5,
51        }
52    }
53
54    /// Maps the verdict to its effective binary form for majority counting.
55    ///
56    /// `Conditional` maps to `Approve`; others are identity.
57    pub fn effective(&self) -> Verdict {
58        match self {
59            Verdict::Approve | Verdict::Conditional => Verdict::Approve,
60            Verdict::Reject => Verdict::Reject,
61        }
62    }
63}
64
65impl fmt::Display for Verdict {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            Verdict::Approve => write!(f, "APPROVE"),
69            Verdict::Reject => write!(f, "REJECT"),
70            Verdict::Conditional => write!(f, "CONDITIONAL"),
71        }
72    }
73}
74
75/// Severity level of a finding reported by an agent.
76///
77/// Ordering: `Critical > Warning > Info`.
78/// Serializes as lowercase (`"critical"`, `"warning"`, `"info"`).
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum Severity {
82    /// Highest severity — blocks approval.
83    Critical,
84    /// Medium severity — warrants attention.
85    Warning,
86    /// Low severity — informational only.
87    Info,
88}
89
90impl Severity {
91    /// Returns a short icon string for report formatting.
92    ///
93    /// - `Critical` => `"[!!!]"`
94    /// - `Warning` => `"[!!]"`
95    /// - `Info` => `"[i]"`
96    pub fn icon(&self) -> &'static str {
97        match self {
98            Severity::Critical => "[!!!]",
99            Severity::Warning => "[!!]",
100            Severity::Info => "[i]",
101        }
102    }
103}
104
105impl fmt::Display for Severity {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match self {
108            Severity::Critical => write!(f, "CRITICAL"),
109            Severity::Warning => write!(f, "WARNING"),
110            Severity::Info => write!(f, "INFO"),
111        }
112    }
113}
114
115impl PartialOrd for Severity {
116    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
117        Some(self.cmp(other))
118    }
119}
120
121impl Ord for Severity {
122    fn cmp(&self, other: &Self) -> Ordering {
123        fn rank(s: &Severity) -> u8 {
124            match s {
125                Severity::Info => 0,
126                Severity::Warning => 1,
127                Severity::Critical => 2,
128            }
129        }
130        rank(self).cmp(&rank(other))
131    }
132}
133
134/// Analysis mode that determines agent perspectives.
135///
136/// Serializes as kebab-case (`"code-review"`, `"design"`, `"analysis"`).
137#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
138#[serde(rename_all = "kebab-case")]
139pub enum Mode {
140    /// Source code review perspective.
141    CodeReview,
142    /// Architecture and design perspective.
143    Design,
144    /// General analysis perspective.
145    Analysis,
146}
147
148impl fmt::Display for Mode {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            Mode::CodeReview => write!(f, "code-review"),
152            Mode::Design => write!(f, "design"),
153            Mode::Analysis => write!(f, "analysis"),
154        }
155    }
156}
157
158/// Identifies one of the three MAGI agents.
159///
160/// Ordering is alphabetical (`Balthasar < Caspar < Melchior`) for
161/// deterministic tiebreaking in consensus.
162/// Serializes as lowercase (`"melchior"`, `"balthasar"`, `"caspar"`).
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
164#[serde(rename_all = "lowercase")]
165pub enum AgentName {
166    /// The Scientist — innovative, research-oriented perspective.
167    Melchior,
168    /// The Pragmatist — practical, engineering-oriented perspective.
169    Balthasar,
170    /// The Critic — skeptical, risk-oriented perspective.
171    Caspar,
172}
173
174impl AgentName {
175    /// Returns the agent's analytical role title.
176    ///
177    /// - `Melchior` => `"Scientist"`
178    /// - `Balthasar` => `"Pragmatist"`
179    /// - `Caspar` => `"Critic"`
180    pub fn title(&self) -> &'static str {
181        match self {
182            AgentName::Melchior => "Scientist",
183            AgentName::Balthasar => "Pragmatist",
184            AgentName::Caspar => "Critic",
185        }
186    }
187
188    /// Returns the agent's proper name as a string.
189    pub fn display_name(&self) -> &'static str {
190        match self {
191            AgentName::Melchior => "Melchior",
192            AgentName::Balthasar => "Balthasar",
193            AgentName::Caspar => "Caspar",
194        }
195    }
196}
197
198impl PartialOrd for AgentName {
199    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
200        Some(self.cmp(other))
201    }
202}
203
204impl Ord for AgentName {
205    fn cmp(&self, other: &Self) -> Ordering {
206        self.display_name().cmp(other.display_name())
207    }
208}
209
210/// A single finding reported by an agent during analysis.
211///
212/// Findings have a severity, title, and detail explanation.
213#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
214pub struct Finding {
215    /// Severity level of this finding.
216    pub severity: Severity,
217    /// Short title describing the finding.
218    pub title: String,
219    /// Detailed explanation of the finding.
220    pub detail: String,
221}
222
223impl Finding {
224    /// Returns the title with Unicode format characters (category Cf) removed.
225    ///
226    /// Strips zero-width spaces, byte order marks, and other invisible
227    /// formatting characters that could interfere with deduplication.
228    /// Uses [`ZERO_WIDTH_PATTERN`], compiled once via `LazyLock`.
229    pub fn stripped_title(&self) -> String {
230        ZERO_WIDTH_PATTERN.replace_all(&self.title, "").into_owned()
231    }
232}
233
234/// Deserialized output from a single LLM agent.
235///
236/// Contains the agent's verdict, confidence, reasoning, and findings.
237/// Unknown JSON fields are silently ignored during deserialization.
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
239pub struct AgentOutput {
240    /// Which agent produced this output.
241    pub agent: AgentName,
242    /// The agent's judgment verdict.
243    pub verdict: Verdict,
244    /// Confidence level between 0.0 and 1.0 (validated externally).
245    pub confidence: f64,
246    /// Brief summary of the agent's analysis.
247    pub summary: String,
248    /// Detailed reasoning behind the verdict.
249    pub reasoning: String,
250    /// Specific findings discovered during analysis.
251    pub findings: Vec<Finding>,
252    /// The agent's actionable recommendation.
253    pub recommendation: String,
254}
255
256impl AgentOutput {
257    /// Returns `true` if the verdict is `Approve` or `Conditional`.
258    pub fn is_approving(&self) -> bool {
259        matches!(self.verdict, Verdict::Approve | Verdict::Conditional)
260    }
261
262    /// Returns `true` if this agent's effective verdict differs from the majority.
263    pub fn is_dissenting(&self, majority: Verdict) -> bool {
264        self.effective_verdict() != majority
265    }
266
267    /// Returns the effective binary verdict (delegates to [`Verdict::effective`]).
268    pub fn effective_verdict(&self) -> Verdict {
269        self.verdict.effective()
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use std::collections::BTreeMap;
277
278    // -- Verdict tests --
279
280    #[test]
281    fn test_verdict_approve_weight_is_positive_one() {
282        assert_eq!(Verdict::Approve.weight(), 1.0);
283    }
284
285    #[test]
286    fn test_verdict_reject_weight_is_negative_one() {
287        assert_eq!(Verdict::Reject.weight(), -1.0);
288    }
289
290    #[test]
291    fn test_verdict_conditional_weight_is_half() {
292        assert_eq!(Verdict::Conditional.weight(), 0.5);
293    }
294
295    #[test]
296    fn test_verdict_conditional_effective_maps_to_approve() {
297        assert_eq!(Verdict::Conditional.effective(), Verdict::Approve);
298    }
299
300    #[test]
301    fn test_verdict_approve_effective_is_identity() {
302        assert_eq!(Verdict::Approve.effective(), Verdict::Approve);
303    }
304
305    #[test]
306    fn test_verdict_reject_effective_is_identity() {
307        assert_eq!(Verdict::Reject.effective(), Verdict::Reject);
308    }
309
310    #[test]
311    fn test_verdict_display_outputs_uppercase() {
312        assert_eq!(format!("{}", Verdict::Approve), "APPROVE");
313        assert_eq!(format!("{}", Verdict::Reject), "REJECT");
314        assert_eq!(format!("{}", Verdict::Conditional), "CONDITIONAL");
315    }
316
317    #[test]
318    fn test_verdict_serializes_as_lowercase() {
319        assert_eq!(
320            serde_json::to_string(&Verdict::Approve).unwrap(),
321            "\"approve\""
322        );
323        assert_eq!(
324            serde_json::to_string(&Verdict::Reject).unwrap(),
325            "\"reject\""
326        );
327        assert_eq!(
328            serde_json::to_string(&Verdict::Conditional).unwrap(),
329            "\"conditional\""
330        );
331    }
332
333    #[test]
334    fn test_verdict_deserializes_from_lowercase() {
335        assert_eq!(
336            serde_json::from_str::<Verdict>("\"approve\"").unwrap(),
337            Verdict::Approve
338        );
339        assert_eq!(
340            serde_json::from_str::<Verdict>("\"reject\"").unwrap(),
341            Verdict::Reject
342        );
343        assert_eq!(
344            serde_json::from_str::<Verdict>("\"conditional\"").unwrap(),
345            Verdict::Conditional
346        );
347    }
348
349    #[test]
350    fn test_verdict_deserialization_rejects_invalid() {
351        assert!(serde_json::from_str::<Verdict>("\"invalid\"").is_err());
352    }
353
354    // -- Severity tests --
355
356    #[test]
357    fn test_severity_ordering_critical_greater_than_warning_greater_than_info() {
358        assert!(Severity::Critical > Severity::Warning);
359        assert!(Severity::Warning > Severity::Info);
360        assert!(Severity::Critical > Severity::Info);
361    }
362
363    #[test]
364    fn test_severity_icon_returns_correct_strings() {
365        assert_eq!(Severity::Critical.icon(), "[!!!]");
366        assert_eq!(Severity::Warning.icon(), "[!!]");
367        assert_eq!(Severity::Info.icon(), "[i]");
368    }
369
370    #[test]
371    fn test_severity_display_outputs_uppercase() {
372        assert_eq!(format!("{}", Severity::Critical), "CRITICAL");
373        assert_eq!(format!("{}", Severity::Warning), "WARNING");
374        assert_eq!(format!("{}", Severity::Info), "INFO");
375    }
376
377    #[test]
378    fn test_severity_serializes_as_lowercase() {
379        assert_eq!(
380            serde_json::to_string(&Severity::Critical).unwrap(),
381            "\"critical\""
382        );
383        assert_eq!(
384            serde_json::to_string(&Severity::Warning).unwrap(),
385            "\"warning\""
386        );
387        assert_eq!(serde_json::to_string(&Severity::Info).unwrap(), "\"info\"");
388    }
389
390    #[test]
391    fn test_severity_deserializes_from_lowercase() {
392        assert_eq!(
393            serde_json::from_str::<Severity>("\"critical\"").unwrap(),
394            Severity::Critical
395        );
396    }
397
398    #[test]
399    fn test_severity_deserialization_rejects_invalid() {
400        assert!(serde_json::from_str::<Severity>("\"invalid\"").is_err());
401    }
402
403    // -- Mode tests --
404
405    #[test]
406    fn test_mode_display_outputs_hyphenated_lowercase() {
407        assert_eq!(format!("{}", Mode::CodeReview), "code-review");
408        assert_eq!(format!("{}", Mode::Design), "design");
409        assert_eq!(format!("{}", Mode::Analysis), "analysis");
410    }
411
412    #[test]
413    fn test_mode_serializes_as_lowercase_with_hyphens() {
414        assert_eq!(
415            serde_json::to_string(&Mode::CodeReview).unwrap(),
416            "\"code-review\""
417        );
418        assert_eq!(serde_json::to_string(&Mode::Design).unwrap(), "\"design\"");
419        assert_eq!(
420            serde_json::to_string(&Mode::Analysis).unwrap(),
421            "\"analysis\""
422        );
423    }
424
425    #[test]
426    fn test_mode_deserializes_from_lowercase_with_hyphens() {
427        assert_eq!(
428            serde_json::from_str::<Mode>("\"code-review\"").unwrap(),
429            Mode::CodeReview
430        );
431        assert_eq!(
432            serde_json::from_str::<Mode>("\"design\"").unwrap(),
433            Mode::Design
434        );
435        assert_eq!(
436            serde_json::from_str::<Mode>("\"analysis\"").unwrap(),
437            Mode::Analysis
438        );
439    }
440
441    #[test]
442    fn test_mode_deserialization_rejects_invalid() {
443        assert!(serde_json::from_str::<Mode>("\"invalid\"").is_err());
444    }
445
446    // -- AgentName tests --
447
448    #[test]
449    fn test_agent_name_title_returns_role() {
450        assert_eq!(AgentName::Melchior.title(), "Scientist");
451        assert_eq!(AgentName::Balthasar.title(), "Pragmatist");
452        assert_eq!(AgentName::Caspar.title(), "Critic");
453    }
454
455    #[test]
456    fn test_agent_name_display_name_returns_name() {
457        assert_eq!(AgentName::Melchior.display_name(), "Melchior");
458        assert_eq!(AgentName::Balthasar.display_name(), "Balthasar");
459        assert_eq!(AgentName::Caspar.display_name(), "Caspar");
460    }
461
462    #[test]
463    fn test_agent_name_ord_is_alphabetical() {
464        assert!(AgentName::Balthasar < AgentName::Caspar);
465        assert!(AgentName::Caspar < AgentName::Melchior);
466        assert!(AgentName::Balthasar < AgentName::Melchior);
467    }
468
469    #[test]
470    fn test_agent_name_serializes_as_lowercase() {
471        assert_eq!(
472            serde_json::to_string(&AgentName::Melchior).unwrap(),
473            "\"melchior\""
474        );
475        assert_eq!(
476            serde_json::to_string(&AgentName::Balthasar).unwrap(),
477            "\"balthasar\""
478        );
479        assert_eq!(
480            serde_json::to_string(&AgentName::Caspar).unwrap(),
481            "\"caspar\""
482        );
483    }
484
485    #[test]
486    fn test_agent_name_deserializes_from_lowercase() {
487        assert_eq!(
488            serde_json::from_str::<AgentName>("\"melchior\"").unwrap(),
489            AgentName::Melchior
490        );
491    }
492
493    #[test]
494    fn test_agent_name_usable_as_btreemap_key() {
495        let mut map = BTreeMap::new();
496        map.insert(AgentName::Melchior, "scientist");
497        map.insert(AgentName::Balthasar, "pragmatist");
498        map.insert(AgentName::Caspar, "critic");
499        assert_eq!(map.get(&AgentName::Melchior), Some(&"scientist"));
500        assert_eq!(map.get(&AgentName::Balthasar), Some(&"pragmatist"));
501        assert_eq!(map.get(&AgentName::Caspar), Some(&"critic"));
502    }
503
504    // -- Finding tests --
505
506    #[test]
507    fn test_finding_stripped_title_removes_zero_width_characters() {
508        let finding = Finding {
509            severity: Severity::Warning,
510            title: "Hello\u{200B}World\u{FEFF}Test\u{200C}End".to_string(),
511            detail: "detail".to_string(),
512        };
513        assert_eq!(finding.stripped_title(), "HelloWorldTestEnd");
514    }
515
516    #[test]
517    fn test_finding_stripped_title_preserves_normal_text() {
518        let finding = Finding {
519            severity: Severity::Info,
520            title: "Normal title".to_string(),
521            detail: "detail".to_string(),
522        };
523        assert_eq!(finding.stripped_title(), "Normal title");
524    }
525
526    #[test]
527    fn test_finding_serde_roundtrip() {
528        let finding = Finding {
529            severity: Severity::Critical,
530            title: "Security issue".to_string(),
531            detail: "SQL injection vulnerability".to_string(),
532        };
533        let json = serde_json::to_string(&finding).unwrap();
534        let deserialized: Finding = serde_json::from_str(&json).unwrap();
535        assert_eq!(finding, deserialized);
536    }
537
538    // -- AgentOutput tests --
539
540    fn make_output(verdict: Verdict) -> AgentOutput {
541        AgentOutput {
542            agent: AgentName::Melchior,
543            verdict,
544            confidence: 0.9,
545            summary: "summary".to_string(),
546            reasoning: "reasoning".to_string(),
547            findings: vec![],
548            recommendation: "recommendation".to_string(),
549        }
550    }
551
552    #[test]
553    fn test_agent_output_is_approving_true_for_approve() {
554        assert!(make_output(Verdict::Approve).is_approving());
555    }
556
557    #[test]
558    fn test_agent_output_is_approving_true_for_conditional() {
559        assert!(make_output(Verdict::Conditional).is_approving());
560    }
561
562    #[test]
563    fn test_agent_output_is_approving_false_for_reject() {
564        assert!(!make_output(Verdict::Reject).is_approving());
565    }
566
567    #[test]
568    fn test_agent_output_is_dissenting_when_verdict_differs_from_majority() {
569        let output = make_output(Verdict::Reject);
570        assert!(output.is_dissenting(Verdict::Approve));
571    }
572
573    #[test]
574    fn test_agent_output_is_not_dissenting_when_verdict_matches_majority() {
575        let output = make_output(Verdict::Approve);
576        assert!(!output.is_dissenting(Verdict::Approve));
577    }
578
579    #[test]
580    fn test_agent_output_conditional_is_not_dissenting_from_approve_majority() {
581        let output = make_output(Verdict::Conditional);
582        assert!(!output.is_dissenting(Verdict::Approve));
583    }
584
585    #[test]
586    fn test_agent_output_effective_verdict_maps_conditional_to_approve() {
587        let output = make_output(Verdict::Conditional);
588        assert_eq!(output.effective_verdict(), Verdict::Approve);
589    }
590
591    #[test]
592    fn test_agent_output_serde_roundtrip() {
593        let output = AgentOutput {
594            agent: AgentName::Balthasar,
595            verdict: Verdict::Conditional,
596            confidence: 0.75,
597            summary: "looks okay".to_string(),
598            reasoning: "mostly good".to_string(),
599            findings: vec![Finding {
600                severity: Severity::Warning,
601                title: "Minor issue".to_string(),
602                detail: "Could improve naming".to_string(),
603            }],
604            recommendation: "approve with changes".to_string(),
605        };
606        let json = serde_json::to_string(&output).unwrap();
607        let deserialized: AgentOutput = serde_json::from_str(&json).unwrap();
608        assert_eq!(output, deserialized);
609    }
610
611    #[test]
612    fn test_agent_output_empty_findings_valid() {
613        let output = make_output(Verdict::Approve);
614        assert!(output.findings.is_empty());
615        let json = serde_json::to_string(&output).unwrap();
616        let deserialized: AgentOutput = serde_json::from_str(&json).unwrap();
617        assert_eq!(output, deserialized);
618    }
619
620    #[test]
621    fn test_agent_output_ignores_unknown_fields() {
622        let json = r#"{
623            "agent": "caspar",
624            "verdict": "reject",
625            "confidence": 0.3,
626            "summary": "bad",
627            "reasoning": "terrible",
628            "findings": [],
629            "recommendation": "reject",
630            "unknown_field": "should be ignored"
631        }"#;
632        let output: AgentOutput = serde_json::from_str(json).unwrap();
633        assert_eq!(output.agent, AgentName::Caspar);
634        assert_eq!(output.verdict, Verdict::Reject);
635    }
636}