Skip to main content

sqz_engine/
preset.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4use crate::error::{Result, SqzError};
5
6/// Parses, validates, and serializes TOML preset files.
7///
8/// Presets control every aspect of the compression pipeline — which stages
9/// run, how aggressive they are, token budget limits, model family, etc.
10///
11/// ```rust
12/// use sqz_engine::preset::PresetParser;
13///
14/// let toml = r#"
15/// [preset]
16/// name = "my-preset"
17/// version = "1.0"
18///
19/// [compression]
20/// stages = ["condense", "strip_nulls"]
21///
22/// [tool_selection]
23/// max_tools = 5
24/// similarity_threshold = 0.7
25///
26/// [budget]
27/// warning_threshold = 0.70
28/// ceiling_threshold = 0.85
29/// default_window_size = 200000
30///
31/// [terse_mode]
32/// enabled = false
33/// level = "moderate"
34///
35/// [model]
36/// family = "anthropic"
37/// primary = "claude-sonnet-4-20250514"
38/// complexity_threshold = 0.4
39/// "#;
40///
41/// let preset = PresetParser::parse(toml).unwrap();
42/// assert_eq!(preset.preset.name, "my-preset");
43/// ```
44pub struct PresetParser;
45
46impl PresetParser {
47    /// Parse a TOML string into a validated `Preset`.
48    pub fn parse(toml_str: &str) -> Result<Preset> {
49        let preset: Preset = toml::from_str(toml_str)?;
50        Self::validate(&preset)?;
51        Ok(preset)
52    }
53
54    /// Serialize a `Preset` back to a pretty-printed TOML string.
55    pub fn to_toml(preset: &Preset) -> Result<String> {
56        Ok(toml::to_string_pretty(preset)?)
57    }
58
59    /// Validate all fields of a `Preset`, returning descriptive errors.
60    pub fn validate(preset: &Preset) -> Result<()> {
61        if preset.preset.name.is_empty() {
62            return Err(SqzError::PresetValidation {
63                field: "preset.name".to_string(),
64                message: "must not be empty".to_string(),
65            });
66        }
67
68        if preset.preset.version.is_empty() {
69            return Err(SqzError::PresetValidation {
70                field: "preset.version".to_string(),
71                message: "must not be empty".to_string(),
72            });
73        }
74
75        let wt = preset.budget.warning_threshold;
76        if !(wt > 0.0 && wt < 1.0) {
77            return Err(SqzError::PresetValidation {
78                field: "budget.warning_threshold".to_string(),
79                message: "must be between 0.0 and 1.0".to_string(),
80            });
81        }
82
83        let ct = preset.budget.ceiling_threshold;
84        if !(ct > 0.0 && ct < 1.0) || ct <= wt {
85            return Err(SqzError::PresetValidation {
86                field: "budget.ceiling_threshold".to_string(),
87                message: "must be between 0.0 and 1.0 and greater than warning_threshold"
88                    .to_string(),
89            });
90        }
91
92        let max_tools = preset.tool_selection.max_tools;
93        if !(1..=50).contains(&max_tools) {
94            return Err(SqzError::PresetValidation {
95                field: "tool_selection.max_tools".to_string(),
96                message: "must be between 1 and 50".to_string(),
97            });
98        }
99
100        let st = preset.tool_selection.similarity_threshold;
101        if !(st > 0.0 && st < 1.0) {
102            return Err(SqzError::PresetValidation {
103                field: "tool_selection.similarity_threshold".to_string(),
104                message: "must be between 0.0 and 1.0".to_string(),
105            });
106        }
107
108        let cxt = preset.model.complexity_threshold;
109        if !(cxt > 0.0 && cxt < 1.0) {
110            return Err(SqzError::PresetValidation {
111                field: "model.complexity_threshold".to_string(),
112                message: "must be between 0.0 and 1.0".to_string(),
113            });
114        }
115
116        Ok(())
117    }
118}
119
120/// A complete compression preset. Controls the pipeline stages, budget
121/// thresholds, model routing, terse mode, and tool selection.
122///
123/// Use [`Preset::default()`] for sensible defaults, or load from TOML
124/// with [`PresetParser::parse`].
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Preset {
127    pub preset: PresetHeader,
128    pub compression: CompressionConfig,
129    pub tool_selection: ToolSelectionConfig,
130    pub budget: BudgetConfig,
131    pub terse_mode: TerseModeConfig,
132    pub model: ModelConfig,
133}
134
135/// Identity block at the top of every `.toml` preset file.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct PresetHeader {
138    /// Short human-readable name, e.g. `"code-review"`.
139    pub name: String,
140    /// Semver string, e.g. `"1.0"`.
141    pub version: String,
142    /// Optional one-line description shown in `sqz preset list`.
143    #[serde(default)]
144    pub description: String,
145}
146
147// Keep PresetMeta as an alias so existing code compiles
148pub type PresetMeta = PresetHeader;
149
150// --- Compression ---
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct CompressionConfig {
154    #[serde(default)]
155    pub stages: Vec<String>,
156    pub keep_fields: Option<KeepFieldsConfig>,
157    pub strip_fields: Option<StripFieldsConfig>,
158    pub condense: Option<CondenseConfig>,
159    pub git_diff_fold: Option<GitDiffFoldConfig>,
160    pub strip_nulls: Option<StripNullsConfig>,
161    pub flatten: Option<FlattenConfig>,
162    pub truncate_strings: Option<TruncateStringsConfig>,
163    pub collapse_arrays: Option<CollapseArraysConfig>,
164    pub custom_transforms: Option<CustomTransformsConfig>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct GitDiffFoldConfig {
169    pub enabled: bool,
170    #[serde(default = "default_max_context_lines")]
171    pub max_context_lines: u32,
172}
173
174fn default_max_context_lines() -> u32 {
175    2
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct KeepFieldsConfig {
180    pub enabled: bool,
181    #[serde(default)]
182    pub fields: Vec<String>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct StripFieldsConfig {
187    pub enabled: bool,
188    #[serde(default)]
189    pub fields: Vec<String>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct CondenseConfig {
194    pub enabled: bool,
195    #[serde(default = "default_max_repeated_lines")]
196    pub max_repeated_lines: u32,
197}
198
199fn default_max_repeated_lines() -> u32 {
200    3
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct StripNullsConfig {
205    pub enabled: bool,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct FlattenConfig {
210    pub enabled: bool,
211    #[serde(default = "default_max_depth")]
212    pub max_depth: u32,
213}
214
215fn default_max_depth() -> u32 {
216    3
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct TruncateStringsConfig {
221    pub enabled: bool,
222    #[serde(default = "default_max_length")]
223    pub max_length: u32,
224}
225
226fn default_max_length() -> u32 {
227    500
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct CollapseArraysConfig {
232    pub enabled: bool,
233    #[serde(default = "default_max_items")]
234    pub max_items: u32,
235    /// Arrays at or under this many characters of compact JSON are never
236    /// cut. 0 cuts on `max_items` alone.
237    #[serde(default = "default_collapse_max_chars")]
238    pub max_chars: u32,
239    #[serde(default)]
240    pub summary_template: String,
241}
242
243fn default_max_items() -> u32 {
244    5
245}
246
247fn default_collapse_max_chars() -> u32 {
248    crate::stages::DEFAULT_COLLAPSE_MAX_CHARS as u32
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct CustomTransformsConfig {
253    pub enabled: bool,
254}
255
256// --- Tool selection ---
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct ToolSelectionConfig {
260    #[serde(default = "default_max_tools")]
261    pub max_tools: usize,
262    #[serde(default = "default_similarity_threshold")]
263    pub similarity_threshold: f64,
264    #[serde(default)]
265    pub default_tools: Vec<String>,
266}
267
268fn default_max_tools() -> usize {
269    5
270}
271
272fn default_similarity_threshold() -> f64 {
273    0.7
274}
275
276// --- Budget ---
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct BudgetConfig {
280    #[serde(default = "default_warning_threshold")]
281    pub warning_threshold: f64,
282    #[serde(default = "default_ceiling_threshold")]
283    pub ceiling_threshold: f64,
284    #[serde(default = "default_window_size")]
285    pub default_window_size: u32,
286    #[serde(default)]
287    pub agents: HashMap<String, f64>,
288}
289
290fn default_warning_threshold() -> f64 {
291    0.70
292}
293
294fn default_ceiling_threshold() -> f64 {
295    0.85
296}
297
298fn default_window_size() -> u32 {
299    200_000
300}
301
302// --- Terse mode ---
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct TerseModeConfig {
306    pub enabled: bool,
307    #[serde(default = "default_terse_level")]
308    pub level: TerseLevel,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "lowercase")]
313pub enum TerseLevel {
314    Minimal,
315    Moderate,
316    Verbose,
317}
318
319fn default_terse_level() -> TerseLevel {
320    TerseLevel::Moderate
321}
322
323// --- Model ---
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct ModelConfig {
327    pub family: String,
328    #[serde(default)]
329    pub primary: String,
330    #[serde(default)]
331    pub local: String,
332    #[serde(default = "default_complexity_threshold")]
333    pub complexity_threshold: f64,
334    pub pricing: Option<ModelPricingConfig>,
335}
336
337fn default_complexity_threshold() -> f64 {
338    0.4
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct ModelPricingConfig {
343    pub input_per_1k: f64,
344    pub output_per_1k: f64,
345    #[serde(default)]
346    pub cache_read_discount: f64,
347}
348
349impl Default for Preset {
350    fn default() -> Self {
351        Preset {
352            preset: PresetMeta {
353                name: "default".to_string(),
354                version: "1.0".to_string(),
355                description: "Default compression preset for general development".to_string(),
356            },
357            compression: CompressionConfig {
358                stages: vec![
359                    "keep_fields".to_string(),
360                    "strip_fields".to_string(),
361                    "condense".to_string(),
362                    "strip_nulls".to_string(),
363                    "flatten".to_string(),
364                    "truncate_strings".to_string(),
365                    "collapse_arrays".to_string(),
366                    "custom_transforms".to_string(),
367                ],
368                keep_fields: Some(KeepFieldsConfig {
369                    enabled: false,
370                    fields: vec![
371                        "id".to_string(),
372                        "name".to_string(),
373                        "type".to_string(),
374                        "status".to_string(),
375                        "error".to_string(),
376                        "message".to_string(),
377                    ],
378                }),
379                strip_fields: Some(StripFieldsConfig {
380                    enabled: true,
381                    fields: vec![
382                        "metadata.internal_id".to_string(),
383                        "debug_info".to_string(),
384                        "trace_id".to_string(),
385                    ],
386                }),
387                condense: Some(CondenseConfig {
388                    enabled: true,
389                    max_repeated_lines: 3,
390                }),
391                git_diff_fold: Some(GitDiffFoldConfig {
392                    enabled: true,
393                    max_context_lines: 2,
394                }),
395                strip_nulls: Some(StripNullsConfig { enabled: true }),
396                flatten: Some(FlattenConfig {
397                    enabled: true,
398                    max_depth: 3,
399                }),
400                truncate_strings: Some(TruncateStringsConfig {
401                    enabled: true,
402                    max_length: 500,
403                }),
404                collapse_arrays: Some(CollapseArraysConfig {
405                    enabled: true,
406                    max_items: 5,
407                    max_chars: default_collapse_max_chars(),
408                    summary_template: "... and {remaining} more items".to_string(),
409                }),
410                custom_transforms: Some(CustomTransformsConfig { enabled: true }),
411            },
412            tool_selection: ToolSelectionConfig {
413                max_tools: 5,
414                similarity_threshold: 0.7,
415                default_tools: vec![
416                    "read_file".to_string(),
417                    "write_file".to_string(),
418                    "search".to_string(),
419                ],
420            },
421            budget: BudgetConfig {
422                warning_threshold: 0.70,
423                ceiling_threshold: 0.85,
424                default_window_size: 200_000,
425                agents: {
426                    let mut m = HashMap::new();
427                    m.insert("parent".to_string(), 0.60);
428                    m.insert("child".to_string(), 0.20);
429                    m
430                },
431            },
432            terse_mode: TerseModeConfig {
433                enabled: true,
434                level: TerseLevel::Moderate,
435            },
436            model: ModelConfig {
437                family: "anthropic".to_string(),
438                primary: "claude-sonnet-4-20250514".to_string(),
439                local: "llama-3.1-8b".to_string(),
440                complexity_threshold: 0.4,
441                pricing: Some(ModelPricingConfig {
442                    input_per_1k: 0.003,
443                    output_per_1k: 0.015,
444                    cache_read_discount: 0.9,
445                }),
446            },
447        }
448    }
449}
450
451// ---------------------------------------------------------------------------
452// Tests
453// ---------------------------------------------------------------------------
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use proptest::prelude::*;
459
460    // ---------------------------------------------------------------------------
461    // Strategies for generating valid Preset objects
462    // ---------------------------------------------------------------------------
463
464    /// Non-empty string strategy (printable ASCII, no control chars).
465    fn arb_nonempty_string() -> impl Strategy<Value = String> {
466        "[a-zA-Z0-9_\\-\\.]{1,32}".prop_map(|s| s)
467    }
468
469    /// f64 strictly in (0.0, 1.0), exclusive.
470    fn arb_open_unit() -> impl Strategy<Value = f64> {
471        (1u32..=9999u32).prop_map(|n| n as f64 / 10_000.0)
472    }
473
474    /// Strategy for a valid BudgetConfig: ceiling > warning, both in (0, 1).
475    fn arb_budget_config() -> impl Strategy<Value = BudgetConfig> {
476        // Pick warning in (0, 0.9), then ceiling in (warning, 1.0).
477        (1u32..=8999u32).prop_flat_map(|w_raw| {
478            let warning = w_raw as f64 / 10_000.0; // in (0.0001, 0.8999)
479            // ceiling must be > warning and < 1.0
480            let c_min = (w_raw + 1) as f64 / 10_000.0;
481            let c_max = 9999.0_f64 / 10_000.0;
482            // Map a u32 in [c_min_int, 9999] to a f64
483            let c_min_int = w_raw + 1;
484            (c_min_int..=9999u32).prop_map(move |c_raw| {
485                let ceiling = c_raw as f64 / 10_000.0;
486                let _ = (c_min, c_max); // suppress unused warnings
487                BudgetConfig {
488                    warning_threshold: warning,
489                    ceiling_threshold: ceiling,
490                    default_window_size: 200_000,
491                    agents: Default::default(),
492                }
493            })
494        })
495    }
496
497    /// Strategy for a valid ToolSelectionConfig.
498    fn arb_tool_selection_config() -> impl Strategy<Value = ToolSelectionConfig> {
499        (1usize..=50usize, arb_open_unit()).prop_map(|(max_tools, similarity_threshold)| {
500            ToolSelectionConfig {
501                max_tools,
502                similarity_threshold,
503                default_tools: vec![],
504            }
505        })
506    }
507
508    /// Strategy for a valid ModelConfig.
509    fn arb_model_config() -> impl Strategy<Value = ModelConfig> {
510        (arb_nonempty_string(), arb_open_unit()).prop_map(|(family, complexity_threshold)| {
511            ModelConfig {
512                family,
513                primary: String::new(),
514                local: String::new(),
515                complexity_threshold,
516                pricing: None,
517            }
518        })
519    }
520
521    /// Strategy for a valid Preset.
522    fn arb_preset() -> impl Strategy<Value = Preset> {
523        (
524            arb_nonempty_string(), // name
525            arb_nonempty_string(), // version
526            arb_budget_config(),
527            arb_tool_selection_config(),
528            arb_model_config(),
529        )
530            .prop_map(|(name, version, budget, tool_selection, model)| Preset {
531                preset: PresetMeta {
532                    name,
533                    version,
534                    description: String::new(),
535                },
536                compression: CompressionConfig {
537                    stages: vec![],
538                    keep_fields: None,
539                    strip_fields: None,
540                    condense: None,
541                    git_diff_fold: None,
542                    strip_nulls: None,
543                    flatten: None,
544                    truncate_strings: None,
545                    collapse_arrays: None,
546                    custom_transforms: None,
547                },
548                tool_selection,
549                budget,
550                terse_mode: TerseModeConfig {
551                    enabled: false,
552                    level: TerseLevel::Moderate,
553                },
554                model,
555            })
556    }
557
558    // ---------------------------------------------------------------------------
559    // Property 31: TOML Preset round-trip
560    // Validates: Requirements 29.1, 29.2, 29.3
561    // ---------------------------------------------------------------------------
562
563    proptest! {
564        /// **Validates: Requirements 29.1, 29.2, 29.3**
565        ///
566        /// Property 31: TOML Preset round-trip.
567        ///
568        /// For all valid Preset objects, serializing to TOML then deserializing
569        /// SHALL produce an equivalent Preset object.
570        ///
571        /// We compare by double-serializing: serialize the original to TOML,
572        /// parse it back, serialize again, and assert the two TOML strings are
573        /// identical. This avoids f64 direct comparison issues while still
574        /// verifying full fidelity.
575        #[test]
576        fn prop_preset_toml_round_trip(preset in arb_preset()) {
577            // First serialize
578            let toml1 = PresetParser::to_toml(&preset)
579                .expect("to_toml should not fail on a valid preset");
580
581            // Parse back
582            let parsed = PresetParser::parse(&toml1)
583                .expect("parse should not fail on a valid TOML string");
584
585            // Second serialize
586            let toml2 = PresetParser::to_toml(&parsed)
587                .expect("to_toml should not fail on re-parsed preset");
588
589            // The two TOML strings must be identical
590            prop_assert_eq!(
591                &toml1,
592                &toml2,
593                "TOML round-trip mismatch:\nfirst:  {}\nsecond: {}",
594                toml1,
595                toml2
596            );
597        }
598    }
599
600    // ---------------------------------------------------------------------------
601    // Property 32: Preset validation error descriptiveness
602    // Validates: Requirements 24.5, 29.4
603    // ---------------------------------------------------------------------------
604
605    /// Strategy for invalid warning_threshold values: 0.0, 1.0, negative, or >1.0.
606    fn arb_invalid_warning_threshold() -> impl Strategy<Value = f64> {
607        prop_oneof![
608            Just(0.0_f64),
609            Just(1.0_f64),
610            // negative values: -1.0 to -0.0001
611            (1u32..=10000u32).prop_map(|n| -(n as f64 / 10_000.0)),
612            // values > 1.0: 1.0001 to 2.0
613            (10001u32..=20000u32).prop_map(|n| n as f64 / 10_000.0),
614        ]
615    }
616
617    /// Strategy for invalid ceiling_threshold values: 0.0, 1.0, negative, or >1.0.
618    fn arb_invalid_ceiling_threshold() -> impl Strategy<Value = f64> {
619        prop_oneof![
620            Just(0.0_f64),
621            Just(1.0_f64),
622            (1u32..=10000u32).prop_map(|n| -(n as f64 / 10_000.0)),
623            (10001u32..=20000u32).prop_map(|n| n as f64 / 10_000.0),
624        ]
625    }
626
627    /// Strategy for invalid max_tools values: 0 or >50.
628    fn arb_invalid_max_tools() -> impl Strategy<Value = usize> {
629        prop_oneof![
630            Just(0usize),
631            (51usize..=200usize),
632        ]
633    }
634
635    /// Strategy for invalid complexity_threshold values: 0.0, 1.0, negative, or >1.0.
636    fn arb_invalid_complexity_threshold() -> impl Strategy<Value = f64> {
637        prop_oneof![
638            Just(0.0_f64),
639            Just(1.0_f64),
640            (1u32..=10000u32).prop_map(|n| -(n as f64 / 10_000.0)),
641            (10001u32..=20000u32).prop_map(|n| n as f64 / 10_000.0),
642        ]
643    }
644
645    proptest! {
646        /// **Validates: Requirements 24.5, 29.4**
647        ///
648        /// Property 32a: Invalid `budget.warning_threshold` produces a descriptive error
649        /// mentioning "budget.warning_threshold".
650        #[test]
651        fn prop_invalid_warning_threshold_error_mentions_field(
652            invalid_wt in arb_invalid_warning_threshold()
653        ) {
654            let mut preset = Preset::default();
655            preset.budget.warning_threshold = invalid_wt;
656            // Also ensure ceiling > warning to isolate the warning_threshold error.
657            // If invalid_wt >= 0.0 and < 1.0 but ceiling <= warning, we still want
658            // the warning_threshold error to fire first. The validate function checks
659            // warning_threshold before ceiling_threshold, so set ceiling to something
660            // that would be valid if warning were valid.
661            preset.budget.ceiling_threshold = 0.85;
662
663            let result = PresetParser::validate(&preset);
664            prop_assert!(result.is_err(), "expected validation error for warning_threshold={}", invalid_wt);
665            let err_msg = result.unwrap_err().to_string();
666            prop_assert!(
667                err_msg.contains("budget.warning_threshold"),
668                "error message '{}' does not mention 'budget.warning_threshold'",
669                err_msg
670            );
671        }
672
673        /// **Validates: Requirements 24.5, 29.4**
674        ///
675        /// Property 32b: Invalid `budget.ceiling_threshold` produces a descriptive error
676        /// mentioning "budget.ceiling_threshold".
677        #[test]
678        fn prop_invalid_ceiling_threshold_error_mentions_field(
679            invalid_ct in arb_invalid_ceiling_threshold()
680        ) {
681            let mut preset = Preset::default();
682            // Keep warning_threshold valid so ceiling_threshold error fires.
683            preset.budget.warning_threshold = 0.70;
684            preset.budget.ceiling_threshold = invalid_ct;
685
686            let result = PresetParser::validate(&preset);
687            prop_assert!(result.is_err(), "expected validation error for ceiling_threshold={}", invalid_ct);
688            let err_msg = result.unwrap_err().to_string();
689            prop_assert!(
690                err_msg.contains("budget.ceiling_threshold"),
691                "error message '{}' does not mention 'budget.ceiling_threshold'",
692                err_msg
693            );
694        }
695
696        /// **Validates: Requirements 24.5, 29.4**
697        ///
698        /// Property 32c: Empty `preset.name` produces a descriptive error
699        /// mentioning "preset.name".
700        #[test]
701        fn prop_empty_preset_name_error_mentions_field(_dummy in 0u32..1u32) {
702            let mut preset = Preset::default();
703            preset.preset.name = String::new();
704
705            let result = PresetParser::validate(&preset);
706            prop_assert!(result.is_err(), "expected validation error for empty preset.name");
707            let err_msg = result.unwrap_err().to_string();
708            prop_assert!(
709                err_msg.contains("preset.name"),
710                "error message '{}' does not mention 'preset.name'",
711                err_msg
712            );
713        }
714
715        /// **Validates: Requirements 24.5, 29.4**
716        ///
717        /// Property 32d: Invalid `tool_selection.max_tools` (0 or >50) produces a
718        /// descriptive error mentioning "tool_selection.max_tools".
719        #[test]
720        fn prop_invalid_max_tools_error_mentions_field(
721            invalid_mt in arb_invalid_max_tools()
722        ) {
723            let mut preset = Preset::default();
724            preset.tool_selection.max_tools = invalid_mt;
725
726            let result = PresetParser::validate(&preset);
727            prop_assert!(result.is_err(), "expected validation error for max_tools={}", invalid_mt);
728            let err_msg = result.unwrap_err().to_string();
729            prop_assert!(
730                err_msg.contains("tool_selection.max_tools"),
731                "error message '{}' does not mention 'tool_selection.max_tools'",
732                err_msg
733            );
734        }
735
736        /// **Validates: Requirements 24.5, 29.4**
737        ///
738        /// Property 32e: Invalid `model.complexity_threshold` produces a descriptive
739        /// error mentioning "model.complexity_threshold".
740        #[test]
741        fn prop_invalid_complexity_threshold_error_mentions_field(
742            invalid_cxt in arb_invalid_complexity_threshold()
743        ) {
744            let mut preset = Preset::default();
745            preset.model.complexity_threshold = invalid_cxt;
746
747            let result = PresetParser::validate(&preset);
748            prop_assert!(result.is_err(), "expected validation error for complexity_threshold={}", invalid_cxt);
749            let err_msg = result.unwrap_err().to_string();
750            prop_assert!(
751                err_msg.contains("model.complexity_threshold"),
752                "error message '{}' does not mention 'model.complexity_threshold'",
753                err_msg
754            );
755        }
756    }
757}