Skip to main content

newt_core/
classifiers.rs

1//! Configurable lightweight classifiers.
2//!
3//! Classifier drop-ins live under `~/.newt/classifiers/` (or
4//! `$NEWT_CONFIG_DIR/classifiers/`). The first shipped classifier is the
5//! `NudgeClassifier`, used by the agentic loop to distinguish final answers
6//! from no-tool-call stall shapes such as "I am about to continue" narrations
7//! and stale-plan findings summaries.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use crate::config::Config;
15
16const NUDGE_CLASSIFIER_FILE: &str = "nudge.toml";
17const BUNDLED_NUDGE_CLASSIFIER: &str = include_str!("classifiers/nudge.toml");
18
19/// A nudge class the agentic loop knows how to act on.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum NudgeClass {
22    /// The assistant is announcing an action it has not actually performed.
23    PendingAction,
24    /// The assistant discovered prerequisite work that should revise the plan.
25    PlanUpdate,
26    /// The assistant appears to be giving a real final answer.
27    FinalAnswer,
28    /// No configured class was close enough to trust.
29    Unknown,
30}
31
32/// Result of a nudge classifier pass.
33#[derive(Debug, Clone, PartialEq)]
34pub struct NudgeClassification {
35    pub class: NudgeClass,
36    pub score: f32,
37}
38
39impl NudgeClassification {
40    pub fn is_pending_action(&self) -> bool {
41        matches!(
42            self.class,
43            NudgeClass::PendingAction | NudgeClass::PlanUpdate
44        )
45    }
46
47    pub fn is_plan_update(&self) -> bool {
48        self.class == NudgeClass::PlanUpdate
49    }
50}
51
52/// Config file shape for `~/.newt/classifiers/nudge.toml`.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct NudgeClassifierConfig {
55    /// Schema version. Currently informational; future versions can migrate.
56    #[serde(default = "default_classifier_version")]
57    pub version: u32,
58    /// Minimum similarity score required before a class is trusted.
59    #[serde(default = "default_min_score")]
60    pub min_score: f32,
61    /// Minimum gap between the winning class and runner-up.
62    #[serde(default = "default_min_margin")]
63    pub min_margin: f32,
64    /// Class definitions keyed by canonical class names:
65    /// `pending_action`, `plan_update`, `final_answer`.
66    #[serde(default)]
67    pub classes: BTreeMap<String, NudgeClassConfig>,
68}
69
70/// One nudge class: many input matchers share one output nudge.
71#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
72pub struct NudgeClassConfig {
73    /// Prototype phrases that classify model output into this class.
74    #[serde(default)]
75    pub matchers: Vec<String>,
76    /// Model-facing corrective direction sent when this class triggers an
77    /// auto-continue path. Empty means "do not send a class-specific nudge".
78    #[serde(default)]
79    pub nudge: String,
80}
81
82impl Default for NudgeClassifierConfig {
83    fn default() -> Self {
84        bundled_nudge_config()
85    }
86}
87
88fn default_classifier_version() -> u32 {
89    1
90}
91
92fn default_min_score() -> f32 {
93    0.28
94}
95
96fn default_min_margin() -> f32 {
97    0.03
98}
99
100fn bundled_nudge_config() -> NudgeClassifierConfig {
101    toml::from_str(BUNDLED_NUDGE_CLASSIFIER).expect("bundled nudge classifier template is valid")
102}
103
104fn bundled_nudge_classes() -> BTreeMap<String, NudgeClassConfig> {
105    bundled_nudge_config().classes
106}
107
108impl NudgeClassifierConfig {
109    /// Load only this config file. Missing files are treated as defaults; malformed
110    /// files return an error so callers that explicitly asked to load can surface it.
111    pub fn load_file(path: &Path) -> anyhow::Result<Self> {
112        match std::fs::read_to_string(path) {
113            Ok(text) => {
114                let cfg: Self = toml::from_str(&text)?;
115                Ok(cfg.with_builtin_fallbacks())
116            }
117            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
118            Err(e) => Err(e.into()),
119        }
120    }
121
122    /// Load from a classifier directory, e.g. `~/.newt/classifiers`.
123    pub fn load_from_dir(dir: &Path) -> anyhow::Result<Self> {
124        Self::load_file(&dir.join(NUDGE_CLASSIFIER_FILE))
125    }
126
127    fn with_builtin_fallbacks(mut self) -> Self {
128        let builtins = bundled_nudge_classes();
129        for (class, bundled) in builtins {
130            self.classes
131                .entry(class)
132                .and_modify(|configured| {
133                    if configured.matchers.is_empty() {
134                        configured.matchers = bundled.matchers.clone();
135                    }
136                    if configured.nudge.trim().is_empty() {
137                        configured.nudge = bundled.nudge.clone();
138                    }
139                })
140                .or_insert(bundled);
141        }
142        if self.min_score <= 0.0 {
143            self.min_score = default_min_score();
144        }
145        if self.min_margin < 0.0 {
146            self.min_margin = default_min_margin();
147        }
148        self
149    }
150}
151
152/// Configurable classifier for no-tool-call nudge decisions.
153#[derive(Debug, Clone)]
154pub struct NudgeClassifier {
155    cfg: NudgeClassifierConfig,
156}
157
158impl Default for NudgeClassifier {
159    fn default() -> Self {
160        Self::builtin()
161    }
162}
163
164impl NudgeClassifier {
165    pub fn builtin() -> Self {
166        Self {
167            cfg: NudgeClassifierConfig::default(),
168        }
169    }
170
171    pub fn from_config(cfg: NudgeClassifierConfig) -> Self {
172        Self {
173            cfg: cfg.with_builtin_fallbacks(),
174        }
175    }
176
177    pub fn load_from_dir(dir: &Path) -> anyhow::Result<Self> {
178        Ok(Self::from_config(NudgeClassifierConfig::load_from_dir(
179            dir,
180        )?))
181    }
182
183    /// Load the user nudge classifier from `~/.newt/classifiers/nudge.toml`.
184    /// Unit tests use built-ins by default so local user config cannot make the
185    /// harness tests nondeterministic; explicit load_from_dir tests still cover
186    /// the file format.
187    pub fn load_default() -> Self {
188        #[cfg(test)]
189        {
190            Self::builtin()
191        }
192        #[cfg(not(test))]
193        {
194            let Some(dir) = classifier_config_dir() else {
195                return Self::builtin();
196            };
197            match Self::load_from_dir(&dir) {
198                Ok(classifier) => classifier,
199                Err(e) => {
200                    tracing::warn!(
201                        path = %dir.join(NUDGE_CLASSIFIER_FILE).display(),
202                        error = %e,
203                        "failed to load nudge classifier config; using built-ins"
204                    );
205                    Self::builtin()
206                }
207            }
208        }
209    }
210
211    pub fn classify(&self, text: &str) -> NudgeClassification {
212        let query = tokens(text);
213        if query.is_empty() {
214            return NudgeClassification {
215                class: NudgeClass::Unknown,
216                score: 0.0,
217            };
218        }
219
220        let mut scored: Vec<(NudgeClass, f32)> = self
221            .cfg
222            .classes
223            .iter()
224            .filter_map(|(class, class_cfg)| {
225                let class = parse_nudge_class(class)?;
226                let best = class_cfg
227                    .matchers
228                    .iter()
229                    .map(|example| prototype_similarity(&query, &tokens(example)))
230                    .fold(0.0_f32, f32::max);
231                Some((class, best))
232            })
233            .collect();
234        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
235        let Some((class, score)) = scored.first().copied() else {
236            return NudgeClassification {
237                class: NudgeClass::Unknown,
238                score: 0.0,
239            };
240        };
241        let runner_up = scored.get(1).map(|(_, s)| *s).unwrap_or(0.0);
242        if score < self.cfg.min_score || (score - runner_up) < self.cfg.min_margin {
243            return NudgeClassification {
244                class: NudgeClass::Unknown,
245                score,
246            };
247        }
248        NudgeClassification { class, score }
249    }
250
251    pub fn is_pending_action(&self, text: &str) -> bool {
252        self.classify(text).is_pending_action()
253    }
254
255    pub fn direction_for(&self, class: NudgeClass) -> Option<&str> {
256        self.cfg
257            .classes
258            .get(nudge_class_key(class))
259            .map(|class_cfg| class_cfg.nudge.as_str())
260            .map(str::trim)
261            .filter(|direction| !direction.is_empty())
262    }
263}
264
265/// The classifier config root: `$NEWT_CONFIG_DIR/classifiers` or
266/// `~/.newt/classifiers`.
267pub fn classifier_config_dir() -> Option<PathBuf> {
268    Config::user_config_dir().map(|dir| dir.join("classifiers"))
269}
270
271fn parse_nudge_class(s: &str) -> Option<NudgeClass> {
272    match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
273        "pending_action" | "continue" | "continuation" => Some(NudgeClass::PendingAction),
274        "plan_update" | "update_plan" | "stale_plan" | "replan" => Some(NudgeClass::PlanUpdate),
275        "final_answer" | "final" | "done" => Some(NudgeClass::FinalAnswer),
276        _ => None,
277    }
278}
279
280fn nudge_class_key(class: NudgeClass) -> &'static str {
281    match class {
282        NudgeClass::PendingAction => "pending_action",
283        NudgeClass::PlanUpdate => "plan_update",
284        NudgeClass::FinalAnswer => "final_answer",
285        NudgeClass::Unknown => "unknown",
286    }
287}
288
289fn tokens(s: &str) -> BTreeSet<String> {
290    s.split(|c: char| !c.is_alphanumeric())
291        .filter_map(|raw| {
292            let t = raw.trim().to_ascii_lowercase();
293            (t.len() >= 3).then_some(t)
294        })
295        .collect()
296}
297
298fn jaccard(a: &BTreeSet<String>, b: &BTreeSet<String>) -> f32 {
299    if a.is_empty() || b.is_empty() {
300        return 0.0;
301    }
302    let intersection = a.intersection(b).count() as f32;
303    let union = a.union(b).count() as f32;
304    if union == 0.0 {
305        0.0
306    } else {
307        intersection / union
308    }
309}
310
311fn prototype_similarity(query: &BTreeSet<String>, prototype: &BTreeSet<String>) -> f32 {
312    if query.is_empty() || prototype.is_empty() {
313        return 0.0;
314    }
315    let overlap = query.intersection(prototype).count() as f32;
316    let prototype_recall = overlap / prototype.len() as f32;
317    jaccard(query, prototype).max(prototype_recall)
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn nudge_classifier_builtin_matches_known_phrases() {
326        let classifier = NudgeClassifier::builtin();
327        assert!(classifier.is_pending_action(
328            "Plan is current — no update needed. Continuing with step 2: inserting the progressive dispatch into lib.rs."
329        ));
330        assert!(classifier.is_pending_action(
331            "\
332Summary
333
334Progress so far: Step 2 is in progress.
335
336Current blocker: The for loop needs to iterate over the slice directly.
337The fix is to use for entry in page.entries.iter().
338
339Next steps needed:
3401. Fix the iteration type error in lib.rs.
3412. Add tests.
3423. Run just check."
343        ));
344        assert!(classifier.is_pending_action("Now I'll add the --home flag to the Cli struct."));
345        assert!(classifier.is_pending_action("Let me keep editing now."));
346        assert!(classifier.is_pending_action(
347            "I found the issue - there's an extra closing brace } on line 809 of help_sections.rs that's causing a syntax error. I need to remove this stray brace."
348        ));
349        assert!(classifier.is_pending_action(
350            "I have two issues: duplicate topic_has_rollups and a stray brace. Let me fix both — read around 490 to see what needs removing, then verify with a build check."
351        ));
352        assert!(!classifier.is_pending_action("The capital of France is Paris."));
353        assert!(!classifier.is_pending_action("Done. Let me know if you want any further changes."));
354        assert!(!classifier.is_pending_action(
355            "The duplicate helper definitions and stray brace were removed, and the build check passed."
356        ));
357        assert!(
358            classifier
359                .direction_for(NudgeClass::PendingAction)
360                .is_some_and(|d| d.contains("emit the tool call now")),
361            "bundled classifier should carry output direction text"
362        );
363    }
364
365    #[test]
366    fn nudge_classifier_separates_plan_update_summaries_from_final_summaries() {
367        let classifier = NudgeClassifier::builtin();
368        let findings = "\
369Summary of Findings
370
371Across the tool calls, I observed two issues in newt-tui/src/help_sections.rs:
3721. Duplicate function
3732. Stray closing brace
374
375Current Status
376
377The build is broken due to these syntax errors. The plan was at step 2, but we need to fix the immediate compilation issues first.
378
379Next Steps Required
380
381To continue, I would need to remove the duplicate function using edit_file, remove the stray brace using edit_file, verify cargo check, then proceed with step 2 of the plan.
382
383However, I've reached the tool-call limit and cannot make these edits now.";
384
385        let classified = classifier.classify(findings);
386        assert_eq!(classified.class, NudgeClass::PlanUpdate);
387        assert!(classified.is_pending_action());
388        assert!(classified.is_plan_update());
389
390        let resume_handoff = "\
391Summary
392
393I reached the tool-call limit. Current state of newt-tui/src/help_sections.rs:
394duplicate topic_has_rollups and rollup_page_for_topic definitions need to be removed.
395
396Recommended next action if session resumes:
3971. Fix the duplicate functions.
3982. Clean up the broken test block.
3993. Read lib.rs and wire the progressive dispatch.
400
401The build is currently broken due to the duplicate definitions — that's the blocker for any further progress.";
402        assert_eq!(
403            classifier.classify(resume_handoff).class,
404            NudgeClass::PlanUpdate
405        );
406
407        let final_summary =
408            classifier.classify("Here is a summary of what I found across the tool calls.");
409        assert_eq!(final_summary.class, NudgeClass::FinalAnswer);
410    }
411
412    #[test]
413    fn nudge_classifier_loads_dropin_from_classifiers_dir() {
414        let dir = tempfile::tempdir().unwrap();
415        let classifiers = dir.path().join("classifiers");
416        std::fs::create_dir_all(&classifiers).unwrap();
417        std::fs::write(
418            classifiers.join(NUDGE_CLASSIFIER_FILE),
419            r#"
420version = 1
421min_score = 0.20
422min_margin = 0.01
423
424[classes.pending_action]
425matchers = ["Proceeding with the patch by editing the target file."]
426nudge = "Call the edit tool now."
427
428[classes.final_answer]
429matchers = ["No changes are needed."]
430"#,
431        )
432        .unwrap();
433
434        let classifier = NudgeClassifier::load_from_dir(&classifiers).unwrap();
435        assert!(
436            classifier.is_pending_action("Proceeding with the patch by editing the target file.")
437        );
438        assert!(!classifier.is_pending_action("No changes are needed."));
439        assert_eq!(
440            classifier.direction_for(NudgeClass::PendingAction),
441            Some("Call the edit tool now.")
442        );
443        assert!(
444            classifier
445                .direction_for(NudgeClass::PlanUpdate)
446                .is_some_and(|d| d.contains("Update the plan first")),
447            "partial user configs still inherit bundled nudges"
448        );
449    }
450
451    #[test]
452    fn classifier_dir_is_under_user_config_dir() {
453        let dir = classifier_config_dir().unwrap_or_else(|| PathBuf::from(".newt/classifiers"));
454        assert!(dir.ends_with("classifiers"));
455    }
456}