Skip to main content

qs_backtest/evaluation/
model.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5/// Describes whether a metric can be interpreted by a consumer.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum MetricStatus {
9    Available,
10    InsufficientData,
11    NotApplicable,
12    InvalidInput,
13}
14
15/// A metric accompanied by an explicit availability status.
16///
17/// Consumers should branch on `status`, rather than assigning a meaning to a
18/// missing value. Available metrics always contain a value; other statuses do
19/// not.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct MetricValue<T> {
22    pub status: MetricStatus,
23    pub value: Option<T>,
24    pub reason: Option<String>,
25}
26
27impl<T> Default for MetricValue<T> {
28    fn default() -> Self {
29        Self::insufficient_data("metric was not present in serialized input")
30    }
31}
32
33impl<T> MetricValue<T> {
34    pub fn available(value: T) -> Self {
35        Self {
36            status: MetricStatus::Available,
37            value: Some(value),
38            reason: None,
39        }
40    }
41
42    pub fn insufficient_data(reason: impl Into<String>) -> Self {
43        Self::unavailable(MetricStatus::InsufficientData, reason)
44    }
45
46    pub fn not_applicable(reason: impl Into<String>) -> Self {
47        Self::unavailable(MetricStatus::NotApplicable, reason)
48    }
49
50    pub fn invalid_input(reason: impl Into<String>) -> Self {
51        Self::unavailable(MetricStatus::InvalidInput, reason)
52    }
53
54    fn unavailable(status: MetricStatus, reason: impl Into<String>) -> Self {
55        Self {
56            status,
57            value: None,
58            reason: Some(reason.into()),
59        }
60    }
61}
62
63/// Normalized position direction, independent of the execution engine's side
64/// type.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum PositionSide {
68    Long,
69    Short,
70}
71
72/// Dimensions used for filtering and deterministic breakdowns.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct PositionDimensions {
75    pub symbol: String,
76    pub side: PositionSide,
77    #[serde(default)]
78    pub group: Option<String>,
79    /// A position may have multiple close reasons after partial closes.
80    #[serde(default)]
81    pub close_reasons: Vec<String>,
82    /// Provider-specific categorical dimensions (setup, session, regime, etc.).
83    #[serde(default)]
84    pub tags: BTreeMap<String, String>,
85}
86
87/// R-normalized maximum favorable and adverse excursion values.
88#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
89pub struct ExcursionInput {
90    #[serde(default)]
91    pub favorable_r: Option<f64>,
92    #[serde(default)]
93    pub adverse_r: Option<f64>,
94}
95
96/// Optional per-position execution observations.
97#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
98pub struct ExecutionDiagnosticsInput {
99    /// Positive values conventionally mean adverse slippage.
100    #[serde(default)]
101    pub slippage_bps: Option<f64>,
102    #[serde(default)]
103    pub latency_ms: Option<f64>,
104    /// Filled quantity divided by requested quantity, normally in `[0, 1]`.
105    #[serde(default)]
106    pub fill_ratio: Option<f64>,
107}
108
109/// Provider-supplied classification of a completed-position outcome.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum OutcomeClassification {
113    Win,
114    Loss,
115    Breakeven,
116}
117
118/// Generic completed-position input for provider evaluation.
119///
120/// `outcome` is deliberately unit-agnostic: it can be account currency, points,
121/// or another consistently applied additive result. `ordinal` defines lifecycle
122/// order for rolling metrics (for example, a close timestamp in milliseconds).
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct PositionOutcome {
125    pub id: String,
126    /// Optional provider or venue identifier associated with this position.
127    #[serde(default)]
128    pub trade_id: Option<String>,
129    pub ordinal: i64,
130    pub dimensions: PositionDimensions,
131    pub outcome: f64,
132    /// Provider classification, allowing the same configured breakeven tolerance
133    /// used during accounting to be preserved. Missing values use exact-zero
134    /// classification for backward compatibility.
135    #[serde(default)]
136    pub outcome_classification: Option<OutcomeClassification>,
137    #[serde(default)]
138    pub r_multiple: Option<f64>,
139    #[serde(default)]
140    pub excursions: Option<ExcursionInput>,
141    #[serde(default)]
142    pub execution: Option<ExecutionDiagnosticsInput>,
143}
144
145impl PositionOutcome {
146    pub fn classification(&self) -> OutcomeClassification {
147        self.outcome_classification.unwrap_or({
148            if self.outcome > 0.0 {
149                OutcomeClassification::Win
150            } else if self.outcome < 0.0 {
151                OutcomeClassification::Loss
152            } else {
153                OutcomeClassification::Breakeven
154            }
155        })
156    }
157}
158
159/// Aggregate lifecycle counters supplied by a provider integration.
160///
161/// These counters are intentionally independent of completed-position rows, so
162/// rejected, expired, or still-open candidates can be represented.
163#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(default)]
165pub struct LifecycleCounts {
166    pub candidates: u64,
167    pub accepted: u64,
168    pub opened: u64,
169    pub completed: u64,
170    pub rejected: u64,
171    /// Accepted pending entries that reached the typed `Filled` terminal state.
172    pub filled: u64,
173    /// Accepted pending entries that reached the typed `Cancelled` terminal state.
174    pub cancelled: u64,
175    /// Accepted pending entries still unfilled when replay ended.
176    pub unfilled_at_end: u64,
177    pub open_at_end: u64,
178}
179
180/// Parser/source coverage supplied by an integration that can observe raw input.
181///
182/// The status counts partition `raw_messages`. Every parsed message emits at
183/// least one signal, and entry signals are a subset of all emitted signals.
184#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(default, deny_unknown_fields)]
186pub struct SourceCoverageCounts {
187    pub raw_messages: u64,
188    pub parsed_messages: u64,
189    pub skipped_messages: u64,
190    pub failed_messages: u64,
191    pub emitted_signals: u64,
192    pub emitted_entry_signals: u64,
193}
194
195impl SourceCoverageCounts {
196    pub fn validation_error(self) -> Option<String> {
197        let Some(classified) = self
198            .parsed_messages
199            .checked_add(self.skipped_messages)
200            .and_then(|count| count.checked_add(self.failed_messages))
201        else {
202            return Some("parsed/skipped/failed message counts overflow u64".into());
203        };
204        if classified != self.raw_messages {
205            return Some(format!(
206                "raw_messages ({}) must equal parsed_messages + skipped_messages + failed_messages ({classified})",
207                self.raw_messages
208            ));
209        }
210        if self.emitted_signals < self.parsed_messages {
211            return Some(format!(
212                "emitted_signals ({}) cannot be less than parsed_messages ({})",
213                self.emitted_signals, self.parsed_messages
214            ));
215        }
216        if self.emitted_entry_signals > self.emitted_signals {
217            return Some(format!(
218                "emitted_entry_signals ({}) cannot exceed emitted_signals ({})",
219                self.emitted_entry_signals, self.emitted_signals
220            ));
221        }
222        None
223    }
224}
225
226/// Selects grouped positions, including positions that have no group.
227#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
228#[serde(rename_all = "snake_case")]
229pub enum GroupFilter {
230    Named(String),
231    Ungrouped,
232}
233
234/// Typed position filter.
235///
236/// Values within one field are ORed. Populated fields (and individual tag keys)
237/// are ANDed with each other. Empty fields impose no constraint.
238#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
239pub struct PositionFilter {
240    #[serde(default)]
241    pub symbols: Vec<String>,
242    #[serde(default)]
243    pub sides: Vec<PositionSide>,
244    #[serde(default)]
245    pub groups: Vec<GroupFilter>,
246    #[serde(default)]
247    pub close_reasons: Vec<String>,
248    /// Each key is a separate dimension. Values for that key are ORed.
249    #[serde(default)]
250    pub tags: BTreeMap<String, Vec<String>>,
251}
252
253impl PositionFilter {
254    pub fn matches(&self, position: &PositionOutcome) -> bool {
255        let dimensions = &position.dimensions;
256
257        let symbol_matches = self.symbols.is_empty()
258            || self
259                .symbols
260                .iter()
261                .any(|symbol| symbol == &dimensions.symbol);
262        let side_matches = self.sides.is_empty() || self.sides.contains(&dimensions.side);
263        let group_matches = self.groups.is_empty()
264            || self.groups.iter().any(|group| match group {
265                GroupFilter::Named(name) => dimensions.group.as_ref() == Some(name),
266                GroupFilter::Ungrouped => dimensions.group.is_none(),
267            });
268        let close_reason_matches = self.close_reasons.is_empty()
269            || self.close_reasons.iter().any(|expected| {
270                dimensions
271                    .close_reasons
272                    .iter()
273                    .any(|actual| actual == expected)
274            });
275        let tags_match = self.tags.iter().all(|(key, accepted_values)| {
276            accepted_values.is_empty()
277                || dimensions
278                    .tags
279                    .get(key)
280                    .is_some_and(|actual| accepted_values.iter().any(|value| value == actual))
281        });
282
283        symbol_matches && side_matches && group_matches && close_reason_matches && tags_match
284    }
285}
286
287/// A requested categorical breakdown. Duplicate requests are evaluated once.
288#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub enum BreakdownDimension {
291    Symbol,
292    Side,
293    Group,
294    CloseReason,
295    Tag(String),
296}
297
298/// Typed and sortable breakdown key.
299#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
300#[serde(rename_all = "snake_case")]
301pub enum BreakdownValue {
302    Text(String),
303    Side(PositionSide),
304    Missing,
305}
306
307/// Configuration for deterministic bootstrap confidence intervals.
308#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
309#[serde(default)]
310pub struct BootstrapConfig {
311    pub samples: usize,
312    pub confidence_level: f64,
313    pub seed: u64,
314    pub minimum_sample_size: usize,
315}
316
317impl Default for BootstrapConfig {
318    fn default() -> Self {
319        Self {
320            samples: 2_000,
321            confidence_level: 0.95,
322            seed: 0xA076_1D64_78BD_642F,
323            minimum_sample_size: 5,
324        }
325    }
326}
327
328/// Provider and source identifiers attached to an evaluation without changing
329/// the normalized position rows.
330#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(default, deny_unknown_fields)]
332pub struct EvaluationContext {
333    pub provider_id: Option<String>,
334    pub source_id: Option<String>,
335}
336
337impl EvaluationContext {
338    pub fn is_empty(&self) -> bool {
339        self.provider_id.is_none() && self.source_id.is_none()
340    }
341}
342
343/// Independently selectable provider-report sections.
344#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
345#[serde(rename_all = "snake_case")]
346pub enum EvaluationSection {
347    Coverage,
348    PositionPerformance,
349    RMetrics,
350    Excursions,
351    Execution,
352    Robustness,
353    Breakdowns,
354}
355
356impl EvaluationSection {
357    pub const ALL: [Self; 7] = [
358        Self::Coverage,
359        Self::PositionPerformance,
360        Self::RMetrics,
361        Self::Excursions,
362        Self::Execution,
363        Self::Robustness,
364        Self::Breakdowns,
365    ];
366
367    pub fn all() -> BTreeSet<Self> {
368        Self::ALL.into_iter().collect()
369    }
370}
371
372fn default_evaluation_sections() -> BTreeSet<EvaluationSection> {
373    EvaluationSection::all()
374}
375
376const fn default_rolling_window() -> usize {
377    20
378}
379
380const fn default_minimum_breakdown_bucket_count() -> usize {
381    1
382}
383
384/// Typed report configuration, deliberately separate from normalized position
385/// and lifecycle inputs.
386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387#[serde(default)]
388pub struct EvaluationOptions {
389    pub context: EvaluationContext,
390    /// Optional parser/source funnel counts. Absence means source coverage was unavailable.
391    pub source_coverage: Option<SourceCoverageCounts>,
392    /// Missing selectors request all sections; an explicit empty set requests none.
393    #[serde(default = "default_evaluation_sections")]
394    pub sections: BTreeSet<EvaluationSection>,
395    pub filter: PositionFilter,
396    pub breakdowns: Vec<BreakdownDimension>,
397    pub bootstrap: BootstrapConfig,
398    /// Number of chronologically ordered completed positions per rolling window.
399    pub rolling_window: usize,
400    /// Buckets with fewer selected positions are omitted before row limiting.
401    pub minimum_breakdown_bucket_count: usize,
402    /// Global deterministic cap across all requested breakdown bucket rows.
403    pub maximum_breakdown_rows: Option<usize>,
404    /// Include normalized rows selected by the same evaluation filter.
405    pub include_position_rows: bool,
406    /// Deterministic cap for included normalized position rows.
407    pub maximum_position_rows: Option<usize>,
408}
409
410impl Default for EvaluationOptions {
411    fn default() -> Self {
412        Self {
413            context: EvaluationContext::default(),
414            source_coverage: None,
415            sections: EvaluationSection::all(),
416            filter: PositionFilter::default(),
417            breakdowns: Vec::new(),
418            bootstrap: BootstrapConfig::default(),
419            rolling_window: default_rolling_window(),
420            minimum_breakdown_bucket_count: default_minimum_breakdown_bucket_count(),
421            maximum_breakdown_rows: None,
422            include_position_rows: false,
423            maximum_position_rows: None,
424        }
425    }
426}
427
428/// Complete input to [`super::evaluate`]. `options` is flattened so payloads
429/// produced before `EvaluationOptions` was introduced retain the same serde shape.
430#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
431pub struct EvaluationRequest {
432    pub positions: Vec<PositionOutcome>,
433    #[serde(default)]
434    pub lifecycle: Option<LifecycleCounts>,
435    #[serde(flatten, default)]
436    pub options: EvaluationOptions,
437}
438
439impl std::ops::Deref for EvaluationRequest {
440    type Target = EvaluationOptions;
441
442    fn deref(&self) -> &Self::Target {
443        &self.options
444    }
445}
446
447impl std::ops::DerefMut for EvaluationRequest {
448    fn deref_mut(&mut self) -> &mut Self::Target {
449        &mut self.options
450    }
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
454pub struct ConfidenceInterval {
455    pub estimate: f64,
456    pub lower: f64,
457    pub upper: f64,
458    pub confidence_level: f64,
459}
460
461#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
462pub struct CoverageSection {
463    pub provided_positions: usize,
464    pub selected_positions: usize,
465    pub filtered_out_positions: usize,
466    pub valid_outcomes: usize,
467    pub invalid_outcomes: usize,
468    /// `None` explicitly means raw parser/source outcomes were unavailable.
469    pub source: Option<SourceCoverageCounts>,
470    pub lifecycle: Option<LifecycleCounts>,
471    pub acceptance_rate: MetricValue<f64>,
472    pub open_rate: MetricValue<f64>,
473    pub completion_rate: MetricValue<f64>,
474    pub r_coverage: MetricValue<f64>,
475    pub excursion_coverage: MetricValue<f64>,
476    pub execution_coverage: MetricValue<f64>,
477}
478
479#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
480pub struct PositionPerformanceSection {
481    pub position_count: usize,
482    pub wins: usize,
483    pub losses: usize,
484    pub breakeven: usize,
485    pub total_outcome: MetricValue<f64>,
486    pub mean_outcome: MetricValue<f64>,
487    pub median_outcome: MetricValue<f64>,
488    pub win_rate: MetricValue<f64>,
489    pub win_rate_confidence: MetricValue<ConfidenceInterval>,
490    pub gross_positive: MetricValue<f64>,
491    pub gross_negative: MetricValue<f64>,
492    pub profit_factor: MetricValue<f64>,
493    pub payoff_ratio: MetricValue<f64>,
494    pub best_outcome: MetricValue<f64>,
495    pub worst_outcome: MetricValue<f64>,
496    pub mean_outcome_confidence: MetricValue<ConfidenceInterval>,
497}
498
499/// Deterministic type-7 quantiles of finite realized-R observations.
500#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
501pub struct RQuantiles {
502    pub p05: f64,
503    pub p10: f64,
504    pub p25: f64,
505    pub p50: f64,
506    pub p75: f64,
507    pub p90: f64,
508    pub p95: f64,
509}
510
511/// One chronologically ordered point on the cumulative realized-R curve.
512#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
513pub struct CumulativeRPoint {
514    pub position_id: String,
515    pub ordinal: i64,
516    pub realized_r: f64,
517    pub cumulative_r: f64,
518}
519
520#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
521pub struct RMetricsSection {
522    pub observed_count: usize,
523    pub missing_or_invalid_count: usize,
524    pub total_r: MetricValue<f64>,
525    pub mean_r: MetricValue<f64>,
526    pub median_r: MetricValue<f64>,
527    pub standard_deviation_r: MetricValue<f64>,
528    pub positive_r_rate: MetricValue<f64>,
529    pub positive_r_rate_confidence: MetricValue<ConfidenceInterval>,
530    pub mean_r_confidence: MetricValue<ConfidenceInterval>,
531    /// Sum of positive R divided by the absolute sum of negative R.
532    #[serde(default)]
533    pub profit_factor: MetricValue<f64>,
534    #[serde(default)]
535    pub average_winner_r: MetricValue<f64>,
536    /// Arithmetic mean of negative R observations (retains its negative sign).
537    #[serde(default)]
538    pub average_loser_r: MetricValue<f64>,
539    #[serde(default)]
540    pub best_r: MetricValue<f64>,
541    #[serde(default)]
542    pub worst_r: MetricValue<f64>,
543    #[serde(default)]
544    pub quantiles: MetricValue<RQuantiles>,
545    /// Ordered by `(ordinal, position_id, realized_r)` for deterministic output.
546    #[serde(default)]
547    pub cumulative_r_curve: MetricValue<Vec<CumulativeRPoint>>,
548    /// Largest peak-to-trough decline on the cumulative realized-R curve.
549    #[serde(default)]
550    pub max_realized_r_drawdown: MetricValue<f64>,
551}
552
553#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
554pub struct ExcursionMetricsSection {
555    pub favorable_observed_count: usize,
556    pub adverse_observed_count: usize,
557    pub mean_favorable_r: MetricValue<f64>,
558    pub median_favorable_r: MetricValue<f64>,
559    pub mean_adverse_r: MetricValue<f64>,
560    pub median_adverse_r: MetricValue<f64>,
561}
562
563#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
564pub struct ExecutionDiagnosticsSection {
565    pub positions_with_diagnostics: usize,
566    pub slippage_observed_count: usize,
567    pub latency_observed_count: usize,
568    pub fill_ratio_observed_count: usize,
569    pub mean_slippage_bps: MetricValue<f64>,
570    pub median_slippage_bps: MetricValue<f64>,
571    pub adverse_slippage_rate: MetricValue<f64>,
572    pub mean_latency_ms: MetricValue<f64>,
573    pub median_latency_ms: MetricValue<f64>,
574    pub mean_fill_ratio: MetricValue<f64>,
575}
576
577#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
578pub struct RemovalImpact {
579    pub removed_count: usize,
580    pub original_total: f64,
581    pub removed_total: f64,
582    pub remaining_total: f64,
583    pub remaining_mean: f64,
584}
585
586#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
587pub struct RollingOutcome {
588    pub start_ordinal: i64,
589    pub end_ordinal: i64,
590    pub position_count: usize,
591    pub total_outcome: f64,
592    pub mean_outcome: f64,
593}
594
595#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
596pub struct RollingOutcomes {
597    pub window_size: usize,
598    pub windows: Vec<RollingOutcome>,
599    pub worst_window_mean: MetricValue<f64>,
600    pub best_window_mean: MetricValue<f64>,
601    pub positive_window_rate: MetricValue<f64>,
602}
603
604/// Shares of gross positive completed-position P&L contributed by the top N winners.
605#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
606#[serde(default)]
607pub struct PnlConcentrationSection {
608    pub top_1: MetricValue<f64>,
609    pub top_3: MetricValue<f64>,
610    pub top_5: MetricValue<f64>,
611    pub top_10: MetricValue<f64>,
612}
613
614#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
615pub struct IntrinsicRobustnessSection {
616    pub best_one_removed: MetricValue<RemovalImpact>,
617    pub best_five_percent_removed: MetricValue<RemovalImpact>,
618    /// Share of gross positive outcome contributed by the best position.
619    pub best_one_positive_concentration: MetricValue<f64>,
620    /// Share of gross positive outcome contributed by the best 5% of positions.
621    pub best_five_percent_positive_concentration: MetricValue<f64>,
622    /// Fixed-count concentration complements the sample-size-relative 5% metric.
623    #[serde(default)]
624    pub pnl_concentration: PnlConcentrationSection,
625    pub rolling_outcomes: RollingOutcomes,
626}
627
628#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
629pub struct BreakdownBucket {
630    pub value: BreakdownValue,
631    pub performance: PositionPerformanceSection,
632    pub r_metrics: RMetricsSection,
633}
634
635#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
636pub struct EvaluationBreakdown {
637    pub dimension: BreakdownDimension,
638    /// Sorted by `BreakdownValue`; this ordering does not depend on hash seeds or
639    /// source position order.
640    pub buckets: Vec<BreakdownBucket>,
641}
642
643/// Visibility into minimum-count filtering and global breakdown row truncation.
644#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
645#[serde(default)]
646pub struct BreakdownRowSummary {
647    pub available_rows: usize,
648    pub included_rows: usize,
649    pub truncated: bool,
650}
651
652impl BreakdownRowSummary {
653    pub fn is_empty(&self) -> bool {
654        self.available_rows == 0 && self.included_rows == 0 && !self.truncated
655    }
656}
657
658/// Filtered normalized position rows included for metric reconciliation.
659#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
660#[serde(default)]
661pub struct EvaluationPositionRows {
662    pub available_rows: usize,
663    pub included_rows: usize,
664    pub truncated: bool,
665    pub rows: Vec<PositionOutcome>,
666}
667
668fn requested_sections_default() -> BTreeSet<EvaluationSection> {
669    EvaluationSection::all()
670}
671
672fn requested_all_sections(sections: &BTreeSet<EvaluationSection>) -> bool {
673    *sections == EvaluationSection::all()
674}
675
676/// Provider-evaluation result. It intentionally has no aggregate score, rank, or
677/// rating; consumers decide which individual sections matter for their use case.
678///
679/// Requested sections serialize exactly as they did before section selection was
680/// introduced. Unrequested sections are omitted and remain `None` when decoded.
681#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
682pub struct EvaluationReport {
683    #[serde(default, skip_serializing_if = "EvaluationContext::is_empty")]
684    pub context: EvaluationContext,
685    #[serde(
686        default = "requested_sections_default",
687        skip_serializing_if = "requested_all_sections"
688    )]
689    pub requested_sections: BTreeSet<EvaluationSection>,
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub coverage: Option<CoverageSection>,
692    #[serde(default, skip_serializing_if = "Option::is_none")]
693    pub position_performance: Option<PositionPerformanceSection>,
694    #[serde(default, skip_serializing_if = "Option::is_none")]
695    pub r_metrics: Option<RMetricsSection>,
696    #[serde(default, skip_serializing_if = "Option::is_none")]
697    pub excursions: Option<ExcursionMetricsSection>,
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    pub execution: Option<ExecutionDiagnosticsSection>,
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub robustness: Option<IntrinsicRobustnessSection>,
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    pub breakdowns: Option<Vec<EvaluationBreakdown>>,
704    #[serde(default, skip_serializing_if = "BreakdownRowSummary::is_empty")]
705    pub breakdown_rows: BreakdownRowSummary,
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub position_rows: Option<EvaluationPositionRows>,
708}