Skip to main content

zeph_core/quality/
config.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Configuration types for the MARCH self-check quality pipeline.
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// When to run the self-check pipeline.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
11#[serde(rename_all = "snake_case")]
12pub enum TriggerPolicy {
13    /// Run only when the turn has retrieved context (semantic recall, summaries, cross-session).
14    #[default]
15    HasRetrieval,
16    /// Always run regardless of retrieved context.
17    Always,
18    /// Never run automatically; only via explicit command.
19    Manual,
20}
21
22/// Configuration for the MARCH self-check quality pipeline.
23///
24/// Add a `[quality]` section to your `config.toml` to enable:
25///
26/// ```toml
27/// [quality]
28/// self_check = true
29/// trigger = "has_retrieval"
30/// async_run = false
31/// ```
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct QualityConfig {
34    /// Enable post-response self-check pipeline.
35    #[serde(default)]
36    pub self_check: bool,
37
38    /// Advisory: preferred provider for the Proposer role.
39    ///
40    /// In MVP this field is parsed but not acted upon — the main provider is used.
41    /// Tracked as a follow-up issue.
42    #[serde(default)]
43    pub proposer_provider: String,
44
45    /// Advisory: preferred provider for the Checker role.
46    ///
47    /// In MVP this field is parsed but not acted upon — the main provider is used.
48    /// Tracked as a follow-up issue.
49    #[serde(default)]
50    pub checker_provider: String,
51
52    /// When to trigger the self-check pipeline.
53    #[serde(default)]
54    pub trigger: TriggerPolicy,
55
56    /// Minimum evidence strength to consider an assertion supported (0.0–1.0).
57    ///
58    /// Assertions where `status != Irrelevant && evidence < min_evidence` are flagged.
59    #[serde(default = "default_min_evidence")]
60    pub min_evidence: f32,
61
62    /// If `false` (default), self-check blocks the response until complete.
63    /// If `true`, it runs in the background and emits a visible closing marker.
64    #[serde(default)]
65    pub async_run: bool,
66
67    /// Hard ceiling on total pipeline latency in milliseconds (sync mode).
68    #[serde(default = "default_latency_budget_ms")]
69    pub latency_budget_ms: u64,
70
71    /// Per-LLM-call timeout in milliseconds. Must be ≤ `latency_budget_ms` / 2.
72    #[serde(default = "default_per_call_timeout_ms")]
73    pub per_call_timeout_ms: u64,
74
75    /// Maximum number of assertions to extract from one response.
76    #[serde(default = "default_max_assertions")]
77    pub max_assertions: usize,
78
79    /// Skip pipeline when assistant response exceeds this many characters.
80    #[serde(default = "default_max_response_chars")]
81    pub max_response_chars: usize,
82
83    /// If `true`, Checker provider clones without prompt-cache emission (recommended).
84    #[serde(default = "default_cache_disabled_for_checker")]
85    pub cache_disabled_for_checker: bool,
86
87    /// String appended to the assistant response when the pipeline flags issues.
88    #[serde(default = "default_flag_marker")]
89    pub flag_marker: String,
90}
91
92fn default_min_evidence() -> f32 {
93    0.6
94}
95fn default_latency_budget_ms() -> u64 {
96    4_000
97}
98fn default_per_call_timeout_ms() -> u64 {
99    2_000
100}
101fn default_max_assertions() -> usize {
102    12
103}
104fn default_max_response_chars() -> usize {
105    8_000
106}
107fn default_cache_disabled_for_checker() -> bool {
108    true
109}
110fn default_flag_marker() -> String {
111    "[verify]".into()
112}
113
114impl Default for QualityConfig {
115    fn default() -> Self {
116        Self {
117            self_check: false,
118            proposer_provider: String::new(),
119            checker_provider: String::new(),
120            trigger: TriggerPolicy::default(),
121            min_evidence: default_min_evidence(),
122            async_run: false,
123            latency_budget_ms: default_latency_budget_ms(),
124            per_call_timeout_ms: default_per_call_timeout_ms(),
125            max_assertions: default_max_assertions(),
126            max_response_chars: default_max_response_chars(),
127            cache_disabled_for_checker: default_cache_disabled_for_checker(),
128            flag_marker: default_flag_marker(),
129        }
130    }
131}
132
133/// Errors returned by [`QualityConfig::validate`].
134#[derive(Debug, Error)]
135pub enum QualityConfigError {
136    #[error("per_call_timeout_ms ({per_call}) × 2 must be ≤ latency_budget_ms ({budget})")]
137    TimeoutExceedsBudget { per_call: u64, budget: u64 },
138    #[error("min_evidence must be in 0.0..=1.0, got {0}")]
139    InvalidMinEvidence(f32),
140}
141
142impl QualityConfig {
143    /// Validate consistency constraints.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if `2 * per_call_timeout_ms > latency_budget_ms` or
148    /// `min_evidence` is outside `[0.0, 1.0]`.
149    pub fn validate(&self) -> Result<(), QualityConfigError> {
150        if 2 * self.per_call_timeout_ms > self.latency_budget_ms {
151            return Err(QualityConfigError::TimeoutExceedsBudget {
152                per_call: self.per_call_timeout_ms,
153                budget: self.latency_budget_ms,
154            });
155        }
156        if !(0.0..=1.0).contains(&self.min_evidence) {
157            return Err(QualityConfigError::InvalidMinEvidence(self.min_evidence));
158        }
159        Ok(())
160    }
161}
162
163impl From<&zeph_config::QualityConfig> for QualityConfig {
164    fn from(c: &zeph_config::QualityConfig) -> Self {
165        Self {
166            self_check: c.self_check,
167            proposer_provider: c.proposer_provider.clone(),
168            checker_provider: c.checker_provider.clone(),
169            trigger: match c.trigger {
170                zeph_config::TriggerPolicy::HasRetrieval => TriggerPolicy::HasRetrieval,
171                zeph_config::TriggerPolicy::Always => TriggerPolicy::Always,
172                zeph_config::TriggerPolicy::Manual => TriggerPolicy::Manual,
173            },
174            min_evidence: c.min_evidence,
175            async_run: c.async_run,
176            latency_budget_ms: c.latency_budget_ms,
177            per_call_timeout_ms: c.per_call_timeout_ms,
178            max_assertions: c.max_assertions,
179            max_response_chars: c.max_response_chars,
180            cache_disabled_for_checker: c.cache_disabled_for_checker,
181            flag_marker: c.flag_marker.clone(),
182        }
183    }
184}