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