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