Skip to main content

vtcode_core/core/agent/
hypothesis.rs

1//! Hypothesis-loop mismatch classification for the agent runloop.
2//!
3//! desirable loop: hypothesis → observation → mismatch → inspect evidence →
4//! revise hypothesis. This module is the pure, testable core of that loop:
5//! it names the mismatch, but never blocks, retries, or performs I/O. The
6//! binary's deterministic failure diagnosis calls into it to frame the
7//! model-facing `next_action`; the evaluator scores the behavior.
8//!
9//! Dimension key for [`MismatchEvidence`] (index → field → meaning):
10//!
11//! | field                 | meaning when set                                          |
12//! | --------------------- | --------------------------------------------------------- |
13//! | `exit_code`           | tool result carried this process exit status               |
14//! | `empty_search_no_match` | grep-style exit 1 with no output inside the queried scope |
15//! | `patch_mismatch`      | patch context/deletion lines do not match the current file |
16//! | `verifier_failed`     | a standalone verifier reported a non-zero status           |
17//! | `repeated_evidence`   | query returned only previously seen lines/ranges           |
18
19/// Named mismatch between the agent's hypothesis and its observation.
20///
21/// Priority order in [`classify_mismatch`] is declaration order: patch
22/// mismatch first, repeated evidence last.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum MismatchKind {
25    /// Process reported a non-zero exit status.
26    NonZeroExit,
27    /// grep-style exit 1 with no output: evidence of absence, not a defect.
28    EmptySearch,
29    /// Patch context or deletion lines do not match the current file.
30    PatchMismatch,
31    /// A standalone verifier reported failure.
32    VerifierFailed,
33    /// Query returned only previously seen evidence.
34    RepeatedEvidence,
35}
36
37/// Structured evidence for one mismatch classification.
38///
39/// Prefer this named struct over a bare tuple so the shape is explicit in
40/// the type system. All fields default to unset via [`Default`].
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub struct MismatchEvidence {
43    /// Process exit status carried by the tool result, when present.
44    pub exit_code: Option<i64>,
45    /// grep-style exit 1 with fully visible empty output.
46    pub empty_search_no_match: bool,
47    /// Patch context or deletion lines mismatch the current file.
48    pub patch_mismatch: bool,
49    /// A standalone verifier reported a non-zero status.
50    pub verifier_failed: bool,
51    /// Query returned only previously seen lines or ranges.
52    pub repeated_evidence: bool,
53}
54
55/// Classify mismatch evidence into its highest-priority kind.
56///
57/// Returns `None` when nothing is set (including `exit_code` of `Some(0)`),
58/// so callers keep truly unknown failures short.
59#[must_use]
60pub fn classify_mismatch(evidence: &MismatchEvidence) -> Option<MismatchKind> {
61    if evidence.patch_mismatch {
62        return Some(MismatchKind::PatchMismatch);
63    }
64    if evidence.verifier_failed {
65        return Some(MismatchKind::VerifierFailed);
66    }
67    if evidence.empty_search_no_match {
68        return Some(MismatchKind::EmptySearch);
69    }
70    if evidence.exit_code.is_some_and(|code| code != 0) {
71        return Some(MismatchKind::NonZeroExit);
72    }
73    if evidence.repeated_evidence {
74        return Some(MismatchKind::RepeatedEvidence);
75    }
76    None
77}
78
79/// Model-facing revision guidance for a classified mismatch.
80///
81/// `None` (unknown context) yields the generic framing. Every variant names
82/// inspection before retry; only [`MismatchKind::EmptySearch`] redirects to
83/// a new scope instead of a re-read.
84#[must_use]
85pub fn revision_guidance(kind: Option<MismatchKind>) -> &'static str {
86    match kind {
87        Some(MismatchKind::EmptySearch) => {
88            "Treat this as evidence of absence in the queried scope; revise the hypothesis to a new scope or question."
89        }
90        Some(MismatchKind::PatchMismatch) => {
91            "Re-read the exact current lines, revise the patch hypothesis, then retry once with exact context."
92        }
93        Some(MismatchKind::VerifierFailed) => {
94            "Inspect the verifier output, revise the fix hypothesis, then re-verify standalone."
95        }
96        Some(MismatchKind::RepeatedEvidence) => {
97            "Stop repeating the same query; revise the hypothesis and change scope or approach."
98        }
99        Some(MismatchKind::NonZeroExit) => {
100            "Inspect the bounded evidence, revise the hypothesis, then retry with corrected arguments."
101        }
102        None => "Revise the hypothesis from the bounded evidence before retrying.",
103    }
104}
105
106/// Append revision guidance to a deterministic `next_action`.
107///
108/// An empty base yields the guidance alone so callers never emit a leading
109/// separator without content.
110#[must_use]
111pub fn append_revision_guidance(base: &str, kind: Option<MismatchKind>) -> String {
112    let base = base.trim_end();
113    if base.is_empty() {
114        return revision_guidance(kind).to_string();
115    }
116    format!("{base} {}", revision_guidance(kind))
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn exit_code_alone_distinguishes_zero_from_nonzero() {
125        let nonzero = MismatchEvidence { exit_code: Some(1), ..Default::default() };
126        assert_eq!(classify_mismatch(&nonzero), Some(MismatchKind::NonZeroExit));
127        let zero = MismatchEvidence { exit_code: Some(0), ..Default::default() };
128        assert_eq!(classify_mismatch(&zero), None);
129        assert_eq!(classify_mismatch(&MismatchEvidence::default()), None);
130    }
131
132    #[test]
133    fn empty_search_outranks_exit_code() {
134        let both = MismatchEvidence {
135            exit_code: Some(1),
136            empty_search_no_match: true,
137            ..Default::default()
138        };
139        assert_eq!(classify_mismatch(&both), Some(MismatchKind::EmptySearch));
140        let exit_only = MismatchEvidence { exit_code: Some(1), ..Default::default() };
141        assert_eq!(classify_mismatch(&exit_only), Some(MismatchKind::NonZeroExit));
142    }
143
144    #[test]
145    fn patch_mismatch_is_top_priority() {
146        let all = MismatchEvidence {
147            exit_code: Some(1),
148            empty_search_no_match: true,
149            patch_mismatch: true,
150            verifier_failed: true,
151            repeated_evidence: true,
152        };
153        assert_eq!(classify_mismatch(&all), Some(MismatchKind::PatchMismatch));
154        let verifier_and_exit = MismatchEvidence {
155            exit_code: Some(2),
156            verifier_failed: true,
157            repeated_evidence: true,
158            ..Default::default()
159        };
160        assert_eq!(classify_mismatch(&verifier_and_exit), Some(MismatchKind::VerifierFailed));
161    }
162
163    #[test]
164    fn repeated_evidence_is_lowest_priority() {
165        let repeated_only = MismatchEvidence { repeated_evidence: true, ..Default::default() };
166        assert_eq!(classify_mismatch(&repeated_only), Some(MismatchKind::RepeatedEvidence));
167        let repeated_and_exit = MismatchEvidence {
168            exit_code: Some(1),
169            repeated_evidence: true,
170            ..Default::default()
171        };
172        assert_eq!(classify_mismatch(&repeated_and_exit), Some(MismatchKind::NonZeroExit));
173    }
174
175    #[test]
176    fn guidance_names_revision_and_scopes_empty_search() {
177        for kind in [
178            Some(MismatchKind::NonZeroExit),
179            Some(MismatchKind::EmptySearch),
180            Some(MismatchKind::PatchMismatch),
181            Some(MismatchKind::VerifierFailed),
182            Some(MismatchKind::RepeatedEvidence),
183            None,
184        ] {
185            let guidance = revision_guidance(kind);
186            assert!(!guidance.is_empty(), "{kind:?} guidance must not be empty");
187            assert!(guidance.contains("hypothesis"), "{kind:?} guidance must name the hypothesis: {guidance}");
188            assert!(
189                guidance.to_ascii_lowercase().contains("revis"),
190                "{kind:?} guidance must name revision: {guidance}"
191            );
192        }
193        let empty = revision_guidance(Some(MismatchKind::EmptySearch));
194        assert!(empty.contains("new scope"), "empty search must redirect scope: {empty}");
195        let generic = revision_guidance(None);
196        assert_ne!(generic, revision_guidance(Some(MismatchKind::NonZeroExit)));
197        for shout in ["MUST", "NEVER", "ALWAYS", "CRITICAL", "IMPORTANT"] {
198            assert!(!generic.contains(shout), "unexpected shouting: {shout}");
199        }
200    }
201
202    #[test]
203    fn append_keeps_base_and_handles_empty_base() {
204        let appended = append_revision_guidance("Retry once.", Some(MismatchKind::NonZeroExit));
205        assert!(appended.starts_with("Retry once."));
206        assert!(appended.contains("hypothesis"));
207        assert!(appended.to_ascii_lowercase().contains("revis"));
208        let bare = append_revision_guidance("   ", None);
209        assert_eq!(bare, revision_guidance(None));
210    }
211}