Skip to main content

wsi_annotation_tests/matrix/
observation.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::Path;
4
5use serde::Serialize;
6use serde_json::{Value, json};
7
8use crate::compare::ComparisonResult;
9use crate::probe::{ProbeError, ProbeObservation};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum PhaseStatus {
14    Passed,
15    Failed,
16    RejectedAsExpected,
17    Unavailable,
18    TimedOut,
19    ResourceLimited,
20    NotRun,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum MatrixPhase {
26    ReferenceInputNormalization,
27    ReferenceInputComparison,
28    ViewerInspection,
29    InspectComparison,
30    RewriteRequest,
31    RewriteResponse,
32    ReferenceOutputNormalization,
33    ReferenceOutputComparison,
34    OutputComparison,
35    IdentityVerification,
36    MaskMetrics,
37    ArtifactVerification,
38}
39
40impl MatrixPhase {
41    pub(super) const fn label(self) -> &'static str {
42        match self {
43            Self::ReferenceInputNormalization => "reference_input_normalization",
44            Self::ReferenceInputComparison => "reference_input_comparison",
45            Self::ViewerInspection => "viewer_inspection",
46            Self::InspectComparison => "inspect_comparison",
47            Self::RewriteRequest => "rewrite_request",
48            Self::RewriteResponse => "rewrite_response",
49            Self::ReferenceOutputNormalization => "reference_output_normalization",
50            Self::ReferenceOutputComparison => "reference_output_comparison",
51            Self::OutputComparison => "output_comparison",
52            Self::IdentityVerification => "identity_verification",
53            Self::MaskMetrics => "mask_metrics",
54            Self::ArtifactVerification => "artifact_verification",
55        }
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize)]
60pub struct PhaseObservation {
61    pub phase: MatrixPhase,
62    pub status: PhaseStatus,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub command: Option<Vec<String>>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub elapsed_ms: Option<f64>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub peak_rss_bytes: Option<u64>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub output_truncated: Option<bool>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub error_code: Option<String>,
73    pub message: String,
74    pub artifacts: Vec<String>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub details: Option<Value>,
77}
78
79#[derive(Debug, Serialize)]
80pub struct MatrixObservation {
81    pub schema_version: u8,
82    pub case_id: String,
83    pub pyramid_level: u8,
84    pub status: PhaseStatus,
85    pub phases: Vec<PhaseObservation>,
86    pub inspect_equal: bool,
87    pub roundtrip_equal: Option<bool>,
88    pub rewrite_rejected: bool,
89    pub highdicom_readable: bool,
90    pub coordinate_error_max_px: Option<f64>,
91    pub coordinate_error_median_px: Option<f64>,
92    pub coordinate_error_rms_px: Option<f64>,
93    pub dice: Option<f64>,
94    pub mask_metrics: Option<Value>,
95    pub runtime_ms: f64,
96    pub peak_rss_bytes: u64,
97    pub viewer_runtime: BTreeMap<String, Value>,
98    pub peak_tracked_heap_bytes: u64,
99    pub viewer_implementation: Value,
100    pub probe_commands: BTreeMap<String, Vec<String>>,
101    pub input_bytes: u64,
102    pub output_bytes: Option<u64>,
103    pub input_sop_instance_uid: String,
104    pub output_sop_instance_uid: Option<String>,
105    pub message: String,
106}
107
108#[derive(Debug, Serialize)]
109pub struct CoreMatrixResult {
110    pub observations: Vec<MatrixObservation>,
111}
112
113impl CoreMatrixResult {
114    #[must_use]
115    pub fn is_ok(&self) -> bool {
116        !self.observations.is_empty()
117            && self
118                .observations
119                .iter()
120                .all(|observation| observation.status == PhaseStatus::Passed)
121    }
122}
123
124pub(super) struct CaseRecorder {
125    pub(super) observation: MatrixObservation,
126}
127
128impl CaseRecorder {
129    pub(super) fn new(
130        case_id: &str,
131        path: &Path,
132        pyramid_level: u8,
133        expected: Option<&Value>,
134    ) -> Self {
135        let mut observation = failed_observation(case_id, path, pyramid_level, expected);
136        observation.status = PhaseStatus::NotRun;
137        Self { observation }
138    }
139
140    pub(super) fn record(&mut self, phase: PhaseObservation) {
141        if let Some(elapsed_ms) = phase.elapsed_ms {
142            self.observation.runtime_ms += elapsed_ms;
143        }
144        if let Some(peak_rss_bytes) = phase.peak_rss_bytes {
145            self.observation.peak_rss_bytes = self.observation.peak_rss_bytes.max(peak_rss_bytes);
146        }
147        if let Some(command) = &phase.command {
148            self.observation
149                .probe_commands
150                .insert(phase.phase.label().to_owned(), command.clone());
151        }
152        self.observation.phases.push(phase);
153    }
154
155    pub(super) fn pass(&mut self, phase: MatrixPhase) {
156        self.record(PhaseObservation {
157            phase,
158            status: PhaseStatus::Passed,
159            command: None,
160            elapsed_ms: None,
161            peak_rss_bytes: None,
162            output_truncated: None,
163            error_code: None,
164            message: String::new(),
165            artifacts: Vec::new(),
166            details: None,
167        });
168    }
169
170    pub(super) fn comparison(&mut self, phase: MatrixPhase, result: &ComparisonResult) {
171        self.record(PhaseObservation {
172            phase,
173            status: pass_status(result.is_ok()),
174            command: None,
175            elapsed_ms: None,
176            peak_rss_bytes: None,
177            output_truncated: None,
178            error_code: result.findings.first().map(|finding| finding.code.clone()),
179            message: result
180                .findings
181                .iter()
182                .map(|finding| finding.message.as_str())
183                .collect::<Vec<_>>()
184                .join("; "),
185            artifacts: Vec::new(),
186            details: serde_json::to_value(result).ok(),
187        });
188    }
189
190    pub(super) fn probe(
191        &mut self,
192        phase: MatrixPhase,
193        observation: &ProbeObservation,
194        artifact: Option<&Path>,
195    ) {
196        self.observation.viewer_implementation = observation.report["implementation"].clone();
197        self.observation.viewer_runtime.insert(
198            phase.label().to_owned(),
199            observation.report["runtime"].clone(),
200        );
201        self.observation.peak_tracked_heap_bytes = self.observation.peak_tracked_heap_bytes.max(
202            observation
203                .report
204                .pointer("/runtime/peak_tracked_heap_bytes")
205                .and_then(Value::as_u64)
206                .unwrap_or(0),
207        );
208        if let Some(path) = artifact {
209            self.observation.output_bytes = fs::metadata(path).ok().map(|metadata| metadata.len());
210        }
211        self.record(PhaseObservation {
212            phase,
213            status: PhaseStatus::Passed,
214            command: Some(observation.command.clone()),
215            elapsed_ms: Some(observation.elapsed_ms),
216            peak_rss_bytes: observation
217                .rss_sampled
218                .then_some(observation.peak_rss_bytes),
219            output_truncated: Some(observation.stderr_truncated),
220            error_code: None,
221            message: String::new(),
222            artifacts: artifact
223                .map(|path| vec![path.to_string_lossy().into_owned()])
224                .unwrap_or_default(),
225            details: Some(observation.report.clone()),
226        });
227    }
228
229    pub(super) fn fail_probe(mut self, phase: MatrixPhase, error: ProbeError) -> MatrixObservation {
230        let error_code = error
231            .report
232            .as_ref()
233            .and_then(|report| report.pointer("/error/code"))
234            .and_then(Value::as_str)
235            .map(str::to_owned);
236        let output_truncated = error
237            .process
238            .as_ref()
239            .map(|process| process.stdout_truncated || process.stderr_truncated);
240        let message = error.to_string();
241        self.record(PhaseObservation {
242            phase,
243            status: if message.contains("timed out") {
244                PhaseStatus::TimedOut
245            } else {
246                PhaseStatus::Failed
247            },
248            command: error.command.map(|command| *command),
249            elapsed_ms: None,
250            peak_rss_bytes: error.peak_rss_bytes,
251            output_truncated,
252            error_code,
253            message: message.clone(),
254            artifacts: Vec::new(),
255            details: error.report,
256        });
257        self.observation.message = message;
258        self.finish()
259    }
260
261    pub(super) fn fail(mut self, phase: MatrixPhase, message: String) -> MatrixObservation {
262        self.record(PhaseObservation {
263            phase,
264            status: PhaseStatus::Failed,
265            command: None,
266            elapsed_ms: None,
267            peak_rss_bytes: None,
268            output_truncated: None,
269            error_code: None,
270            message: message.clone(),
271            artifacts: Vec::new(),
272            details: None,
273        });
274        self.observation.message = message;
275        self.finish()
276    }
277
278    pub(super) fn finish(mut self) -> MatrixObservation {
279        self.observation.status = derive_case_status(&self.observation.phases);
280        self.observation
281    }
282}
283
284pub(super) fn derive_case_status(phases: &[PhaseObservation]) -> PhaseStatus {
285    if phases
286        .iter()
287        .any(|phase| phase.status == PhaseStatus::Failed)
288    {
289        PhaseStatus::Failed
290    } else if phases
291        .iter()
292        .any(|phase| phase.status == PhaseStatus::TimedOut)
293    {
294        PhaseStatus::TimedOut
295    } else if phases
296        .iter()
297        .any(|phase| phase.status == PhaseStatus::ResourceLimited)
298    {
299        PhaseStatus::ResourceLimited
300    } else if phases
301        .iter()
302        .any(|phase| phase.status == PhaseStatus::Unavailable)
303    {
304        PhaseStatus::Unavailable
305    } else if phases.is_empty()
306        || phases
307            .iter()
308            .all(|phase| phase.status == PhaseStatus::NotRun)
309    {
310        PhaseStatus::NotRun
311    } else {
312        PhaseStatus::Passed
313    }
314}
315
316pub(super) const fn pass_status(passed: bool) -> PhaseStatus {
317    if passed {
318        PhaseStatus::Passed
319    } else {
320        PhaseStatus::Failed
321    }
322}
323
324fn failed_observation(
325    case_id: &str,
326    path: &Path,
327    pyramid_level: u8,
328    expected: Option<&Value>,
329) -> MatrixObservation {
330    MatrixObservation {
331        schema_version: 2,
332        case_id: case_id.to_owned(),
333        pyramid_level,
334        status: PhaseStatus::Failed,
335        phases: Vec::new(),
336        inspect_equal: false,
337        roundtrip_equal: None,
338        rewrite_rejected: false,
339        highdicom_readable: false,
340        coordinate_error_max_px: None,
341        coordinate_error_median_px: None,
342        coordinate_error_rms_px: None,
343        dice: None,
344        mask_metrics: None,
345        runtime_ms: 0.0,
346        peak_rss_bytes: 0,
347        viewer_runtime: BTreeMap::new(),
348        peak_tracked_heap_bytes: 0,
349        viewer_implementation: json!({}),
350        probe_commands: BTreeMap::new(),
351        input_bytes: fs::metadata(path).map_or(0, |metadata| metadata.len()),
352        output_bytes: None,
353        input_sop_instance_uid: expected
354            .and_then(|value| value.get("sop_instance_uid"))
355            .and_then(Value::as_str)
356            .unwrap_or_default()
357            .to_owned(),
358        output_sop_instance_uid: None,
359        message: String::new(),
360    }
361}