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