Skip to main content

newt_core/
tuning.rs

1//! Community model tuning profiles.
2//!
3//! Defines the shareable TOML format for model tuning data and provides
4//! load/save helpers for `~/.newt/community-tunings.toml`.
5//!
6//! The community format is deliberately independent of the newt codebase —
7//! it can be shared as a gist, forum post, or repository without any code
8//! dependency. Import with `newt tunings import <file>`.
9//!
10//! # File format
11//!
12//! ```toml
13//! # newt community model tuning profiles · format v1
14//! [format]
15//! version = "1"
16//! generated_by = "newt/0.6.6"
17//!
18//! [[profiles]]
19//! model = "nemotron3:33b"
20//! context_window = 32768
21//! safe_context = 24576
22//! mid_loop_trim_threshold = 12
23//! max_tool_rounds = 20
24//! tune_source = "empirical"
25//! confidence = "high"
26//! data_points = 15
27//! notes = "Overflows at ~30k tokens; 24k confirmed safe for 10-round tool sessions"
28//! ```
29
30use std::path::PathBuf;
31
32use serde::{Deserialize, Serialize};
33
34// ---------------------------------------------------------------------------
35// Types
36// ---------------------------------------------------------------------------
37
38/// How the tuning parameters were derived.
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum TuneSource {
42    /// Measured from real inference sessions by the auto-tuner.
43    #[default]
44    Empirical,
45    /// Taken from the model's declared metadata via Ollama `/api/show`.
46    OllamaShow,
47    /// Set manually by the user.
48    Manual,
49    /// Contributed by the community without local validation.
50    Community,
51}
52
53/// One model's tuning profile in the community format.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct TuningProfile {
56    /// Model name as it appears in Ollama (e.g. `"nemotron3:33b"`).
57    pub model: String,
58
59    /// Declared context window from Ollama `/api/show` (tokens).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub context_window: Option<u32>,
62
63    /// Recommended `num_ctx` to send to Ollama — safe for multi-tool sessions.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub safe_context: Option<u32>,
66
67    /// Recommended `mid_loop_trim_threshold`.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub mid_loop_trim_threshold: Option<usize>,
70
71    /// Recommended `max_tool_rounds`.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub max_tool_rounds: Option<usize>,
74
75    /// Learned observed/estimated prompt-token calibration ratio (Phase 20,
76    /// `docs/design/model-self-tuning.md` §2.3): how far the chars/4
77    /// estimator under/over-counts this model's chat template. Additive
78    /// optional field — format version stays "1"; files written by older
79    /// versions parse unchanged and older versions ignore the key.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub estimate_ratio: Option<f32>,
82
83    /// How these values were derived.
84    #[serde(default)]
85    pub tune_source: TuneSource,
86
87    /// Confidence level: "none", "low", "medium", or "high".
88    #[serde(default = "default_confidence")]
89    pub confidence: String,
90
91    /// Number of inference sessions that informed this profile.
92    #[serde(default)]
93    pub data_points: u32,
94
95    /// Free-text notes for human readers.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub notes: Option<String>,
98}
99
100fn default_confidence() -> String {
101    "none".to_string()
102}
103
104/// File-level metadata for the community tunings format.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct FormatMeta {
107    /// Format version — currently always `"1"`.
108    pub version: String,
109    /// `"newt/<semver>"` string identifying the exporter.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub generated_by: Option<String>,
112    /// ISO-8601 timestamp of export.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub generated_at: Option<String>,
115}
116
117impl Default for FormatMeta {
118    fn default() -> Self {
119        Self {
120            version: "1".to_string(),
121            generated_by: None,
122            generated_at: None,
123        }
124    }
125}
126
127/// Top-level community tunings document.
128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
129pub struct CommunityTunings {
130    #[serde(default)]
131    pub format: FormatMeta,
132    /// Model tuning profiles.
133    #[serde(default)]
134    pub profiles: Vec<TuningProfile>,
135}
136
137// ---------------------------------------------------------------------------
138// Persistence
139// ---------------------------------------------------------------------------
140
141/// Path to the community tunings file: `~/.newt/community-tunings.toml`.
142pub fn community_tunings_path() -> Option<PathBuf> {
143    crate::config::Config::user_config_path().map(|p| p.with_file_name("community-tunings.toml"))
144}
145
146/// Load community tunings from `~/.newt/community-tunings.toml`.
147/// Returns an empty document on any error (file absent, parse failure, etc.).
148pub fn load_community_tunings() -> CommunityTunings {
149    let Some(path) = community_tunings_path() else {
150        return CommunityTunings::default();
151    };
152    let Ok(data) = std::fs::read_to_string(&path) else {
153        return CommunityTunings::default();
154    };
155    toml::from_str(&data).unwrap_or_default()
156}
157
158/// Save community tunings to `~/.newt/community-tunings.toml` (best-effort).
159pub fn save_community_tunings(tunings: &CommunityTunings) -> std::io::Result<()> {
160    let path = community_tunings_path()
161        .ok_or_else(|| std::io::Error::other("cannot determine ~/.newt path"))?;
162    let data = toml::to_string_pretty(tunings).map_err(|e| std::io::Error::other(e.to_string()))?;
163    std::fs::write(path, data)
164}
165
166/// Find a profile for the given model name (exact match).
167impl CommunityTunings {
168    pub fn find(&self, model: &str) -> Option<&TuningProfile> {
169        self.profiles.iter().find(|p| p.model == model)
170    }
171
172    /// Merge `other` into `self`: existing profiles are updated if the
173    /// incoming entry has higher confidence; new profiles are appended.
174    pub fn merge(&mut self, other: Self) {
175        for incoming in other.profiles {
176            if let Some(existing) = self.profiles.iter_mut().find(|p| p.model == incoming.model) {
177                if confidence_rank(&incoming.confidence) >= confidence_rank(&existing.confidence) {
178                    *existing = incoming;
179                }
180            } else {
181                self.profiles.push(incoming);
182            }
183        }
184    }
185}
186
187fn confidence_rank(c: &str) -> u8 {
188    match c {
189        "high" => 3,
190        "medium" => 2,
191        "low" => 1,
192        _ => 0,
193    }
194}
195
196// ---------------------------------------------------------------------------
197// Tests
198// ---------------------------------------------------------------------------
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn community_tunings_roundtrips_toml() {
206        let mut ct = CommunityTunings::default();
207        ct.format.version = "1".to_string();
208        ct.format.generated_by = Some("newt/0.6.6".to_string());
209        ct.profiles.push(TuningProfile {
210            model: "nemotron3:33b".to_string(),
211            context_window: Some(32768),
212            safe_context: Some(24576),
213            mid_loop_trim_threshold: Some(12),
214            max_tool_rounds: Some(20),
215            // Phase 20: the learned chars/4 calibration rides along.
216            estimate_ratio: Some(1.29),
217            tune_source: TuneSource::Empirical,
218            confidence: "high".to_string(),
219            data_points: 15,
220            notes: Some("confirmed safe".to_string()),
221        });
222        let serialized = toml::to_string_pretty(&ct).unwrap();
223        let back: CommunityTunings = toml::from_str(&serialized).unwrap();
224        assert_eq!(back.profiles.len(), 1);
225        assert_eq!(back.profiles[0].model, "nemotron3:33b");
226        assert_eq!(back.profiles[0].safe_context, Some(24576));
227        assert_eq!(back.profiles[0].confidence, "high");
228        assert_eq!(back.profiles[0].estimate_ratio, Some(1.29));
229    }
230
231    /// Phase 20: `estimate_ratio` is ADDITIVE — format version stays "1",
232    /// and a v1 file without the key parses with the field absent.
233    #[test]
234    fn estimate_ratio_is_optional_in_v1_files() {
235        let toml_text = r#"
236[format]
237version = "1"
238
239[[profiles]]
240model = "old:7b"
241safe_context = 4096
242confidence = "low"
243"#;
244        let ct: CommunityTunings = toml::from_str(toml_text).unwrap();
245        assert_eq!(ct.format.version, "1");
246        assert_eq!(ct.profiles[0].estimate_ratio, None);
247        // And serializing it back does not invent the key.
248        let out = toml::to_string_pretty(&ct).unwrap();
249        assert!(!out.contains("estimate_ratio"), "{out}");
250    }
251
252    #[test]
253    fn community_tunings_find_returns_correct_profile() {
254        let ct = CommunityTunings {
255            profiles: vec![
256                TuningProfile {
257                    model: "a:7b".to_string(),
258                    context_window: None,
259                    safe_context: None,
260                    mid_loop_trim_threshold: None,
261                    max_tool_rounds: None,
262                    estimate_ratio: None,
263                    tune_source: TuneSource::Manual,
264                    confidence: "medium".to_string(),
265                    data_points: 0,
266                    notes: None,
267                },
268                TuningProfile {
269                    model: "b:13b".to_string(),
270                    context_window: Some(8192),
271                    safe_context: Some(6553),
272                    mid_loop_trim_threshold: None,
273                    max_tool_rounds: None,
274                    estimate_ratio: None,
275                    tune_source: TuneSource::OllamaShow,
276                    confidence: "low".to_string(),
277                    data_points: 1,
278                    notes: None,
279                },
280            ],
281            ..CommunityTunings::default()
282        };
283        assert!(ct.find("a:7b").is_some());
284        assert_eq!(ct.find("b:13b").unwrap().context_window, Some(8192));
285        assert!(ct.find("c:30b").is_none());
286    }
287
288    #[test]
289    fn merge_replaces_lower_confidence_with_higher() {
290        let mut base = CommunityTunings {
291            profiles: vec![TuningProfile {
292                model: "m:7b".to_string(),
293                safe_context: Some(4096),
294                confidence: "low".to_string(),
295                context_window: None,
296                mid_loop_trim_threshold: None,
297                max_tool_rounds: None,
298                estimate_ratio: None,
299                tune_source: TuneSource::Community,
300                data_points: 1,
301                notes: None,
302            }],
303            ..CommunityTunings::default()
304        };
305        let incoming = CommunityTunings {
306            profiles: vec![TuningProfile {
307                model: "m:7b".to_string(),
308                safe_context: Some(8192),
309                confidence: "high".to_string(),
310                context_window: None,
311                mid_loop_trim_threshold: None,
312                max_tool_rounds: None,
313                estimate_ratio: None,
314                tune_source: TuneSource::Empirical,
315                data_points: 10,
316                notes: None,
317            }],
318            ..CommunityTunings::default()
319        };
320        base.merge(incoming);
321        assert_eq!(base.profiles.len(), 1);
322        assert_eq!(base.profiles[0].safe_context, Some(8192));
323        assert_eq!(base.profiles[0].data_points, 10);
324    }
325
326    #[test]
327    fn merge_does_not_replace_higher_confidence_with_lower() {
328        let mut base = CommunityTunings {
329            profiles: vec![TuningProfile {
330                model: "m:7b".to_string(),
331                safe_context: Some(8192),
332                confidence: "high".to_string(),
333                context_window: None,
334                mid_loop_trim_threshold: None,
335                max_tool_rounds: None,
336                estimate_ratio: None,
337                tune_source: TuneSource::Empirical,
338                data_points: 10,
339                notes: None,
340            }],
341            ..CommunityTunings::default()
342        };
343        let incoming = CommunityTunings {
344            profiles: vec![TuningProfile {
345                model: "m:7b".to_string(),
346                safe_context: Some(2048),
347                confidence: "low".to_string(),
348                context_window: None,
349                mid_loop_trim_threshold: None,
350                max_tool_rounds: None,
351                estimate_ratio: None,
352                tune_source: TuneSource::Community,
353                data_points: 1,
354                notes: None,
355            }],
356            ..CommunityTunings::default()
357        };
358        base.merge(incoming);
359        // The existing high-confidence entry wins.
360        assert_eq!(base.profiles[0].safe_context, Some(8192));
361        assert_eq!(base.profiles[0].data_points, 10);
362    }
363
364    #[test]
365    fn confidence_rank_orders_correctly() {
366        assert!(confidence_rank("high") > confidence_rank("medium"));
367        assert!(confidence_rank("medium") > confidence_rank("low"));
368        assert!(confidence_rank("low") > confidence_rank("none"));
369        assert_eq!(confidence_rank("none"), 0);
370    }
371
372    #[test]
373    fn empty_document_parses_without_error() {
374        let ct: CommunityTunings = toml::from_str("").unwrap();
375        assert!(ct.profiles.is_empty());
376    }
377}