Skip to main content

tailtriage_analyzer/options/
mod.rs

1use crate::AnalyzeConfigOverrideSummary;
2use serde::Serialize;
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6mod descriptors;
7mod overrides;
8mod toml;
9pub use descriptors::analyze_option_descriptors;
10
11/// Semantic analyzer options grouped by triage domain.
12///
13/// # Examples
14///
15/// ```
16/// use tailtriage_analyzer::AnalyzeOptions;
17///
18/// let options = AnalyzeOptions::default()
19///     .with_queueing(|o| o.trigger_permille = 450)
20///     .with_confidence(|o| o.high_score_threshold = 90);
21///
22/// assert_eq!(options.queueing.trigger_permille, 450);
23/// assert_eq!(options.confidence.high_score_threshold, 90);
24/// ```
25#[non_exhaustive]
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
27pub struct AnalyzeOptions {
28    /// Queue-pressure thresholds used to rank queue-saturation suspects during triage.
29    pub queueing: QueueingOptions,
30    /// Blocking-pool heuristics used to rank blocking-pressure suspects during triage.
31    pub blocking: BlockingOptions,
32    /// Executor-pressure thresholds used when runtime queue evidence is available.
33    pub executor: ExecutorOptions,
34    /// Downstream-stage heuristics used to compare stage dominance against blocking evidence.
35    pub downstream: DownstreamOptions,
36    /// Score thresholds that map suspect scores into confidence buckets and ambiguity warnings.
37    pub confidence: ConfidenceOptions,
38    /// Evidence-quality thresholds that control low-sample warnings and confidence downgrades.
39    pub evidence: EvidenceOptions,
40    /// Route-level thresholds for optional route triage breakdown summaries.
41    pub route: RouteOptions,
42    /// Temporal-shift thresholds for optional early/late triage segment summaries.
43    pub temporal: TemporalOptions,
44}
45
46/// Queue-saturation suspect thresholds.
47#[non_exhaustive]
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49pub struct QueueingOptions {
50    /// Minimum p95 queue-share permille needed before queue-saturation suspect ranking can trigger.
51    pub trigger_permille: u64,
52}
53
54impl Default for QueueingOptions {
55    fn default() -> Self {
56        Self {
57            trigger_permille: 300,
58        }
59    }
60}
61
62/// Blocking-pressure suspect thresholds and minimum-sample guards.
63#[non_exhaustive]
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct BlockingOptions {
66    /// Minimum number of non-zero blocking queue samples needed before blocking signal can trigger.
67    pub min_nonzero_samples_for_signal: usize,
68    /// Blocking queue-depth p95 threshold used for stronger blocking-pressure suspect evidence.
69    pub strong_p95_threshold: u64,
70    /// Blocking queue-depth peak threshold used for stronger blocking-pressure suspect evidence.
71    pub strong_peak_threshold: u64,
72    /// Minimum non-zero blocking sample share (permille) for stronger blocking-pressure suspect evidence.
73    pub strong_nonzero_share_permille: u64,
74    /// Minimum blocking sample count before strong blocking heuristics can trigger.
75    pub strong_min_samples: usize,
76}
77
78impl Default for BlockingOptions {
79    fn default() -> Self {
80        Self {
81            min_nonzero_samples_for_signal: 2,
82            strong_p95_threshold: 12,
83            strong_peak_threshold: 20,
84            strong_nonzero_share_permille: 700,
85            strong_min_samples: 30,
86        }
87    }
88}
89
90/// Executor-pressure suspect thresholds derived from runtime queue pressure.
91#[non_exhaustive]
92#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
93pub struct ExecutorOptions {
94    /// Minimum runtime global-queue p95 needed before executor-pressure suspect ranking can trigger.
95    pub min_global_queue_p95_for_signal: u64,
96}
97
98impl Default for ExecutorOptions {
99    fn default() -> Self {
100        Self {
101            min_global_queue_p95_for_signal: 1,
102        }
103    }
104}
105
106/// Downstream-stage suspect thresholds and blocking-correlation heuristics.
107#[non_exhaustive]
108#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
109pub struct DownstreamOptions {
110    /// Minimum stage sample count required before downstream-stage suspect ranking can trigger.
111    pub min_stage_samples: usize,
112    /// Stage-name substrings used to detect downstream evidence that may correlate with blocking work.
113    pub blocking_correlated_stage_patterns: Vec<String>,
114    /// Minimum score margin required before favoring downstream-stage suspects over blocking-correlated interpretations.
115    pub blocking_correlation_score_margin: u8,
116}
117
118impl Default for DownstreamOptions {
119    fn default() -> Self {
120        Self {
121            min_stage_samples: 3,
122            blocking_correlated_stage_patterns: vec![
123                "spawn_blocking".to_owned(),
124                "blocking_path".to_owned(),
125                "blocking".to_owned(),
126            ],
127            blocking_correlation_score_margin: 2,
128        }
129    }
130}
131
132/// Confidence-bucket and ambiguity-warning score thresholds.
133#[non_exhaustive]
134#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
135pub struct ConfidenceOptions {
136    /// Minimum suspect score treated as medium confidence.
137    pub medium_score_threshold: u8,
138    /// Minimum suspect score treated as high confidence.
139    pub high_score_threshold: u8,
140    /// Minimum top-suspect score before ambiguity heuristics may emit a warning.
141    pub ambiguity_min_score: u8,
142    /// Maximum score gap considered a near-tie for ambiguity warning heuristics.
143    pub ambiguity_score_gap: u8,
144}
145
146impl Default for ConfidenceOptions {
147    fn default() -> Self {
148        Self {
149            medium_score_threshold: 65,
150            high_score_threshold: 85,
151            ambiguity_min_score: 60,
152            ambiguity_score_gap: 4,
153        }
154    }
155}
156
157/// Evidence-quality thresholds used for low-sample warnings.
158#[non_exhaustive]
159#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
160pub struct EvidenceOptions {
161    /// Completed-request threshold below which low-sample evidence warnings apply.
162    pub low_completed_request_threshold: usize,
163}
164
165impl Default for EvidenceOptions {
166    fn default() -> Self {
167        Self {
168            low_completed_request_threshold: 20,
169        }
170    }
171}
172
173/// Route-breakdown thresholds used for route-level suspect comparisons.
174#[non_exhaustive]
175#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
176pub struct RouteOptions {
177    /// Minimum per-route completed requests required for route breakdown inclusion.
178    pub min_request_count: usize,
179    /// Maximum number of route breakdown entries emitted in one report.
180    pub breakdown_limit: usize,
181    /// Whether to emit a warning when route-level primary suspects diverge from each other.
182    pub emit_on_divergent_suspects: bool,
183    /// Numerator for slowest-to-fastest route p95 ratio heuristic threshold.
184    pub slowest_to_fastest_p95_ratio_numerator: u64,
185    /// Denominator for slowest-to-fastest route p95 ratio heuristic threshold.
186    pub slowest_to_fastest_p95_ratio_denominator: u64,
187    /// Numerator for slowest-route to global p95 ratio heuristic threshold.
188    pub slowest_to_global_p95_ratio_numerator: u64,
189    /// Denominator for slowest-route to global p95 ratio heuristic threshold.
190    pub slowest_to_global_p95_ratio_denominator: u64,
191}
192
193impl Default for RouteOptions {
194    fn default() -> Self {
195        Self {
196            min_request_count: 3,
197            breakdown_limit: 10,
198            emit_on_divergent_suspects: true,
199            slowest_to_fastest_p95_ratio_numerator: 3,
200            slowest_to_fastest_p95_ratio_denominator: 2,
201            slowest_to_global_p95_ratio_numerator: 5,
202            slowest_to_global_p95_ratio_denominator: 4,
203        }
204    }
205}
206
207/// Temporal-shift thresholds used for early/late suspect comparisons.
208#[non_exhaustive]
209#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
210pub struct TemporalOptions {
211    /// Minimum completed requests required before temporal segmentation heuristics run.
212    pub min_request_count: usize,
213    /// Minimum completed requests required in each temporal segment for suspect comparison.
214    pub min_segment_request_count: usize,
215    /// Minimum queue/service-share movement (permille) required to flag temporal suspect shift evidence.
216    pub share_shift_permille: u64,
217    /// Numerator for temporal p95 ratio movement heuristic threshold.
218    pub p95_shift_ratio_numerator: u64,
219    /// Denominator for temporal p95 ratio movement heuristic threshold.
220    pub p95_shift_ratio_denominator: u64,
221    /// Whether to emit temporal suspect-shift warnings when movement heuristics trigger.
222    pub emit_on_suspect_shift: bool,
223    /// Whether to suppress runtime-sparse suspect-shift warnings when supporting movement evidence is absent.
224    pub suppress_runtime_sparse_suspect_shift_without_supporting_movement: bool,
225}
226
227impl Default for TemporalOptions {
228    fn default() -> Self {
229        Self {
230            min_request_count: 20,
231            min_segment_request_count: 8,
232            share_shift_permille: 200,
233            p95_shift_ratio_numerator: 3,
234            p95_shift_ratio_denominator: 2,
235            emit_on_suspect_shift: true,
236            suppress_runtime_sparse_suspect_shift_without_supporting_movement: true,
237        }
238    }
239}
240
241fn push_non_default_override(
242    out: &mut Vec<AnalyzeConfigOverrideSummary>,
243    path: &str,
244    value: String,
245) {
246    out.push(AnalyzeConfigOverrideSummary {
247        path: path.to_string(),
248        value,
249    });
250}
251
252fn queueing_non_default_overrides(
253    out: &mut Vec<AnalyzeConfigOverrideSummary>,
254    options: &AnalyzeOptions,
255    defaults: &AnalyzeOptions,
256) {
257    if options.queueing.trigger_permille != defaults.queueing.trigger_permille {
258        push_non_default_override(
259            out,
260            "queueing.trigger_permille",
261            options.queueing.trigger_permille.to_string(),
262        );
263    }
264}
265
266fn blocking_non_default_overrides(
267    out: &mut Vec<AnalyzeConfigOverrideSummary>,
268    options: &AnalyzeOptions,
269    defaults: &AnalyzeOptions,
270) {
271    if options.blocking.min_nonzero_samples_for_signal
272        != defaults.blocking.min_nonzero_samples_for_signal
273    {
274        push_non_default_override(
275            out,
276            "blocking.min_nonzero_samples_for_signal",
277            options.blocking.min_nonzero_samples_for_signal.to_string(),
278        );
279    }
280    if options.blocking.strong_p95_threshold != defaults.blocking.strong_p95_threshold {
281        push_non_default_override(
282            out,
283            "blocking.strong_p95_threshold",
284            options.blocking.strong_p95_threshold.to_string(),
285        );
286    }
287    if options.blocking.strong_peak_threshold != defaults.blocking.strong_peak_threshold {
288        push_non_default_override(
289            out,
290            "blocking.strong_peak_threshold",
291            options.blocking.strong_peak_threshold.to_string(),
292        );
293    }
294    if options.blocking.strong_nonzero_share_permille
295        != defaults.blocking.strong_nonzero_share_permille
296    {
297        push_non_default_override(
298            out,
299            "blocking.strong_nonzero_share_permille",
300            options.blocking.strong_nonzero_share_permille.to_string(),
301        );
302    }
303    if options.blocking.strong_min_samples != defaults.blocking.strong_min_samples {
304        push_non_default_override(
305            out,
306            "blocking.strong_min_samples",
307            options.blocking.strong_min_samples.to_string(),
308        );
309    }
310}
311
312fn executor_non_default_overrides(
313    out: &mut Vec<AnalyzeConfigOverrideSummary>,
314    options: &AnalyzeOptions,
315    defaults: &AnalyzeOptions,
316) {
317    if options.executor.min_global_queue_p95_for_signal
318        != defaults.executor.min_global_queue_p95_for_signal
319    {
320        push_non_default_override(
321            out,
322            "executor.min_global_queue_p95_for_signal",
323            options.executor.min_global_queue_p95_for_signal.to_string(),
324        );
325    }
326}
327
328fn downstream_non_default_overrides(
329    out: &mut Vec<AnalyzeConfigOverrideSummary>,
330    options: &AnalyzeOptions,
331    defaults: &AnalyzeOptions,
332) {
333    if options.downstream.min_stage_samples != defaults.downstream.min_stage_samples {
334        push_non_default_override(
335            out,
336            "downstream.min_stage_samples",
337            options.downstream.min_stage_samples.to_string(),
338        );
339    }
340    if options.downstream.blocking_correlated_stage_patterns
341        != defaults.downstream.blocking_correlated_stage_patterns
342    {
343        push_non_default_override(
344            out,
345            "downstream.blocking_correlated_stage_patterns",
346            options
347                .downstream
348                .blocking_correlated_stage_patterns
349                .join(","),
350        );
351    }
352    if options.downstream.blocking_correlation_score_margin
353        != defaults.downstream.blocking_correlation_score_margin
354    {
355        push_non_default_override(
356            out,
357            "downstream.blocking_correlation_score_margin",
358            options
359                .downstream
360                .blocking_correlation_score_margin
361                .to_string(),
362        );
363    }
364}
365
366fn confidence_non_default_overrides(
367    out: &mut Vec<AnalyzeConfigOverrideSummary>,
368    options: &AnalyzeOptions,
369    defaults: &AnalyzeOptions,
370) {
371    if options.confidence.medium_score_threshold != defaults.confidence.medium_score_threshold {
372        push_non_default_override(
373            out,
374            "confidence.medium_score_threshold",
375            options.confidence.medium_score_threshold.to_string(),
376        );
377    }
378    if options.confidence.high_score_threshold != defaults.confidence.high_score_threshold {
379        push_non_default_override(
380            out,
381            "confidence.high_score_threshold",
382            options.confidence.high_score_threshold.to_string(),
383        );
384    }
385    if options.confidence.ambiguity_min_score != defaults.confidence.ambiguity_min_score {
386        push_non_default_override(
387            out,
388            "confidence.ambiguity_min_score",
389            options.confidence.ambiguity_min_score.to_string(),
390        );
391    }
392    if options.confidence.ambiguity_score_gap != defaults.confidence.ambiguity_score_gap {
393        push_non_default_override(
394            out,
395            "confidence.ambiguity_score_gap",
396            options.confidence.ambiguity_score_gap.to_string(),
397        );
398    }
399}
400
401fn evidence_non_default_overrides(
402    out: &mut Vec<AnalyzeConfigOverrideSummary>,
403    options: &AnalyzeOptions,
404    defaults: &AnalyzeOptions,
405) {
406    if options.evidence.low_completed_request_threshold
407        != defaults.evidence.low_completed_request_threshold
408    {
409        push_non_default_override(
410            out,
411            "evidence.low_completed_request_threshold",
412            options.evidence.low_completed_request_threshold.to_string(),
413        );
414    }
415}
416
417fn route_non_default_overrides(
418    out: &mut Vec<AnalyzeConfigOverrideSummary>,
419    options: &AnalyzeOptions,
420    defaults: &AnalyzeOptions,
421) {
422    if options.route.min_request_count != defaults.route.min_request_count {
423        push_non_default_override(
424            out,
425            "route.min_request_count",
426            options.route.min_request_count.to_string(),
427        );
428    }
429    if options.route.breakdown_limit != defaults.route.breakdown_limit {
430        push_non_default_override(
431            out,
432            "route.breakdown_limit",
433            options.route.breakdown_limit.to_string(),
434        );
435    }
436    if options.route.emit_on_divergent_suspects != defaults.route.emit_on_divergent_suspects {
437        push_non_default_override(
438            out,
439            "route.emit_on_divergent_suspects",
440            options.route.emit_on_divergent_suspects.to_string(),
441        );
442    }
443    if options.route.slowest_to_fastest_p95_ratio_numerator
444        != defaults.route.slowest_to_fastest_p95_ratio_numerator
445    {
446        push_non_default_override(
447            out,
448            "route.slowest_to_fastest_p95_ratio_numerator",
449            options
450                .route
451                .slowest_to_fastest_p95_ratio_numerator
452                .to_string(),
453        );
454    }
455    if options.route.slowest_to_fastest_p95_ratio_denominator
456        != defaults.route.slowest_to_fastest_p95_ratio_denominator
457    {
458        push_non_default_override(
459            out,
460            "route.slowest_to_fastest_p95_ratio_denominator",
461            options
462                .route
463                .slowest_to_fastest_p95_ratio_denominator
464                .to_string(),
465        );
466    }
467    if options.route.slowest_to_global_p95_ratio_numerator
468        != defaults.route.slowest_to_global_p95_ratio_numerator
469    {
470        push_non_default_override(
471            out,
472            "route.slowest_to_global_p95_ratio_numerator",
473            options
474                .route
475                .slowest_to_global_p95_ratio_numerator
476                .to_string(),
477        );
478    }
479    if options.route.slowest_to_global_p95_ratio_denominator
480        != defaults.route.slowest_to_global_p95_ratio_denominator
481    {
482        push_non_default_override(
483            out,
484            "route.slowest_to_global_p95_ratio_denominator",
485            options
486                .route
487                .slowest_to_global_p95_ratio_denominator
488                .to_string(),
489        );
490    }
491}
492
493fn temporal_non_default_overrides(
494    out: &mut Vec<AnalyzeConfigOverrideSummary>,
495    options: &AnalyzeOptions,
496    defaults: &AnalyzeOptions,
497) {
498    if options.temporal.min_request_count != defaults.temporal.min_request_count {
499        push_non_default_override(
500            out,
501            "temporal.min_request_count",
502            options.temporal.min_request_count.to_string(),
503        );
504    }
505    if options.temporal.min_segment_request_count != defaults.temporal.min_segment_request_count {
506        push_non_default_override(
507            out,
508            "temporal.min_segment_request_count",
509            options.temporal.min_segment_request_count.to_string(),
510        );
511    }
512    if options.temporal.share_shift_permille != defaults.temporal.share_shift_permille {
513        push_non_default_override(
514            out,
515            "temporal.share_shift_permille",
516            options.temporal.share_shift_permille.to_string(),
517        );
518    }
519    if options.temporal.p95_shift_ratio_numerator != defaults.temporal.p95_shift_ratio_numerator {
520        push_non_default_override(
521            out,
522            "temporal.p95_shift_ratio_numerator",
523            options.temporal.p95_shift_ratio_numerator.to_string(),
524        );
525    }
526    if options.temporal.p95_shift_ratio_denominator != defaults.temporal.p95_shift_ratio_denominator
527    {
528        push_non_default_override(
529            out,
530            "temporal.p95_shift_ratio_denominator",
531            options.temporal.p95_shift_ratio_denominator.to_string(),
532        );
533    }
534    if options.temporal.emit_on_suspect_shift != defaults.temporal.emit_on_suspect_shift {
535        push_non_default_override(
536            out,
537            "temporal.emit_on_suspect_shift",
538            options.temporal.emit_on_suspect_shift.to_string(),
539        );
540    }
541    if options
542        .temporal
543        .suppress_runtime_sparse_suspect_shift_without_supporting_movement
544        != defaults
545            .temporal
546            .suppress_runtime_sparse_suspect_shift_without_supporting_movement
547    {
548        push_non_default_override(
549            out,
550            "temporal.suppress_runtime_sparse_suspect_shift_without_supporting_movement",
551            options
552                .temporal
553                .suppress_runtime_sparse_suspect_shift_without_supporting_movement
554                .to_string(),
555        );
556    }
557}
558impl AnalyzeOptions {
559    /// Returns sorted non-default semantic option overrides as stable path/value summaries.
560    #[must_use]
561    pub fn non_default_overrides(&self) -> Vec<AnalyzeConfigOverrideSummary> {
562        let defaults = Self::default();
563        let mut out = Vec::new();
564        queueing_non_default_overrides(&mut out, self, &defaults);
565        blocking_non_default_overrides(&mut out, self, &defaults);
566        executor_non_default_overrides(&mut out, self, &defaults);
567        downstream_non_default_overrides(&mut out, self, &defaults);
568        confidence_non_default_overrides(&mut out, self, &defaults);
569        evidence_non_default_overrides(&mut out, self, &defaults);
570        route_non_default_overrides(&mut out, self, &defaults);
571        temporal_non_default_overrides(&mut out, self, &defaults);
572        out.sort_by(|a, b| a.path.cmp(&b.path));
573        out
574    }
575    /// Applies queueing-option edits and returns updated options for fluent setup.
576    #[must_use]
577    pub fn with_queueing(mut self, f: impl FnOnce(&mut QueueingOptions)) -> Self {
578        f(&mut self.queueing);
579        self
580    }
581    /// Applies blocking-option edits and returns updated options for fluent setup.
582    #[must_use]
583    pub fn with_blocking(mut self, f: impl FnOnce(&mut BlockingOptions)) -> Self {
584        f(&mut self.blocking);
585        self
586    }
587    /// Applies executor-option edits and returns updated options for fluent setup.
588    #[must_use]
589    pub fn with_executor(mut self, f: impl FnOnce(&mut ExecutorOptions)) -> Self {
590        f(&mut self.executor);
591        self
592    }
593    /// Applies downstream-option edits and returns updated options for fluent setup.
594    #[must_use]
595    pub fn with_downstream(mut self, f: impl FnOnce(&mut DownstreamOptions)) -> Self {
596        f(&mut self.downstream);
597        self
598    }
599    /// Applies confidence-option edits and returns updated options for fluent setup.
600    #[must_use]
601    pub fn with_confidence(mut self, f: impl FnOnce(&mut ConfidenceOptions)) -> Self {
602        f(&mut self.confidence);
603        self
604    }
605    /// Applies evidence-option edits and returns updated options for fluent setup.
606    #[must_use]
607    pub fn with_evidence(mut self, f: impl FnOnce(&mut EvidenceOptions)) -> Self {
608        f(&mut self.evidence);
609        self
610    }
611    /// Applies route-option edits and returns updated options for fluent setup.
612    #[must_use]
613    pub fn with_route(mut self, f: impl FnOnce(&mut RouteOptions)) -> Self {
614        f(&mut self.route);
615        self
616    }
617    /// Applies temporal-option edits and returns updated options for fluent setup.
618    #[must_use]
619    pub fn with_temporal(mut self, f: impl FnOnce(&mut TemporalOptions)) -> Self {
620        f(&mut self.temporal);
621        self
622    }
623    /// Validates semantic analyzer thresholds and heuristic invariants before triage.
624    ///
625    /// # Errors
626    ///
627    /// Returns [`AnalyzeConfigError::InvalidConfigValue`] when any threshold or ratio is invalid.
628    #[allow(clippy::too_many_lines)]
629    pub fn validate(&self) -> Result<(), AnalyzeConfigError> {
630        let invalid =
631            |path, message: String| Err(AnalyzeConfigError::InvalidConfigValue { path, message });
632        if self.queueing.trigger_permille > 1000 {
633            return invalid("queueing.trigger_permille", "must be <= 1000".into());
634        }
635        if self.blocking.strong_nonzero_share_permille > 1000 {
636            return invalid(
637                "blocking.strong_nonzero_share_permille",
638                "must be <= 1000".into(),
639            );
640        }
641        if self.confidence.medium_score_threshold > self.confidence.high_score_threshold {
642            return invalid(
643                "confidence.medium_score_threshold",
644                "must be <= confidence.high_score_threshold".into(),
645            );
646        }
647        if self.confidence.high_score_threshold > 100 {
648            return invalid("confidence.high_score_threshold", "must be <= 100".into());
649        }
650        if self.confidence.ambiguity_min_score > 100 {
651            return invalid("confidence.ambiguity_min_score", "must be <= 100".into());
652        }
653        if self.confidence.ambiguity_score_gap > 100 {
654            return invalid("confidence.ambiguity_score_gap", "must be <= 100".into());
655        }
656        if self.downstream.blocking_correlation_score_margin > 100 {
657            return invalid(
658                "downstream.blocking_correlation_score_margin",
659                "must be <= 100".into(),
660            );
661        }
662        if self.route.breakdown_limit == 0 {
663            return invalid("route.breakdown_limit", "must be > 0".into());
664        }
665        for (num_path, den_path, num, den) in [
666            (
667                "route.slowest_to_fastest_p95_ratio_numerator",
668                "route.slowest_to_fastest_p95_ratio_denominator",
669                self.route.slowest_to_fastest_p95_ratio_numerator,
670                self.route.slowest_to_fastest_p95_ratio_denominator,
671            ),
672            (
673                "route.slowest_to_global_p95_ratio_numerator",
674                "route.slowest_to_global_p95_ratio_denominator",
675                self.route.slowest_to_global_p95_ratio_numerator,
676                self.route.slowest_to_global_p95_ratio_denominator,
677            ),
678        ] {
679            if num == 0 {
680                return invalid(num_path, "must be > 0".into());
681            }
682            if den == 0 {
683                return invalid(den_path, "must be > 0".into());
684            }
685            if num < den {
686                return invalid(num_path, format!("must be >= {den_path}"));
687            }
688        }
689        if self.temporal.min_segment_request_count == 0 {
690            return invalid("temporal.min_segment_request_count", "must be > 0".into());
691        }
692        if self.temporal.min_segment_request_count.saturating_mul(2)
693            > self.temporal.min_request_count
694        {
695            return invalid(
696                "temporal.min_segment_request_count",
697                "min_segment_request_count * 2 must be <= temporal.min_request_count".into(),
698            );
699        }
700        if self.temporal.share_shift_permille > 1000 {
701            return invalid("temporal.share_shift_permille", "must be <= 1000".into());
702        }
703        if self.temporal.p95_shift_ratio_numerator == 0 {
704            return invalid("temporal.p95_shift_ratio_numerator", "must be > 0".into());
705        }
706        if self.temporal.p95_shift_ratio_denominator == 0 {
707            return invalid("temporal.p95_shift_ratio_denominator", "must be > 0".into());
708        }
709        if self.temporal.p95_shift_ratio_numerator < self.temporal.p95_shift_ratio_denominator {
710            return invalid(
711                "temporal.p95_shift_ratio_numerator",
712                "must be >= temporal.p95_shift_ratio_denominator".into(),
713            );
714        }
715        if self
716            .downstream
717            .blocking_correlated_stage_patterns
718            .is_empty()
719        {
720            return invalid(
721                "downstream.blocking_correlated_stage_patterns",
722                "must not be empty".into(),
723            );
724        }
725        if self
726            .downstream
727            .blocking_correlated_stage_patterns
728            .iter()
729            .any(|p| p.trim().is_empty())
730        {
731            return invalid(
732                "downstream.blocking_correlated_stage_patterns",
733                "entries must be non-empty after trim".into(),
734            );
735        }
736        Ok(())
737    }
738}
739
740/// Validation and configuration errors for analyzer options and checked triage APIs.
741#[derive(Debug, Clone, PartialEq, Eq)]
742pub enum AnalyzeConfigError {
743    /// Invalid override assignment syntax.
744    InvalidOverrideSyntax {
745        /// Raw override string that failed `path=value` syntax parsing.
746        raw: String,
747    },
748    /// Unknown semantic override path.
749    UnknownOverridePath {
750        /// Unknown semantic option path provided by the caller.
751        path: String,
752        /// Optional nearest known path hint.
753        suggestion: Option<&'static str>,
754    },
755    /// Override value could not be parsed for its path type.
756    InvalidOverrideValue {
757        /// Option path that rejected the provided value.
758        path: &'static str,
759        /// Raw value string that could not be parsed for this path.
760        value: String,
761        /// Human-readable expected value shape for this path.
762        expected: &'static str,
763    },
764    /// Semantic option value failed validation.
765    InvalidConfigValue {
766        /// Option path containing an invalid threshold or heuristic invariant.
767        path: &'static str,
768        /// Validation message describing why the value is invalid.
769        message: String,
770    },
771    /// Missing `[analyzer]` table in configuration input.
772    MissingAnalyzerTable,
773    /// Missing `schema_version` in configuration input.
774    MissingSchemaVersion,
775    /// Unsupported `schema_version` in configuration input.
776    UnsupportedSchemaVersion {
777        /// Encountered schema version from input configuration.
778        found: u64,
779        /// Highest schema version supported by this analyzer build.
780        supported: u64,
781    },
782    /// Invalid TOML error.
783    InvalidToml {
784        /// TOML parsing or decoding error details.
785        message: String,
786    },
787}
788impl Display for AnalyzeConfigError {
789    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
790        match self {
791            Self::InvalidOverrideSyntax { raw } => write!(f, "invalid override syntax: {raw}"),
792            Self::UnknownOverridePath { path, suggestion } => {
793                if let Some(s) = suggestion {
794                    write!(f, "unknown analyzer option '{path}'; did you mean '{s}'?")
795                } else {
796                    write!(f, "unknown analyzer option '{path}'")
797                }
798            }
799            Self::InvalidOverrideValue {
800                path,
801                value,
802                expected,
803            } => write!(
804                f,
805                "invalid override value for '{path}': '{value}' (expected {expected})"
806            ),
807            Self::InvalidConfigValue { path, message } => {
808                write!(f, "invalid config value at '{path}': {message}")
809            }
810            Self::MissingAnalyzerTable => write!(f, "missing [analyzer] table"),
811            Self::MissingSchemaVersion => write!(f, "missing schema_version"),
812            Self::UnsupportedSchemaVersion { found, supported } => write!(
813                f,
814                "unsupported schema_version {found}; supported {supported}"
815            ),
816            Self::InvalidToml { message } => write!(f, "invalid toml: {message}"),
817        }
818    }
819}
820impl Error for AnalyzeConfigError {}
821
822#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
823/// Human-readable metadata for one semantic analyzer option path.
824pub struct AnalyzeOptionDescriptor {
825    /// Stable analyzer option path name.
826    pub path: &'static str,
827    /// Default value string for this option path.
828    pub default_value: &'static str,
829    /// Rust type name for this option value.
830    pub value_type: &'static str,
831    /// Short label describing which triage heuristic area this option affects.
832    pub affects: &'static str,
833    /// Bounded explanation of this option's role in suspect ranking heuristics.
834    pub description: &'static str,
835    /// Effect summary when this threshold increases, if directional wording applies.
836    pub increasing: Option<&'static str>,
837    /// Effect summary when this threshold decreases, if directional wording applies.
838    pub decreasing: Option<&'static str>,
839}
840impl AnalyzeOptionDescriptor {
841    /// Creates a static descriptor entry for one semantic analyzer option path.
842    #[must_use]
843    pub const fn new(
844        path: &'static str,
845        default_value: &'static str,
846        value_type: &'static str,
847        affects: &'static str,
848        description: &'static str,
849        increasing: Option<&'static str>,
850        decreasing: Option<&'static str>,
851    ) -> Self {
852        Self {
853            path,
854            default_value,
855            value_type,
856            affects,
857            description,
858            increasing,
859            decreasing,
860        }
861    }
862}