Skip to main content

observer_core/
analytics.rs

1// SPDX-FileCopyrightText: 2026 Alexander R. Croft
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use crate::report::{
5    ActionStatus, ProductStageRecord, ProductStatus, ReportMode, ReportRecord, Status,
6    TelemetryScope, TelemetryValue,
7};
8use crate::cmake::{
9    CmakeCheckFailClass, CmakeCheckRecord, CmakeCheckStatus, CmakeMissingArtifact,
10    CmakeModelReportHeader, CmakeModelReportRecord, CmakeSummaryRecord, CmakeTargetRef,
11};
12use serde::{Deserialize, Serialize};
13use serde_json::{Number, Value};
14use std::collections::{BTreeMap, BTreeSet};
15use std::fmt;
16
17pub type AnalyticsResult<T> = Result<T, AnalyticsError>;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum AnalyticsErrorCode {
21    InvalidJsonl,
22    InvalidReportShape,
23    InvalidCaseIdentity,
24    InvalidTelemetryShape,
25    InvalidBuildMeta,
26    InvalidCubeShape,
27    InvalidCompareShape,
28    InvalidCompareIndexShape,
29    DuplicateBuildId,
30    MissingBuildId,
31    InvalidCompareMeta,
32    UnknownRelationBuild,
33    UnsupportedRelationKind,
34    InvalidCompareCardinality,
35    EmptyCubeDir,
36}
37
38impl AnalyticsErrorCode {
39    pub fn as_str(self) -> &'static str {
40        match self {
41            AnalyticsErrorCode::InvalidJsonl => "invalid_jsonl",
42            AnalyticsErrorCode::InvalidReportShape => "invalid_report_shape",
43            AnalyticsErrorCode::InvalidCaseIdentity => "invalid_case_identity",
44            AnalyticsErrorCode::InvalidTelemetryShape => "invalid_telemetry_shape",
45            AnalyticsErrorCode::InvalidBuildMeta => "invalid_build_meta",
46            AnalyticsErrorCode::InvalidCubeShape => "invalid_cube_shape",
47            AnalyticsErrorCode::InvalidCompareShape => "invalid_compare_shape",
48            AnalyticsErrorCode::InvalidCompareIndexShape => "invalid_compare_index_shape",
49            AnalyticsErrorCode::DuplicateBuildId => "duplicate_build_id",
50            AnalyticsErrorCode::MissingBuildId => "missing_build_id",
51            AnalyticsErrorCode::InvalidCompareMeta => "invalid_compare_meta",
52            AnalyticsErrorCode::UnknownRelationBuild => "unknown_relation_build",
53            AnalyticsErrorCode::UnsupportedRelationKind => "unsupported_relation_kind",
54            AnalyticsErrorCode::InvalidCompareCardinality => "invalid_compare_cardinality",
55            AnalyticsErrorCode::EmptyCubeDir => "empty_cube_dir",
56        }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct AnalyticsError {
62    pub code: AnalyticsErrorCode,
63    pub msg: String,
64}
65
66impl AnalyticsError {
67    pub fn new(code: AnalyticsErrorCode, msg: impl Into<String>) -> Self {
68        Self {
69            code,
70            msg: msg.into(),
71        }
72    }
73}
74
75impl fmt::Display for AnalyticsError {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        write!(f, "{}", self.msg)
78    }
79}
80
81impl std::error::Error for AnalyticsError {}
82
83#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct BuildMeta {
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub branch: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub commit: Option<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub job_id: Option<String>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub pipeline_id: Option<String>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub runner: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub parent_build_id: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub shard_id: Option<String>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub subbuild_id: Option<String>,
102    #[serde(skip_serializing)]
103    pub build_id: Option<String>,
104}
105
106impl BuildMeta {
107    pub fn parse_json(source: &str) -> AnalyticsResult<Self> {
108        let value: Value = serde_json::from_str(source).map_err(|error| {
109            AnalyticsError::new(
110                AnalyticsErrorCode::InvalidBuildMeta,
111                format!("invalid build metadata json: {error}"),
112            )
113        })?;
114        if let Value::Object(map) = &value {
115            for forbidden in ["inventory_sha256", "suite_sha256", "report_mode"] {
116                if map.contains_key(forbidden) {
117                    return Err(AnalyticsError::new(
118                        AnalyticsErrorCode::InvalidBuildMeta,
119                        format!("build metadata must not supply canonical field `{forbidden}`"),
120                    ));
121                }
122            }
123            for (key, item) in map {
124                if !item.is_string() && !item.is_null() {
125                    return Err(AnalyticsError::new(
126                        AnalyticsErrorCode::InvalidBuildMeta,
127                        format!("build metadata field `{key}` must be a string"),
128                    ));
129                }
130            }
131        }
132        serde_json::from_value(value).map_err(|error| {
133            AnalyticsError::new(
134                AnalyticsErrorCode::InvalidBuildMeta,
135                format!("invalid build metadata shape: {error}"),
136            )
137        })
138    }
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize)]
142pub struct BuildCube {
143    pub k: &'static str,
144    pub v: &'static str,
145    pub build: CubeBuild,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub stage: Option<CubeStage>,
148    pub order: CubeOrder,
149    pub dimensions: CubeDimensions,
150    pub facts: CubeFacts,
151    pub measures: CubeMeasures,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize)]
155pub struct CubeBuild {
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub build_id: Option<String>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub inventory_sha256: Option<String>,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub suite_sha256: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub model_sha256: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub report_sha256: Option<String>,
166    pub report_mode: ReportMode,
167    pub meta: BuildMeta,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct CubeStage {
172    pub runner_kind: String,
173    pub status: ProductStatus,
174    pub exit_code: i32,
175    pub required: bool,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub case_pass: Option<usize>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub case_fail: Option<usize>,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub assert_pass: Option<usize>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub assert_fail: Option<usize>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub check_pass: Option<usize>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub check_fail: Option<usize>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub check_validation_fail: Option<usize>,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub check_runner_fail: Option<usize>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
195pub struct CubeOrder {
196    pub actions: &'static str,
197    pub asserts: &'static str,
198    pub cases: &'static str,
199    #[serde(skip_serializing_if = "is_default_report_check_order")]
200    pub cmake_checks: &'static str,
201    pub dimensions: &'static str,
202    pub telemetry: &'static str,
203}
204
205fn is_default_report_check_order(value: &&'static str) -> bool {
206    *value == "report_check_order"
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize)]
210pub struct CubeDimensions {
211    pub action_kind: Vec<String>,
212    pub action_status: Vec<ActionStatus>,
213    pub assert_status: Vec<Status>,
214    #[serde(skip_serializing_if = "Vec::is_empty")]
215    pub branch: Vec<String>,
216    pub case_status: Vec<Status>,
217    #[serde(skip_serializing_if = "Vec::is_empty")]
218    pub cmake_check_fail_class: Vec<CmakeCheckFailClass>,
219    #[serde(skip_serializing_if = "Vec::is_empty")]
220    pub cmake_check_kind: Vec<String>,
221    #[serde(skip_serializing_if = "Vec::is_empty")]
222    pub cmake_check_status: Vec<CmakeCheckStatus>,
223    #[serde(skip_serializing_if = "Vec::is_empty")]
224    pub cmake_configuration_id: Vec<String>,
225    #[serde(skip_serializing_if = "Vec::is_empty")]
226    pub cmake_target_name: Vec<String>,
227    #[serde(skip_serializing_if = "Vec::is_empty")]
228    pub commit: Vec<String>,
229    pub item_id: Vec<String>,
230    #[serde(skip_serializing_if = "Vec::is_empty")]
231    pub job_id: Vec<String>,
232    #[serde(skip_serializing_if = "Vec::is_empty")]
233    pub pipeline_id: Vec<String>,
234    #[serde(skip_serializing_if = "Vec::is_empty")]
235    pub runner: Vec<String>,
236    pub telemetry_kind: Vec<String>,
237    pub telemetry_name: Vec<String>,
238    pub telemetry_unit: Vec<String>,
239    pub test_name: Vec<String>,
240}
241
242#[derive(Debug, Clone, PartialEq, Serialize)]
243pub struct CubeFacts {
244    pub cases: Vec<CubeCaseRow>,
245    pub actions: Vec<CubeActionRow>,
246    pub asserts: Vec<CubeAssertRow>,
247    #[serde(skip_serializing_if = "Vec::is_empty")]
248    pub cmake_checks: Vec<CubeCmakeCheckRow>,
249    pub telemetry: Vec<CubeTelemetryRow>,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct CubeCmakeCheckRow {
254    pub row_ix: usize,
255    pub check_id: String,
256    pub check_kind: String,
257    pub status: CmakeCheckStatus,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub fail_class: Option<CmakeCheckFailClass>,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub msg: Option<String>,
262    #[serde(skip_serializing_if = "Vec::is_empty")]
263    pub checked_targets: Vec<CmakeTargetRef>,
264    #[serde(skip_serializing_if = "Vec::is_empty")]
265    pub missing_artifacts: Vec<CmakeMissingArtifact>,
266}
267
268#[derive(Debug, Clone, PartialEq, Serialize)]
269pub struct CubeCaseRow {
270    pub row_ix: usize,
271    pub case_id: String,
272    pub item_id: String,
273    pub test_name: String,
274    pub status: Status,
275    pub assert_pass: usize,
276    pub assert_fail: usize,
277    pub unhandled_action_fail: usize,
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize)]
281pub struct CubeActionRow {
282    pub row_ix: usize,
283    pub case_id: String,
284    pub item_id: String,
285    pub test_name: String,
286    pub action_ix: usize,
287    pub action: String,
288    pub status: ActionStatus,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub fail_kind: Option<String>,
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub fail_code: Option<String>,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub exit: Option<i64>,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub out_len: Option<u64>,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub err_len: Option<u64>,
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub body_len: Option<u64>,
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub bytes_len: Option<u64>,
303}
304
305#[derive(Debug, Clone, PartialEq, Serialize)]
306pub struct CubeAssertRow {
307    pub row_ix: usize,
308    pub case_id: String,
309    pub item_id: String,
310    pub test_name: String,
311    pub assert_ix: usize,
312    pub status: Status,
313    pub msg: String,
314}
315
316#[derive(Debug, Clone, PartialEq, Serialize)]
317pub struct CubeTelemetryRow {
318    pub row_ix: usize,
319    pub case_id: String,
320    pub item_id: String,
321    pub test_name: String,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub action_ix: Option<usize>,
324    pub scope: TelemetryScope,
325    pub name: String,
326    pub kind: &'static str,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub unit: Option<String>,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub value: Option<Number>,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub values: Option<Vec<Number>>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub tag_value: Option<String>,
335    pub canonical: bool,
336}
337
338#[derive(Debug, Clone, PartialEq, Serialize)]
339pub struct CubeMeasures {
340    pub action_fail: usize,
341    pub action_total: usize,
342    pub assert_fail: usize,
343    pub assert_pass: usize,
344    pub assert_total: usize,
345    pub case_fail: usize,
346    pub case_pass: usize,
347    pub case_total: usize,
348    #[serde(skip_serializing_if = "is_zero")]
349    pub cmake_check_fail: usize,
350    #[serde(skip_serializing_if = "is_zero")]
351    pub cmake_check_pass: usize,
352    #[serde(skip_serializing_if = "is_zero")]
353    pub cmake_check_runner_fail: usize,
354    #[serde(skip_serializing_if = "is_zero")]
355    pub cmake_check_total: usize,
356    #[serde(skip_serializing_if = "is_zero")]
357    pub cmake_check_validation_fail: usize,
358    #[serde(skip_serializing_if = "is_zero")]
359    pub cmake_missing_artifact_total: usize,
360    pub fail_by_action_kind: BTreeMap<String, usize>,
361    pub fail_by_item_id: BTreeMap<String, usize>,
362    pub metric_summary: BTreeMap<String, MetricSummary>,
363    pub telemetry_total: usize,
364}
365
366fn is_zero(value: &usize) -> bool {
367    *value == 0
368}
369
370#[derive(Debug, Clone, PartialEq, Serialize)]
371pub struct MetricSummary {
372    pub avg: f64,
373    pub count: usize,
374    pub max: Number,
375    pub min: Number,
376    pub unit: String,
377}
378
379#[derive(Debug, Clone, PartialEq, Serialize)]
380pub struct BuildCompare {
381    pub k: &'static str,
382    pub v: &'static str,
383    pub builds: Vec<CompareBuildEntry>,
384    pub relations: Vec<BuildRelation>,
385    pub comparisons: CompareTables,
386    pub measures: CompareMeasures,
387}
388
389#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
390pub struct CompareBuildEntry {
391    pub build_id: String,
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub inventory_sha256: Option<String>,
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub suite_sha256: Option<String>,
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub model_sha256: Option<String>,
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub report_sha256: Option<String>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub runner_kind: Option<String>,
402    pub report_mode: ReportMode,
403    pub meta: BuildMeta,
404}
405
406#[derive(Debug, Clone, PartialEq, Serialize)]
407pub struct CompareTables {
408    pub action_fail_change: Vec<ActionFailChangeRow>,
409    pub case_status_change: Vec<CaseStatusChangeRow>,
410    pub metric_delta: Vec<MetricDeltaRow>,
411    pub missing_or_added_tests: Vec<MissingOrAddedTestRow>,
412}
413
414#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
415pub struct ActionFailChangeRow {
416    pub action: String,
417    pub baseline_fail: usize,
418    pub candidate_fail: usize,
419    pub delta: i64,
420    pub item_id: String,
421    pub test_name: String,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
425pub struct CaseStatusChangeRow {
426    pub baseline_status: CompareStatus,
427    pub candidate_status: CompareStatus,
428    pub category: CaseChangeCategory,
429    pub item_id: String,
430    pub test_name: String,
431}
432
433#[derive(Debug, Clone, PartialEq, Serialize)]
434pub struct MetricDeltaRow {
435    pub baseline_value: Number,
436    pub candidate_value: Number,
437    pub delta: Number,
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub delta_ratio: Option<f64>,
440    pub item_id: String,
441    pub name: String,
442    pub test_name: String,
443    pub unit: String,
444}
445
446#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
447pub struct MissingOrAddedTestRow {
448    pub category: PresenceCategory,
449    pub item_id: String,
450    pub test_name: String,
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
454pub struct CompareMeasures {
455    pub added_tests: usize,
456    pub build_count: usize,
457    pub fail_to_pass: usize,
458    pub pair_count: usize,
459    pub pass_to_fail: usize,
460    pub removed_tests: usize,
461}
462
463#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
464pub struct BuildCompareIndex {
465    pub k: &'static str,
466    pub v: &'static str,
467    pub builds: Vec<CompareBuildEntry>,
468    pub relations: Vec<BuildRelation>,
469    pub pairs: Vec<CompareIndexPair>,
470    pub measures: CompareIndexMeasures,
471}
472
473#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
474pub struct CompareIndexPair {
475    pub baseline_build_id: String,
476    pub candidate_build_id: String,
477    pub compare_artifact: String,
478}
479
480#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
481pub struct CompareIndexMeasures {
482    pub build_count: usize,
483    pub pair_count: usize,
484    pub relation_count: usize,
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
488#[serde(rename_all = "snake_case")]
489pub enum CompareStatus {
490    Pass,
491    Fail,
492    Missing,
493}
494
495#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
496#[serde(rename_all = "snake_case")]
497pub enum CaseChangeCategory {
498    PassToPass,
499    PassToFail,
500    FailToPass,
501    FailToFail,
502    Added,
503    Removed,
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
507#[serde(rename_all = "snake_case")]
508pub enum PresenceCategory {
509    Added,
510    Removed,
511}
512
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "snake_case")]
515pub enum RelationKind {
516    Previous,
517    Parent,
518    Child,
519    Shard,
520    Subbuild,
521}
522
523impl RelationKind {
524    fn parse(value: &str) -> AnalyticsResult<Self> {
525        match value {
526            "previous" => Ok(Self::Previous),
527            "parent" => Ok(Self::Parent),
528            "child" => Ok(Self::Child),
529            "shard" => Ok(Self::Shard),
530            "subbuild" => Ok(Self::Subbuild),
531            _ => Err(AnalyticsError::new(
532                AnalyticsErrorCode::UnsupportedRelationKind,
533                format!("unsupported relation_kind `{value}`"),
534            )),
535        }
536    }
537}
538
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540pub struct BuildRelation {
541    pub from_build_id: String,
542    pub relation_kind: RelationKind,
543    pub to_build_id: String,
544}
545
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
547pub struct CompareMeta {
548    pub relations: Vec<BuildRelation>,
549}
550
551pub fn serialize_analytics_json<T: Serialize>(value: &T) -> AnalyticsResult<String> {
552    let mut out = Vec::new();
553    let formatter = serde_json::ser::PrettyFormatter::with_indent(b"\t");
554    let mut serializer = serde_json::Serializer::with_formatter(&mut out, formatter);
555    value.serialize(&mut serializer).map_err(|error| {
556        AnalyticsError::new(
557            AnalyticsErrorCode::InvalidReportShape,
558            format!("failed to serialize analytics artifact: {error}"),
559        )
560    })?;
561    String::from_utf8(out).map_err(|error| {
562        AnalyticsError::new(
563            AnalyticsErrorCode::InvalidReportShape,
564            format!("failed to build analytics json string: {error}"),
565        )
566    })
567}
568
569pub fn derive_cube_from_report(
570    records: &[ReportRecord],
571    build_meta: Option<BuildMeta>,
572) -> AnalyticsResult<BuildCube> {
573    validate_report_shape(records)?;
574
575    let header = records
576        .iter()
577        .find_map(|record| match record {
578            ReportRecord::Header(header) => Some(header.clone()),
579            _ => None,
580        })
581        .ok_or_else(|| AnalyticsError::new(AnalyticsErrorCode::InvalidReportShape, "report missing header record"))?;
582
583    let case_rows = records
584        .iter()
585        .filter_map(|record| match record {
586            ReportRecord::Case(case) => Some(case),
587            _ => None,
588        })
589        .enumerate()
590        .map(|(row_ix, case)| CubeCaseRow {
591            row_ix,
592            case_id: case.case_id.clone(),
593            item_id: case.item_id.clone(),
594            test_name: case.test_name.clone(),
595            status: case.status,
596            assert_pass: case.assert_pass,
597            assert_fail: case.assert_fail,
598            unhandled_action_fail: case.unhandled_action_fail,
599        })
600        .collect::<Vec<_>>();
601
602    let case_lookup = case_rows
603        .iter()
604        .map(|row| (row.case_id.clone(), (row.item_id.clone(), row.test_name.clone())))
605        .collect::<BTreeMap<_, _>>();
606
607    let action_rows = records
608        .iter()
609        .filter_map(|record| match record {
610            ReportRecord::Action(action) => Some(action),
611            _ => None,
612        })
613        .enumerate()
614        .map(|(row_ix, action)| {
615            let (item_id, test_name) = lookup_case_identity(&case_lookup, &action.case_id)?;
616            Ok(CubeActionRow {
617                row_ix,
618                case_id: action.case_id.clone(),
619                item_id,
620                test_name,
621                action_ix: action.action_ix,
622                action: action.action.clone(),
623                status: action.status,
624                fail_kind: action.fail.as_ref().map(|value| value.kind.clone()),
625                fail_code: action.fail.as_ref().and_then(|value| value.code.clone()),
626                exit: json_i64(action.ok.as_ref(), "exit"),
627                out_len: json_u64(action.ok.as_ref(), "out_len"),
628                err_len: json_u64(action.ok.as_ref(), "err_len"),
629                body_len: json_u64(action.ok.as_ref(), "body_len"),
630                bytes_len: json_u64(action.ok.as_ref(), "bytes_len"),
631            })
632        })
633        .collect::<AnalyticsResult<Vec<_>>>()?;
634
635    let assert_rows = records
636        .iter()
637        .filter_map(|record| match record {
638            ReportRecord::Assert(assert) => Some(assert),
639            _ => None,
640        })
641        .enumerate()
642        .map(|(row_ix, assert)| {
643            let (item_id, test_name) = lookup_case_identity(&case_lookup, &assert.case_id)?;
644            Ok(CubeAssertRow {
645                row_ix,
646                case_id: assert.case_id.clone(),
647                item_id,
648                test_name,
649                assert_ix: assert.assert_ix,
650                status: assert.status,
651                msg: assert.msg.clone(),
652            })
653        })
654        .collect::<AnalyticsResult<Vec<_>>>()?;
655
656    let telemetry_rows = records
657        .iter()
658        .filter_map(|record| match record {
659            ReportRecord::Telemetry(telemetry) => Some(telemetry),
660            _ => None,
661        })
662        .enumerate()
663        .map(|(row_ix, telemetry)| {
664            let (item_id, test_name) = lookup_case_identity(&case_lookup, &telemetry.case_id)?;
665            let (kind, value, values, tag_value) = match &telemetry.entry.value {
666                TelemetryValue::Metric { value } => ("metric", Some(value.clone()), None, None),
667                TelemetryValue::Vector { values } => ("vector", None, Some(values.clone()), None),
668                TelemetryValue::Tag { value } => ("tag", None, None, Some(value.clone())),
669            };
670            Ok(CubeTelemetryRow {
671                row_ix,
672                case_id: telemetry.case_id.clone(),
673                item_id,
674                test_name,
675                action_ix: telemetry.action_ix,
676                scope: telemetry.scope,
677                name: telemetry.entry.name.clone(),
678                kind,
679                unit: telemetry.entry.unit.clone(),
680                value,
681                values,
682                tag_value,
683                canonical: telemetry.canonical,
684            })
685        })
686        .collect::<AnalyticsResult<Vec<_>>>()?;
687
688    let measures = derive_measures(&case_rows, &action_rows, &assert_rows, &[], &telemetry_rows)?;
689    let meta = build_meta.unwrap_or_default();
690
691    Ok(BuildCube {
692        k: "observer_build_cube",
693        v: "0",
694        build: CubeBuild {
695            build_id: meta.build_id.clone(),
696            inventory_sha256: Some(header.inventory_sha256),
697            suite_sha256: Some(header.suite_sha256),
698            model_sha256: None,
699            report_sha256: None,
700            report_mode: header.mode,
701            meta: meta.clone(),
702        },
703        stage: None,
704        order: CubeOrder {
705            actions: "report_case_order_then_action_ix",
706            asserts: "report_case_order_then_assert_ix",
707            cases: "report_case_order",
708            cmake_checks: "report_check_order",
709            dimensions: "utf8_lexicographic",
710            telemetry: "report_case_order_then_action_ix_then_record_order",
711        },
712        dimensions: derive_dimensions(&case_rows, &action_rows, &assert_rows, &telemetry_rows, &meta, &[]),
713        facts: CubeFacts {
714            cases: case_rows,
715            actions: action_rows,
716            asserts: assert_rows,
717            cmake_checks: Vec::new(),
718            telemetry: telemetry_rows,
719        },
720        measures,
721    })
722}
723
724pub fn derive_cube_from_cmake_report(
725    records: &[CmakeModelReportRecord],
726    stage: &ProductStageRecord,
727    report_mode: ReportMode,
728    build_meta: Option<BuildMeta>,
729) -> AnalyticsResult<BuildCube> {
730    let (header, summary) = validate_cmake_report_shape(records)?;
731    let meta = build_meta.unwrap_or_default();
732    let cmake_check_rows = records
733        .iter()
734        .filter_map(|record| match record {
735            CmakeModelReportRecord::Check(check) => Some(check),
736            _ => None,
737        })
738        .enumerate()
739        .map(|(row_ix, check)| {
740            Ok(CubeCmakeCheckRow {
741                row_ix,
742                check_id: check.check_id.clone(),
743                check_kind: check.check_kind.clone(),
744                status: check.status,
745                fail_class: check.fail.as_ref().map(|fail| fail.class),
746                msg: check.fail.as_ref().map(|fail| fail.msg.clone()),
747                checked_targets: extract_checked_targets(check)?,
748                missing_artifacts: check
749                    .fail
750                    .as_ref()
751                    .map(|fail| fail.missing_artifacts.clone())
752                    .unwrap_or_default(),
753            })
754        })
755        .collect::<AnalyticsResult<Vec<_>>>()?;
756    let measures = derive_measures(&[], &[], &[], &cmake_check_rows, &[])?;
757
758    if measures.cmake_check_pass != summary.check_pass
759        || measures.cmake_check_fail != summary.check_fail
760        || measures.cmake_check_validation_fail != summary.check_validation_fail
761        || measures.cmake_check_runner_fail != summary.check_runner_fail
762    {
763        return Err(AnalyticsError::new(
764            AnalyticsErrorCode::InvalidReportShape,
765            "cmake summary counters did not match cmake_check rows",
766        ));
767    }
768
769    Ok(BuildCube {
770        k: "observer_build_cube",
771        v: "0",
772        build: CubeBuild {
773            build_id: meta.build_id.clone(),
774            inventory_sha256: stage.child_inventory_sha256.clone(),
775            suite_sha256: None,
776            model_sha256: Some(header.model_sha256.clone()),
777            report_sha256: stage.child_report_sha256.clone(),
778            report_mode,
779            meta: meta.clone(),
780        },
781        stage: Some(cube_stage_from_product_stage(stage)),
782        order: CubeOrder {
783            actions: "report_case_order_then_action_ix",
784            asserts: "report_case_order_then_assert_ix",
785            cases: "report_case_order",
786            cmake_checks: "report_check_order",
787            dimensions: "utf8_lexicographic",
788            telemetry: "report_case_order_then_action_ix_then_record_order",
789        },
790        dimensions: derive_dimensions(&[], &[], &[], &[], &meta, &cmake_check_rows),
791        facts: CubeFacts {
792            cases: Vec::new(),
793            actions: Vec::new(),
794            asserts: Vec::new(),
795            cmake_checks: cmake_check_rows,
796            telemetry: Vec::new(),
797        },
798        measures,
799    })
800}
801
802pub fn apply_product_stage_metadata(cube: &mut BuildCube, stage: &ProductStageRecord) {
803    cube.build.report_sha256 = stage.child_report_sha256.clone();
804    cube.build.model_sha256 = stage.child_model_sha256.clone();
805    cube.stage = Some(cube_stage_from_product_stage(stage));
806}
807
808pub fn derive_cube_from_product_stage(
809    stage: &ProductStageRecord,
810    report_mode: ReportMode,
811    build_meta: Option<BuildMeta>,
812) -> AnalyticsResult<BuildCube> {
813    let meta = build_meta.unwrap_or_default();
814    Ok(BuildCube {
815        k: "observer_build_cube",
816        v: "0",
817        build: CubeBuild {
818            build_id: meta.build_id.clone(),
819            inventory_sha256: stage.child_inventory_sha256.clone(),
820            suite_sha256: stage.child_suite_sha256.clone(),
821            model_sha256: stage.child_model_sha256.clone(),
822            report_sha256: stage.child_report_sha256.clone(),
823            report_mode,
824            meta: meta.clone(),
825        },
826        stage: Some(cube_stage_from_product_stage(stage)),
827        order: CubeOrder {
828            actions: "report_case_order_then_action_ix",
829            asserts: "report_case_order_then_assert_ix",
830            cases: "report_case_order",
831            cmake_checks: "report_check_order",
832            dimensions: "utf8_lexicographic",
833            telemetry: "report_case_order_then_action_ix_then_record_order",
834        },
835        dimensions: empty_cube_dimensions(&meta),
836        facts: CubeFacts {
837            cases: Vec::new(),
838            actions: Vec::new(),
839            asserts: Vec::new(),
840            cmake_checks: Vec::new(),
841            telemetry: Vec::new(),
842        },
843        measures: empty_cube_measures(),
844    })
845}
846
847pub fn parse_build_cube_json(source: &str) -> AnalyticsResult<BuildCube> {
848    let parsed: ParsedBuildCube = serde_json::from_str(source).map_err(|error| {
849        AnalyticsError::new(
850            AnalyticsErrorCode::InvalidCubeShape,
851            format!("invalid cube json: {error}"),
852        )
853    })?;
854    if parsed.k != "observer_build_cube" {
855        return Err(AnalyticsError::new(
856            AnalyticsErrorCode::InvalidCubeShape,
857            format!("unsupported cube kind `{}`", parsed.k),
858        ));
859    }
860    if parsed.v != "0" {
861        return Err(AnalyticsError::new(
862            AnalyticsErrorCode::InvalidCubeShape,
863            format!("unsupported cube version `{}`", parsed.v),
864        ));
865    }
866
867    let build_id = parsed.build.build_id.ok_or_else(|| {
868        AnalyticsError::new(
869            AnalyticsErrorCode::MissingBuildId,
870            "source cube missing build.build_id",
871        )
872    })?;
873
874    let meta = parsed.build.meta;
875    let case_rows = parsed
876        .facts
877        .cases
878        .into_iter()
879        .map(|row| CubeCaseRow {
880            row_ix: row.row_ix,
881            case_id: row.case_id,
882            item_id: row.item_id,
883            test_name: row.test_name,
884            status: row.status,
885            assert_pass: row.assert_pass,
886            assert_fail: row.assert_fail,
887            unhandled_action_fail: row.unhandled_action_fail,
888        })
889        .collect::<Vec<_>>();
890    let action_rows = parsed
891        .facts
892        .actions
893        .into_iter()
894        .map(|row| CubeActionRow {
895            row_ix: row.row_ix,
896            case_id: row.case_id,
897            item_id: row.item_id,
898            test_name: row.test_name,
899            action_ix: row.action_ix,
900            action: row.action,
901            status: row.status,
902            fail_kind: row.fail_kind,
903            fail_code: row.fail_code,
904            exit: row.exit,
905            out_len: row.out_len,
906            err_len: row.err_len,
907            body_len: row.body_len,
908            bytes_len: row.bytes_len,
909        })
910        .collect::<Vec<_>>();
911    let assert_rows = parsed
912        .facts
913        .asserts
914        .into_iter()
915        .map(|row| CubeAssertRow {
916            row_ix: row.row_ix,
917            case_id: row.case_id,
918            item_id: row.item_id,
919            test_name: row.test_name,
920            assert_ix: row.assert_ix,
921            status: row.status,
922            msg: row.msg,
923        })
924        .collect::<Vec<_>>();
925    let telemetry_rows = parsed
926        .facts
927        .telemetry
928        .into_iter()
929        .map(|row| {
930            Ok(CubeTelemetryRow {
931                row_ix: row.row_ix,
932                case_id: row.case_id,
933                item_id: row.item_id,
934                test_name: row.test_name,
935                action_ix: row.action_ix,
936                scope: row.scope,
937                name: row.name,
938                kind: parse_cube_telemetry_kind(&row.kind)?,
939                unit: row.unit,
940                value: row.value,
941                values: row.values,
942                tag_value: row.tag_value,
943                canonical: row.canonical,
944            })
945        })
946        .collect::<AnalyticsResult<Vec<_>>>()?;
947    let cmake_check_rows = parsed.facts.cmake_checks.into_iter().map(|row| CubeCmakeCheckRow {
948        row_ix: row.row_ix,
949        check_id: row.check_id,
950        check_kind: row.check_kind,
951        status: row.status,
952        fail_class: row.fail_class,
953        msg: row.msg,
954        checked_targets: row.checked_targets,
955        missing_artifacts: row.missing_artifacts,
956    }).collect::<Vec<_>>();
957
958    let order = if let Some(order) = parsed.order {
959        CubeOrder {
960            actions: static_cube_order_field(&order.actions)?,
961            asserts: static_cube_order_field(&order.asserts)?,
962            cases: static_cube_order_field(&order.cases)?,
963            cmake_checks: static_cube_order_field(&order.cmake_checks)?,
964            dimensions: static_cube_order_field(&order.dimensions)?,
965            telemetry: static_cube_order_field(&order.telemetry)?,
966        }
967    } else {
968        CubeOrder {
969            actions: "report_case_order_then_action_ix",
970            asserts: "report_case_order_then_assert_ix",
971            cases: "report_case_order",
972            cmake_checks: "report_check_order",
973            dimensions: "utf8_lexicographic",
974            telemetry: "report_case_order_then_action_ix_then_record_order",
975        }
976    };
977
978    let dimensions = if let Some(dimensions) = parsed.dimensions {
979        CubeDimensions {
980            action_kind: dimensions.action_kind,
981            action_status: dimensions.action_status,
982            assert_status: dimensions.assert_status,
983            branch: dimensions.branch,
984            case_status: dimensions.case_status,
985            cmake_check_fail_class: dimensions.cmake_check_fail_class,
986            cmake_check_kind: dimensions.cmake_check_kind,
987            cmake_check_status: dimensions.cmake_check_status,
988            cmake_configuration_id: dimensions.cmake_configuration_id,
989            cmake_target_name: dimensions.cmake_target_name,
990            commit: dimensions.commit,
991            item_id: dimensions.item_id,
992            job_id: dimensions.job_id,
993            pipeline_id: dimensions.pipeline_id,
994            runner: dimensions.runner,
995            telemetry_kind: dimensions.telemetry_kind,
996            telemetry_name: dimensions.telemetry_name,
997            telemetry_unit: dimensions.telemetry_unit,
998            test_name: dimensions.test_name,
999        }
1000    } else {
1001        derive_dimensions(&case_rows, &action_rows, &assert_rows, &telemetry_rows, &meta, &cmake_check_rows)
1002    };
1003
1004    let measures = if let Some(measures) = parsed.measures {
1005        CubeMeasures {
1006            action_fail: measures.action_fail,
1007            action_total: measures.action_total,
1008            assert_fail: measures.assert_fail,
1009            assert_pass: measures.assert_pass,
1010            assert_total: measures.assert_total,
1011            case_fail: measures.case_fail,
1012            case_pass: measures.case_pass,
1013            case_total: measures.case_total,
1014            cmake_check_fail: measures.cmake_check_fail,
1015            cmake_check_pass: measures.cmake_check_pass,
1016            cmake_check_runner_fail: measures.cmake_check_runner_fail,
1017            cmake_check_total: measures.cmake_check_total,
1018            cmake_check_validation_fail: measures.cmake_check_validation_fail,
1019            cmake_missing_artifact_total: measures.cmake_missing_artifact_total,
1020            fail_by_action_kind: measures.fail_by_action_kind,
1021            fail_by_item_id: measures.fail_by_item_id,
1022            metric_summary: measures
1023                .metric_summary
1024                .into_iter()
1025                .map(|(name, summary)| {
1026                    (
1027                        name,
1028                        MetricSummary {
1029                            avg: summary.avg,
1030                            count: summary.count,
1031                            max: summary.max,
1032                            min: summary.min,
1033                            unit: summary.unit,
1034                        },
1035                    )
1036                })
1037                .collect(),
1038            telemetry_total: measures.telemetry_total,
1039        }
1040    } else {
1041        derive_measures(&case_rows, &action_rows, &assert_rows, &cmake_check_rows, &telemetry_rows)?
1042    };
1043
1044    Ok(BuildCube {
1045        k: "observer_build_cube",
1046        v: "0",
1047        build: CubeBuild {
1048            build_id: Some(build_id),
1049            inventory_sha256: parsed.build.inventory_sha256,
1050            suite_sha256: parsed.build.suite_sha256,
1051            model_sha256: parsed.build.model_sha256,
1052            report_sha256: parsed.build.report_sha256,
1053            report_mode: parsed.build.report_mode,
1054            meta,
1055        },
1056        stage: parsed.stage.map(|stage| CubeStage {
1057            runner_kind: stage.runner_kind,
1058            status: stage.status,
1059            exit_code: stage.exit_code,
1060            required: stage.required,
1061            case_pass: stage.case_pass,
1062            case_fail: stage.case_fail,
1063            assert_pass: stage.assert_pass,
1064            assert_fail: stage.assert_fail,
1065            check_pass: stage.check_pass,
1066            check_fail: stage.check_fail,
1067            check_validation_fail: stage.check_validation_fail,
1068            check_runner_fail: stage.check_runner_fail,
1069        }),
1070        order,
1071        dimensions,
1072        facts: CubeFacts {
1073            cases: case_rows,
1074            actions: action_rows,
1075            asserts: assert_rows,
1076            cmake_checks: cmake_check_rows,
1077            telemetry: telemetry_rows,
1078        },
1079        measures,
1080    })
1081}
1082
1083pub fn parse_compare_meta_json(source: &str) -> AnalyticsResult<CompareMeta> {
1084    let value: Value = serde_json::from_str(source).map_err(|error| {
1085        AnalyticsError::new(
1086            AnalyticsErrorCode::InvalidCompareMeta,
1087            format!("invalid compare metadata json: {error}"),
1088        )
1089    })?;
1090    let object = value.as_object().ok_or_else(|| {
1091        AnalyticsError::new(
1092            AnalyticsErrorCode::InvalidCompareMeta,
1093            "compare metadata must be a json object",
1094        )
1095    })?;
1096    if object.len() != 1 || !object.contains_key("relations") {
1097        return Err(AnalyticsError::new(
1098            AnalyticsErrorCode::InvalidCompareMeta,
1099            "compare metadata must contain only `relations`",
1100        ));
1101    }
1102    let relations = object
1103        .get("relations")
1104        .and_then(Value::as_array)
1105        .ok_or_else(|| {
1106            AnalyticsError::new(
1107                AnalyticsErrorCode::InvalidCompareMeta,
1108                "compare metadata `relations` must be an array",
1109            )
1110        })?;
1111
1112    let mut parsed_relations = Vec::with_capacity(relations.len());
1113    for relation in relations {
1114        let relation_object = relation.as_object().ok_or_else(|| {
1115            AnalyticsError::new(
1116                AnalyticsErrorCode::InvalidCompareMeta,
1117                "relation row must be a json object",
1118            )
1119        })?;
1120        if relation_object.len() != 3 {
1121            return Err(AnalyticsError::new(
1122                AnalyticsErrorCode::InvalidCompareMeta,
1123                "relation row must contain exactly from_build_id, relation_kind, and to_build_id",
1124            ));
1125        }
1126        let from_build_id = relation_object
1127            .get("from_build_id")
1128            .and_then(Value::as_str)
1129            .ok_or_else(|| {
1130                AnalyticsError::new(
1131                    AnalyticsErrorCode::InvalidCompareMeta,
1132                    "relation row missing string from_build_id",
1133                )
1134            })?;
1135        let to_build_id = relation_object
1136            .get("to_build_id")
1137            .and_then(Value::as_str)
1138            .ok_or_else(|| {
1139                AnalyticsError::new(
1140                    AnalyticsErrorCode::InvalidCompareMeta,
1141                    "relation row missing string to_build_id",
1142                )
1143            })?;
1144        let relation_kind = relation_object
1145            .get("relation_kind")
1146            .and_then(Value::as_str)
1147            .ok_or_else(|| {
1148                AnalyticsError::new(
1149                    AnalyticsErrorCode::InvalidCompareMeta,
1150                    "relation row missing string relation_kind",
1151                )
1152            })?;
1153
1154        parsed_relations.push(BuildRelation {
1155            from_build_id: from_build_id.to_owned(),
1156            relation_kind: RelationKind::parse(relation_kind)?,
1157            to_build_id: to_build_id.to_owned(),
1158        });
1159    }
1160
1161    Ok(CompareMeta {
1162        relations: parsed_relations,
1163    })
1164}
1165
1166pub fn parse_build_compare_json(source: &str) -> AnalyticsResult<BuildCompare> {
1167    let parsed: ParsedBuildCompare = serde_json::from_str(source).map_err(|error| {
1168        AnalyticsError::new(
1169            AnalyticsErrorCode::InvalidCompareShape,
1170            format!("invalid compare json: {error}"),
1171        )
1172    })?;
1173    if parsed.k != "observer_build_compare" {
1174        return Err(AnalyticsError::new(
1175            AnalyticsErrorCode::InvalidCompareShape,
1176            format!("unsupported compare kind `{}`", parsed.k),
1177        ));
1178    }
1179    if parsed.v != "0" {
1180        return Err(AnalyticsError::new(
1181            AnalyticsErrorCode::InvalidCompareShape,
1182            format!("unsupported compare version `{}`", parsed.v),
1183        ));
1184    }
1185    Ok(BuildCompare {
1186        k: "observer_build_compare",
1187        v: "0",
1188        builds: parsed
1189            .builds
1190            .into_iter()
1191            .map(|build| CompareBuildEntry {
1192                build_id: build.build_id,
1193                inventory_sha256: build.inventory_sha256,
1194                suite_sha256: build.suite_sha256,
1195                model_sha256: build.model_sha256,
1196                report_sha256: build.report_sha256,
1197                runner_kind: build.runner_kind,
1198                report_mode: build.report_mode,
1199                meta: build.meta,
1200            })
1201            .collect(),
1202        relations: parsed
1203            .relations
1204            .into_iter()
1205            .map(|relation| BuildRelation {
1206                from_build_id: relation.from_build_id,
1207                relation_kind: relation.relation_kind,
1208                to_build_id: relation.to_build_id,
1209            })
1210            .collect(),
1211        comparisons: CompareTables {
1212            action_fail_change: parsed
1213                .comparisons
1214                .action_fail_change
1215                .into_iter()
1216                .map(|row| ActionFailChangeRow {
1217                    action: row.action,
1218                    baseline_fail: row.baseline_fail,
1219                    candidate_fail: row.candidate_fail,
1220                    delta: row.delta,
1221                    item_id: row.item_id,
1222                    test_name: row.test_name,
1223                })
1224                .collect(),
1225            case_status_change: parsed
1226                .comparisons
1227                .case_status_change
1228                .into_iter()
1229                .map(|row| CaseStatusChangeRow {
1230                    baseline_status: row.baseline_status,
1231                    candidate_status: row.candidate_status,
1232                    category: row.category,
1233                    item_id: row.item_id,
1234                    test_name: row.test_name,
1235                })
1236                .collect(),
1237            metric_delta: parsed
1238                .comparisons
1239                .metric_delta
1240                .into_iter()
1241                .map(|row| MetricDeltaRow {
1242                    baseline_value: row.baseline_value,
1243                    candidate_value: row.candidate_value,
1244                    delta: row.delta,
1245                    delta_ratio: row.delta_ratio,
1246                    item_id: row.item_id,
1247                    name: row.name,
1248                    test_name: row.test_name,
1249                    unit: row.unit,
1250                })
1251                .collect(),
1252            missing_or_added_tests: parsed
1253                .comparisons
1254                .missing_or_added_tests
1255                .into_iter()
1256                .map(|row| MissingOrAddedTestRow {
1257                    category: row.category,
1258                    item_id: row.item_id,
1259                    test_name: row.test_name,
1260                })
1261                .collect(),
1262        },
1263        measures: CompareMeasures {
1264            added_tests: parsed.measures.added_tests,
1265            build_count: parsed.measures.build_count,
1266            fail_to_pass: parsed.measures.fail_to_pass,
1267            pair_count: parsed.measures.pair_count,
1268            pass_to_fail: parsed.measures.pass_to_fail,
1269            removed_tests: parsed.measures.removed_tests,
1270        },
1271    })
1272}
1273
1274pub fn parse_build_compare_index_json(source: &str) -> AnalyticsResult<BuildCompareIndex> {
1275    let parsed: ParsedBuildCompareIndex = serde_json::from_str(source).map_err(|error| {
1276        AnalyticsError::new(
1277            AnalyticsErrorCode::InvalidCompareIndexShape,
1278            format!("invalid compare-index json: {error}"),
1279        )
1280    })?;
1281    if parsed.k != "observer_build_compare_index" {
1282        return Err(AnalyticsError::new(
1283            AnalyticsErrorCode::InvalidCompareIndexShape,
1284            format!("unsupported compare-index kind `{}`", parsed.k),
1285        ));
1286    }
1287    if parsed.v != "0" {
1288        return Err(AnalyticsError::new(
1289            AnalyticsErrorCode::InvalidCompareIndexShape,
1290            format!("unsupported compare-index version `{}`", parsed.v),
1291        ));
1292    }
1293    Ok(BuildCompareIndex {
1294        k: "observer_build_compare_index",
1295        v: "0",
1296        builds: parsed
1297            .builds
1298            .into_iter()
1299            .map(|build| CompareBuildEntry {
1300                build_id: build.build_id,
1301                inventory_sha256: build.inventory_sha256,
1302                suite_sha256: build.suite_sha256,
1303                model_sha256: build.model_sha256,
1304                report_sha256: build.report_sha256,
1305                runner_kind: build.runner_kind,
1306                report_mode: build.report_mode,
1307                meta: build.meta,
1308            })
1309            .collect(),
1310        relations: parsed
1311            .relations
1312            .into_iter()
1313            .map(|relation| BuildRelation {
1314                from_build_id: relation.from_build_id,
1315                relation_kind: relation.relation_kind,
1316                to_build_id: relation.to_build_id,
1317            })
1318            .collect(),
1319        pairs: parsed
1320            .pairs
1321            .into_iter()
1322            .map(|pair| CompareIndexPair {
1323                baseline_build_id: pair.baseline_build_id,
1324                candidate_build_id: pair.candidate_build_id,
1325                compare_artifact: pair.compare_artifact,
1326            })
1327            .collect(),
1328        measures: CompareIndexMeasures {
1329            build_count: parsed.measures.build_count,
1330            pair_count: parsed.measures.pair_count,
1331            relation_count: parsed.measures.relation_count,
1332        },
1333    })
1334}
1335
1336pub fn derive_compare_pair(
1337    baseline: &BuildCube,
1338    candidate: &BuildCube,
1339    compare_meta: Option<CompareMeta>,
1340) -> AnalyticsResult<BuildCompare> {
1341    let baseline_id = baseline.build.build_id.clone().ok_or_else(|| {
1342        AnalyticsError::new(
1343            AnalyticsErrorCode::MissingBuildId,
1344            "baseline cube missing build.build_id",
1345        )
1346    })?;
1347    let candidate_id = candidate.build.build_id.clone().ok_or_else(|| {
1348        AnalyticsError::new(
1349            AnalyticsErrorCode::MissingBuildId,
1350            "candidate cube missing build.build_id",
1351        )
1352    })?;
1353    if baseline_id == candidate_id {
1354        return Err(AnalyticsError::new(
1355            AnalyticsErrorCode::DuplicateBuildId,
1356            format!("pairwise comparison requires distinct build ids, found `{baseline_id}` twice"),
1357        ));
1358    }
1359
1360    let builds = vec![
1361        CompareBuildEntry {
1362            build_id: baseline_id.clone(),
1363            inventory_sha256: baseline.build.inventory_sha256.clone(),
1364            suite_sha256: baseline.build.suite_sha256.clone(),
1365            model_sha256: baseline.build.model_sha256.clone(),
1366            report_sha256: baseline.build.report_sha256.clone(),
1367            runner_kind: baseline.stage.as_ref().map(|stage| stage.runner_kind.clone()),
1368            report_mode: baseline.build.report_mode,
1369            meta: baseline.build.meta.clone(),
1370        },
1371        CompareBuildEntry {
1372            build_id: candidate_id.clone(),
1373            inventory_sha256: candidate.build.inventory_sha256.clone(),
1374            suite_sha256: candidate.build.suite_sha256.clone(),
1375            model_sha256: candidate.build.model_sha256.clone(),
1376            report_sha256: candidate.build.report_sha256.clone(),
1377            runner_kind: candidate.stage.as_ref().map(|stage| stage.runner_kind.clone()),
1378            report_mode: candidate.build.report_mode,
1379            meta: candidate.build.meta.clone(),
1380        },
1381    ];
1382
1383    let mut relations = compare_meta.map(|meta| meta.relations).unwrap_or_default();
1384    let known_build_ids = [baseline_id.clone(), candidate_id.clone()]
1385        .into_iter()
1386        .collect::<BTreeSet<_>>();
1387    for relation in &relations {
1388        if !known_build_ids.contains(&relation.from_build_id)
1389            || !known_build_ids.contains(&relation.to_build_id)
1390        {
1391            return Err(AnalyticsError::new(
1392                AnalyticsErrorCode::UnknownRelationBuild,
1393                "relation metadata references build id not present in input cubes",
1394            ));
1395        }
1396    }
1397    relations.sort_by(|left, right| {
1398        left.from_build_id
1399            .cmp(&right.from_build_id)
1400            .then(relation_kind_key(left.relation_kind).cmp(relation_kind_key(right.relation_kind)))
1401            .then(left.to_build_id.cmp(&right.to_build_id))
1402    });
1403
1404    let baseline_cases = baseline
1405        .facts
1406        .cases
1407        .iter()
1408        .map(|row| ((row.item_id.clone(), row.test_name.clone()), row.status))
1409        .collect::<BTreeMap<_, _>>();
1410    let candidate_cases = candidate
1411        .facts
1412        .cases
1413        .iter()
1414        .map(|row| ((row.item_id.clone(), row.test_name.clone()), row.status))
1415        .collect::<BTreeMap<_, _>>();
1416
1417    let mut all_case_keys = BTreeSet::new();
1418    all_case_keys.extend(baseline_cases.keys().cloned());
1419    all_case_keys.extend(candidate_cases.keys().cloned());
1420
1421    let mut case_status_change = Vec::new();
1422    let mut missing_or_added_tests = Vec::new();
1423    let mut pass_to_fail = 0usize;
1424    let mut fail_to_pass = 0usize;
1425    let mut added_tests = 0usize;
1426    let mut removed_tests = 0usize;
1427
1428    for (item_id, test_name) in all_case_keys {
1429        let baseline_status = baseline_cases.get(&(item_id.clone(), test_name.clone())).copied();
1430        let candidate_status = candidate_cases.get(&(item_id.clone(), test_name.clone())).copied();
1431        let baseline_compare = compare_status(baseline_status);
1432        let candidate_compare = compare_status(candidate_status);
1433        let category = classify_case_change(baseline_compare, candidate_compare);
1434
1435        match category {
1436            CaseChangeCategory::PassToFail => pass_to_fail += 1,
1437            CaseChangeCategory::FailToPass => fail_to_pass += 1,
1438            CaseChangeCategory::Added => {
1439                added_tests += 1;
1440                missing_or_added_tests.push(MissingOrAddedTestRow {
1441                    category: PresenceCategory::Added,
1442                    item_id: item_id.clone(),
1443                    test_name: test_name.clone(),
1444                });
1445            }
1446            CaseChangeCategory::Removed => {
1447                removed_tests += 1;
1448                missing_or_added_tests.push(MissingOrAddedTestRow {
1449                    category: PresenceCategory::Removed,
1450                    item_id: item_id.clone(),
1451                    test_name: test_name.clone(),
1452                });
1453            }
1454            CaseChangeCategory::PassToPass | CaseChangeCategory::FailToFail => {}
1455        }
1456
1457        case_status_change.push(CaseStatusChangeRow {
1458            baseline_status: baseline_compare,
1459            candidate_status: candidate_compare,
1460            category,
1461            item_id,
1462            test_name,
1463        });
1464    }
1465
1466    let baseline_action_fails = aggregate_action_failures(&baseline.facts.actions);
1467    let candidate_action_fails = aggregate_action_failures(&candidate.facts.actions);
1468    let mut action_keys = BTreeSet::new();
1469    action_keys.extend(baseline_action_fails.keys().cloned());
1470    action_keys.extend(candidate_action_fails.keys().cloned());
1471    let mut action_fail_change = Vec::new();
1472    for (item_id, test_name, action) in action_keys {
1473        let baseline_fail = *baseline_action_fails
1474            .get(&(item_id.clone(), test_name.clone(), action.clone()))
1475            .unwrap_or(&0);
1476        let candidate_fail = *candidate_action_fails
1477            .get(&(item_id.clone(), test_name.clone(), action.clone()))
1478            .unwrap_or(&0);
1479        if baseline_fail == candidate_fail {
1480            continue;
1481        }
1482        action_fail_change.push(ActionFailChangeRow {
1483            action,
1484            baseline_fail,
1485            candidate_fail,
1486            delta: candidate_fail as i64 - baseline_fail as i64,
1487            item_id,
1488            test_name,
1489        });
1490    }
1491
1492    let baseline_metrics = aggregate_metric_values(&baseline.facts.telemetry)?;
1493    let candidate_metrics = aggregate_metric_values(&candidate.facts.telemetry)?;
1494    let mut metric_keys = BTreeSet::new();
1495    metric_keys.extend(baseline_metrics.keys().cloned());
1496    metric_keys.extend(candidate_metrics.keys().cloned());
1497    let mut metric_delta = Vec::new();
1498    for (item_id, test_name, name, unit) in metric_keys {
1499        let Some(baseline_value) = baseline_metrics
1500            .get(&(item_id.clone(), test_name.clone(), name.clone(), unit.clone()))
1501            .cloned()
1502        else {
1503            continue;
1504        };
1505        let Some(candidate_value) = candidate_metrics
1506            .get(&(item_id.clone(), test_name.clone(), name.clone(), unit.clone()))
1507            .cloned()
1508        else {
1509            continue;
1510        };
1511        let baseline_f64 = number_to_f64(&baseline_value)?;
1512        let candidate_f64 = number_to_f64(&candidate_value)?;
1513        let delta_f64 = candidate_f64 - baseline_f64;
1514        if delta_f64 == 0.0 {
1515            continue;
1516        }
1517        metric_delta.push(MetricDeltaRow {
1518            baseline_value,
1519            candidate_value,
1520            delta: number_from_f64(delta_f64)?,
1521            delta_ratio: if baseline_f64 == 0.0 {
1522                None
1523            } else {
1524                Some(delta_f64 / baseline_f64)
1525            },
1526            item_id,
1527            name,
1528            test_name,
1529            unit,
1530        });
1531    }
1532
1533    Ok(BuildCompare {
1534        k: "observer_build_compare",
1535        v: "0",
1536        builds,
1537        relations,
1538        comparisons: CompareTables {
1539            action_fail_change,
1540            case_status_change,
1541            metric_delta,
1542            missing_or_added_tests,
1543        },
1544        measures: CompareMeasures {
1545            added_tests,
1546            build_count: 2,
1547            fail_to_pass,
1548            pair_count: 1,
1549            pass_to_fail,
1550            removed_tests,
1551        },
1552    })
1553}
1554
1555pub fn derive_compare_index(
1556    cubes: &[BuildCube],
1557    compare_meta: Option<CompareMeta>,
1558) -> AnalyticsResult<BuildCompareIndex> {
1559    if cubes.is_empty() {
1560        return Err(AnalyticsError::new(
1561            AnalyticsErrorCode::EmptyCubeDir,
1562            "compare directory contained no cube artifacts",
1563        ));
1564    }
1565
1566    let mut builds = cubes
1567        .iter()
1568        .map(|cube| {
1569            let build_id = cube.build.build_id.clone().ok_or_else(|| {
1570                AnalyticsError::new(
1571                    AnalyticsErrorCode::MissingBuildId,
1572                    "source cube missing build.build_id",
1573                )
1574            })?;
1575            Ok(CompareBuildEntry {
1576                build_id,
1577                inventory_sha256: cube.build.inventory_sha256.clone(),
1578                suite_sha256: cube.build.suite_sha256.clone(),
1579                model_sha256: cube.build.model_sha256.clone(),
1580                report_sha256: cube.build.report_sha256.clone(),
1581                runner_kind: cube.stage.as_ref().map(|stage| stage.runner_kind.clone()),
1582                report_mode: cube.build.report_mode,
1583                meta: cube.build.meta.clone(),
1584            })
1585        })
1586        .collect::<AnalyticsResult<Vec<_>>>()?;
1587    builds.sort_by(|left, right| left.build_id.cmp(&right.build_id));
1588
1589    for pair in builds.windows(2) {
1590        if pair[0].build_id == pair[1].build_id {
1591            return Err(AnalyticsError::new(
1592                AnalyticsErrorCode::DuplicateBuildId,
1593                format!("duplicate build id `{}` in compare index input", pair[0].build_id),
1594            ));
1595        }
1596    }
1597
1598    let build_ids = builds
1599        .iter()
1600        .map(|build| build.build_id.clone())
1601        .collect::<BTreeSet<_>>();
1602    let mut relations = compare_meta.map(|meta| meta.relations).unwrap_or_default();
1603    for relation in &relations {
1604        if !build_ids.contains(&relation.from_build_id) || !build_ids.contains(&relation.to_build_id) {
1605            return Err(AnalyticsError::new(
1606                AnalyticsErrorCode::UnknownRelationBuild,
1607                "relation metadata references build id not present in input cubes",
1608            ));
1609        }
1610    }
1611    relations.sort_by(|left, right| {
1612        left.from_build_id
1613            .cmp(&right.from_build_id)
1614            .then(relation_kind_key(left.relation_kind).cmp(relation_kind_key(right.relation_kind)))
1615            .then(left.to_build_id.cmp(&right.to_build_id))
1616    });
1617
1618    let previous_pairs = relations
1619        .iter()
1620        .filter(|relation| relation.relation_kind == RelationKind::Previous)
1621        .map(|relation| (relation.from_build_id.clone(), relation.to_build_id.clone()))
1622        .collect::<Vec<_>>();
1623
1624    let pair_keys = if previous_pairs.is_empty() {
1625        builds
1626            .windows(2)
1627            .map(|pair| (pair[0].build_id.clone(), pair[1].build_id.clone()))
1628            .collect::<Vec<_>>()
1629    } else {
1630        previous_pairs
1631    };
1632
1633    let pairs = pair_keys
1634        .into_iter()
1635        .map(|(baseline_build_id, candidate_build_id)| CompareIndexPair {
1636            compare_artifact: format!(
1637                "compare-{}-{}.json",
1638                compare_artifact_build_component(&baseline_build_id),
1639                compare_artifact_build_component(&candidate_build_id)
1640            ),
1641            baseline_build_id,
1642            candidate_build_id,
1643        })
1644        .collect::<Vec<_>>();
1645
1646    let relation_count = relations.len();
1647
1648    Ok(BuildCompareIndex {
1649        k: "observer_build_compare_index",
1650        v: "0",
1651        builds,
1652        relations,
1653        measures: CompareIndexMeasures {
1654            build_count: cubes.len(),
1655            pair_count: pairs.len(),
1656            relation_count,
1657        },
1658        pairs,
1659    })
1660}
1661
1662fn validate_report_shape(records: &[ReportRecord]) -> AnalyticsResult<()> {
1663    let header_count = records.iter().filter(|record| matches!(record, ReportRecord::Header(_))).count();
1664    let summary_count = records.iter().filter(|record| matches!(record, ReportRecord::Summary(_))).count();
1665    if header_count != 1 {
1666        return Err(AnalyticsError::new(
1667            AnalyticsErrorCode::InvalidReportShape,
1668            format!("report must contain exactly one header record, found {header_count}"),
1669        ));
1670    }
1671    if summary_count != 1 {
1672        return Err(AnalyticsError::new(
1673            AnalyticsErrorCode::InvalidReportShape,
1674            format!("report must contain exactly one final summary record, found {summary_count}"),
1675        ));
1676    }
1677    if !matches!(records.first(), Some(ReportRecord::Header(_))) {
1678        return Err(AnalyticsError::new(
1679            AnalyticsErrorCode::InvalidReportShape,
1680            "report must begin with header record",
1681        ));
1682    }
1683    if !matches!(records.last(), Some(ReportRecord::Summary(_))) {
1684        return Err(AnalyticsError::new(
1685            AnalyticsErrorCode::InvalidReportShape,
1686            "report missing final summary record",
1687        ));
1688    }
1689    Ok(())
1690}
1691
1692fn lookup_case_identity(
1693    lookup: &BTreeMap<String, (String, String)>,
1694    case_id: &str,
1695) -> AnalyticsResult<(String, String)> {
1696    lookup
1697        .get(case_id)
1698        .cloned()
1699        .ok_or_else(|| AnalyticsError::new(AnalyticsErrorCode::InvalidCaseIdentity, format!("unknown case id `{case_id}`")))
1700}
1701
1702fn json_i64(value: Option<&Value>, key: &str) -> Option<i64> {
1703    value?.get(key)?.as_i64()
1704}
1705
1706fn json_u64(value: Option<&Value>, key: &str) -> Option<u64> {
1707    value?.get(key)?.as_u64()
1708}
1709
1710fn derive_dimensions(
1711    cases: &[CubeCaseRow],
1712    actions: &[CubeActionRow],
1713    asserts: &[CubeAssertRow],
1714    telemetry: &[CubeTelemetryRow],
1715    meta: &BuildMeta,
1716    cmake_checks: &[CubeCmakeCheckRow],
1717) -> CubeDimensions {
1718    CubeDimensions {
1719        action_kind: unique_sorted(actions.iter().map(|row| row.action.clone())),
1720        action_status: unique_sorted_by(actions.iter().map(|row| row.status), action_status_key),
1721        assert_status: unique_sorted_by(asserts.iter().map(|row| row.status), status_key),
1722        branch: optional_dimension(meta.branch.clone()),
1723        case_status: unique_sorted_by(cases.iter().map(|row| row.status), status_key),
1724        cmake_check_fail_class: unique_sorted_by(
1725            cmake_checks.iter().filter_map(|row| row.fail_class),
1726            cmake_fail_class_key,
1727        ),
1728        cmake_check_kind: unique_sorted(cmake_checks.iter().map(|row| row.check_kind.clone())),
1729        cmake_check_status: unique_sorted_by(
1730            cmake_checks.iter().map(|row| row.status),
1731            cmake_check_status_key,
1732        ),
1733        cmake_configuration_id: unique_sorted(cmake_checks.iter().flat_map(|row| {
1734            row.checked_targets
1735                .iter()
1736                .map(|target| target.configuration_id.clone())
1737                .chain(row.missing_artifacts.iter().map(|artifact| artifact.target.configuration_id.clone()))
1738        })),
1739        cmake_target_name: unique_sorted(cmake_checks.iter().flat_map(|row| {
1740            row.checked_targets
1741                .iter()
1742                .map(|target| target.target_name.clone())
1743                .chain(row.missing_artifacts.iter().map(|artifact| artifact.target.target_name.clone()))
1744        })),
1745        commit: optional_dimension(meta.commit.clone()),
1746        item_id: unique_sorted(cases.iter().map(|row| row.item_id.clone())),
1747        job_id: optional_dimension(meta.job_id.clone()),
1748        pipeline_id: optional_dimension(meta.pipeline_id.clone()),
1749        runner: optional_dimension(meta.runner.clone()),
1750        telemetry_kind: unique_sorted(telemetry.iter().map(|row| row.kind.to_owned())),
1751        telemetry_name: unique_sorted(telemetry.iter().map(|row| row.name.clone())),
1752        telemetry_unit: unique_sorted(telemetry.iter().filter_map(|row| row.unit.clone())),
1753        test_name: unique_sorted(cases.iter().map(|row| row.test_name.clone())),
1754    }
1755}
1756
1757fn optional_dimension(value: Option<String>) -> Vec<String> {
1758    value.into_iter().collect()
1759}
1760
1761fn unique_sorted<T>(items: impl Iterator<Item = T>) -> Vec<T>
1762where
1763    T: Ord,
1764{
1765    let mut set = BTreeSet::new();
1766    set.extend(items);
1767    set.into_iter().collect()
1768}
1769
1770fn unique_sorted_by<T, K>(items: impl Iterator<Item = T>, key_fn: fn(&T) -> K) -> Vec<T>
1771where
1772    K: Ord,
1773{
1774    let mut values = items.collect::<Vec<_>>();
1775    values.sort_by_key(|item| key_fn(item));
1776    values.dedup_by(|left, right| key_fn(left) == key_fn(right));
1777    values
1778}
1779
1780fn status_key(status: &Status) -> &'static str {
1781    match status {
1782        Status::Fail => "fail",
1783        Status::Pass => "pass",
1784    }
1785}
1786
1787fn action_status_key(status: &ActionStatus) -> &'static str {
1788    match status {
1789        ActionStatus::Fail => "fail",
1790        ActionStatus::Ok => "ok",
1791    }
1792}
1793
1794fn cmake_check_status_key(status: &CmakeCheckStatus) -> &'static str {
1795    match status {
1796        CmakeCheckStatus::Fail => "fail",
1797        CmakeCheckStatus::Pass => "pass",
1798        CmakeCheckStatus::RunnerFail => "runner_fail",
1799        CmakeCheckStatus::ValidationFail => "validation_fail",
1800    }
1801}
1802
1803fn cmake_fail_class_key(class: &CmakeCheckFailClass) -> &'static str {
1804    match class {
1805        CmakeCheckFailClass::CertificationFailure => "certification_failure",
1806        CmakeCheckFailClass::RunnerFailure => "runner_failure",
1807        CmakeCheckFailClass::ValidationFailure => "validation_failure",
1808    }
1809}
1810
1811fn derive_measures(
1812    cases: &[CubeCaseRow],
1813    actions: &[CubeActionRow],
1814    asserts: &[CubeAssertRow],
1815    cmake_checks: &[CubeCmakeCheckRow],
1816    telemetry: &[CubeTelemetryRow],
1817) -> AnalyticsResult<CubeMeasures> {
1818    let mut fail_by_action_kind = BTreeMap::new();
1819    for row in actions {
1820        let entry = fail_by_action_kind.entry(row.action.clone()).or_insert(0);
1821        if row.status == ActionStatus::Fail {
1822            *entry += 1;
1823        }
1824    }
1825
1826    let mut fail_by_item_id = BTreeMap::new();
1827    for row in cases {
1828        let entry = fail_by_item_id.entry(row.item_id.clone()).or_insert(0);
1829        if row.status == Status::Fail {
1830            *entry += 1;
1831        }
1832    }
1833
1834    let mut metric_samples = BTreeMap::<String, (String, Vec<Number>)>::new();
1835    for row in telemetry {
1836        if row.kind != "metric" {
1837            continue;
1838        }
1839        let unit = row.unit.clone().unwrap_or_default();
1840        let value = row.value.clone().ok_or_else(|| {
1841            AnalyticsError::new(
1842                AnalyticsErrorCode::InvalidTelemetryShape,
1843                format!("metric telemetry `{}` missing numeric value", row.name),
1844            )
1845        })?;
1846        let entry = metric_samples.entry(row.name.clone()).or_insert_with(|| (unit.clone(), Vec::new()));
1847        if entry.0 != unit {
1848            return Err(AnalyticsError::new(
1849                AnalyticsErrorCode::InvalidTelemetryShape,
1850                format!("metric telemetry `{}` used multiple units", row.name),
1851            ));
1852        }
1853        entry.1.push(value);
1854    }
1855
1856    let metric_summary = metric_samples
1857        .into_iter()
1858        .map(|(name, (unit, values))| {
1859            let floats = values
1860                .iter()
1861                .map(number_to_f64)
1862                .collect::<AnalyticsResult<Vec<_>>>()?;
1863            let avg = floats.iter().sum::<f64>() / floats.len() as f64;
1864            let min = values
1865                .iter()
1866                .min_by(|left, right| compare_numbers(left, right))
1867                .cloned()
1868                .unwrap();
1869            let max = values
1870                .iter()
1871                .max_by(|left, right| compare_numbers(left, right))
1872                .cloned()
1873                .unwrap();
1874            Ok((
1875                name,
1876                MetricSummary {
1877                    avg,
1878                    count: values.len(),
1879                    max,
1880                    min,
1881                    unit,
1882                },
1883            ))
1884        })
1885        .collect::<AnalyticsResult<BTreeMap<_, _>>>()?;
1886
1887    Ok(CubeMeasures {
1888        action_fail: actions.iter().filter(|row| row.status == ActionStatus::Fail).count(),
1889        action_total: actions.len(),
1890        assert_fail: asserts.iter().filter(|row| row.status == Status::Fail).count(),
1891        assert_pass: asserts.iter().filter(|row| row.status == Status::Pass).count(),
1892        assert_total: asserts.len(),
1893        case_fail: cases.iter().filter(|row| row.status == Status::Fail).count(),
1894        case_pass: cases.iter().filter(|row| row.status == Status::Pass).count(),
1895        case_total: cases.len(),
1896        cmake_check_fail: cmake_checks
1897            .iter()
1898            .filter(|row| row.status == CmakeCheckStatus::Fail)
1899            .count(),
1900        cmake_check_pass: cmake_checks
1901            .iter()
1902            .filter(|row| row.status == CmakeCheckStatus::Pass)
1903            .count(),
1904        cmake_check_runner_fail: cmake_checks
1905            .iter()
1906            .filter(|row| row.status == CmakeCheckStatus::RunnerFail)
1907            .count(),
1908        cmake_check_total: cmake_checks.len(),
1909        cmake_check_validation_fail: cmake_checks
1910            .iter()
1911            .filter(|row| row.status == CmakeCheckStatus::ValidationFail)
1912            .count(),
1913        cmake_missing_artifact_total: cmake_checks
1914            .iter()
1915            .map(|row| row.missing_artifacts.len())
1916            .sum(),
1917        fail_by_action_kind,
1918        fail_by_item_id,
1919        metric_summary,
1920        telemetry_total: telemetry.len(),
1921    })
1922}
1923
1924fn number_to_f64(value: &Number) -> AnalyticsResult<f64> {
1925    value.as_f64().ok_or_else(|| {
1926        AnalyticsError::new(
1927            AnalyticsErrorCode::InvalidTelemetryShape,
1928            "metric telemetry value must be numeric",
1929        )
1930    })
1931}
1932
1933fn compare_numbers(left: &Number, right: &Number) -> std::cmp::Ordering {
1934    let left = left.as_f64().unwrap_or(0.0);
1935    let right = right.as_f64().unwrap_or(0.0);
1936    left.partial_cmp(&right).unwrap_or(std::cmp::Ordering::Equal)
1937}
1938
1939fn number_from_f64(value: f64) -> AnalyticsResult<Number> {
1940    if value.fract() == 0.0 && value >= i64::MIN as f64 && value <= i64::MAX as f64 {
1941        return Ok(Number::from(value as i64));
1942    }
1943    Number::from_f64(value).ok_or_else(|| {
1944        AnalyticsError::new(
1945            AnalyticsErrorCode::InvalidTelemetryShape,
1946            "metric delta produced non-finite number",
1947        )
1948    })
1949}
1950
1951fn compare_status(status: Option<Status>) -> CompareStatus {
1952    match status {
1953        Some(Status::Pass) => CompareStatus::Pass,
1954        Some(Status::Fail) => CompareStatus::Fail,
1955        None => CompareStatus::Missing,
1956    }
1957}
1958
1959fn classify_case_change(baseline: CompareStatus, candidate: CompareStatus) -> CaseChangeCategory {
1960    match (baseline, candidate) {
1961        (CompareStatus::Pass, CompareStatus::Pass) => CaseChangeCategory::PassToPass,
1962        (CompareStatus::Pass, CompareStatus::Fail) => CaseChangeCategory::PassToFail,
1963        (CompareStatus::Fail, CompareStatus::Pass) => CaseChangeCategory::FailToPass,
1964        (CompareStatus::Fail, CompareStatus::Fail) => CaseChangeCategory::FailToFail,
1965        (CompareStatus::Missing, _) => CaseChangeCategory::Added,
1966        (_, CompareStatus::Missing) => CaseChangeCategory::Removed,
1967    }
1968}
1969
1970fn aggregate_action_failures(actions: &[CubeActionRow]) -> BTreeMap<(String, String, String), usize> {
1971    let mut out = BTreeMap::new();
1972    for action in actions {
1973        if action.status != ActionStatus::Fail {
1974            continue;
1975        }
1976        *out.entry((action.item_id.clone(), action.test_name.clone(), action.action.clone()))
1977            .or_insert(0) += 1;
1978    }
1979    out
1980}
1981
1982fn aggregate_metric_values(
1983    telemetry: &[CubeTelemetryRow],
1984) -> AnalyticsResult<BTreeMap<(String, String, String, String), Number>> {
1985    let mut out = BTreeMap::new();
1986    for row in telemetry {
1987        if row.kind != "metric" {
1988            continue;
1989        }
1990        let unit = row.unit.clone().ok_or_else(|| {
1991            AnalyticsError::new(
1992                AnalyticsErrorCode::InvalidCubeShape,
1993                format!("metric telemetry `{}` missing unit in cube artifact", row.name),
1994            )
1995        })?;
1996        let value = row.value.clone().ok_or_else(|| {
1997            AnalyticsError::new(
1998                AnalyticsErrorCode::InvalidCubeShape,
1999                format!("metric telemetry `{}` missing value in cube artifact", row.name),
2000            )
2001        })?;
2002        out.insert(
2003            (
2004                row.item_id.clone(),
2005                row.test_name.clone(),
2006                row.name.clone(),
2007                unit,
2008            ),
2009            value,
2010        );
2011    }
2012    Ok(out)
2013}
2014
2015fn relation_kind_key(kind: RelationKind) -> &'static str {
2016    match kind {
2017        RelationKind::Child => "child",
2018        RelationKind::Parent => "parent",
2019        RelationKind::Previous => "previous",
2020        RelationKind::Shard => "shard",
2021        RelationKind::Subbuild => "subbuild",
2022    }
2023}
2024
2025fn cube_stage_from_product_stage(stage: &ProductStageRecord) -> CubeStage {
2026    CubeStage {
2027        runner_kind: stage.runner_kind.clone(),
2028        status: stage.status,
2029        exit_code: stage.exit_code,
2030        required: stage.required,
2031        case_pass: stage.child_case_pass,
2032        case_fail: stage.child_case_fail,
2033        assert_pass: stage.child_assert_pass,
2034        assert_fail: stage.child_assert_fail,
2035        check_pass: stage.child_check_pass,
2036        check_fail: stage.child_check_fail,
2037        check_validation_fail: stage.child_check_validation_fail,
2038        check_runner_fail: stage.child_check_runner_fail,
2039    }
2040}
2041
2042fn empty_cube_dimensions(meta: &BuildMeta) -> CubeDimensions {
2043    CubeDimensions {
2044        action_kind: Vec::new(),
2045        action_status: Vec::new(),
2046        assert_status: Vec::new(),
2047        branch: meta.branch.clone().into_iter().collect(),
2048        case_status: Vec::new(),
2049        cmake_check_fail_class: Vec::new(),
2050        cmake_check_kind: Vec::new(),
2051        cmake_check_status: Vec::new(),
2052        cmake_configuration_id: Vec::new(),
2053        cmake_target_name: Vec::new(),
2054        commit: meta.commit.clone().into_iter().collect(),
2055        item_id: Vec::new(),
2056        job_id: meta.job_id.clone().into_iter().collect(),
2057        pipeline_id: meta.pipeline_id.clone().into_iter().collect(),
2058        runner: meta.runner.clone().into_iter().collect(),
2059        telemetry_kind: Vec::new(),
2060        telemetry_name: Vec::new(),
2061        telemetry_unit: Vec::new(),
2062        test_name: Vec::new(),
2063    }
2064}
2065
2066fn empty_cube_measures() -> CubeMeasures {
2067    CubeMeasures {
2068        action_fail: 0,
2069        action_total: 0,
2070        assert_fail: 0,
2071        assert_pass: 0,
2072        assert_total: 0,
2073        case_fail: 0,
2074        case_pass: 0,
2075        case_total: 0,
2076        cmake_check_fail: 0,
2077        cmake_check_pass: 0,
2078        cmake_check_runner_fail: 0,
2079        cmake_check_total: 0,
2080        cmake_check_validation_fail: 0,
2081        cmake_missing_artifact_total: 0,
2082        fail_by_action_kind: BTreeMap::new(),
2083        fail_by_item_id: BTreeMap::new(),
2084        metric_summary: BTreeMap::new(),
2085        telemetry_total: 0,
2086    }
2087}
2088
2089fn compare_artifact_build_component(build_id: &str) -> &str {
2090    build_id.strip_prefix("build-").unwrap_or(build_id)
2091}
2092
2093#[derive(Debug, Deserialize)]
2094struct ParsedBuildCube {
2095    k: String,
2096    v: String,
2097    build: ParsedCubeBuild,
2098    #[serde(default)]
2099    stage: Option<ParsedCubeStage>,
2100    #[serde(default)]
2101    order: Option<ParsedCubeOrder>,
2102    #[serde(default)]
2103    dimensions: Option<ParsedCubeDimensions>,
2104    facts: ParsedCubeFacts,
2105    #[serde(default)]
2106    measures: Option<ParsedCubeMeasures>,
2107}
2108
2109#[derive(Debug, Deserialize)]
2110struct ParsedCubeOrder {
2111    actions: String,
2112    asserts: String,
2113    cases: String,
2114    #[serde(default = "default_report_check_order")]
2115    cmake_checks: String,
2116    dimensions: String,
2117    telemetry: String,
2118}
2119
2120fn default_report_check_order() -> String {
2121    "report_check_order".to_owned()
2122}
2123
2124#[derive(Debug, Deserialize)]
2125struct ParsedCubeDimensions {
2126    action_kind: Vec<String>,
2127    action_status: Vec<ActionStatus>,
2128    assert_status: Vec<Status>,
2129    #[serde(default)]
2130    branch: Vec<String>,
2131    case_status: Vec<Status>,
2132    #[serde(default)]
2133    cmake_check_fail_class: Vec<CmakeCheckFailClass>,
2134    #[serde(default)]
2135    cmake_check_kind: Vec<String>,
2136    #[serde(default)]
2137    cmake_check_status: Vec<CmakeCheckStatus>,
2138    #[serde(default)]
2139    cmake_configuration_id: Vec<String>,
2140    #[serde(default)]
2141    cmake_target_name: Vec<String>,
2142    #[serde(default)]
2143    commit: Vec<String>,
2144    item_id: Vec<String>,
2145    #[serde(default)]
2146    job_id: Vec<String>,
2147    #[serde(default)]
2148    pipeline_id: Vec<String>,
2149    #[serde(default)]
2150    runner: Vec<String>,
2151    telemetry_kind: Vec<String>,
2152    telemetry_name: Vec<String>,
2153    telemetry_unit: Vec<String>,
2154    test_name: Vec<String>,
2155}
2156
2157#[derive(Debug, Deserialize)]
2158struct ParsedCubeMeasures {
2159    action_fail: usize,
2160    action_total: usize,
2161    assert_fail: usize,
2162    assert_pass: usize,
2163    assert_total: usize,
2164    case_fail: usize,
2165    case_pass: usize,
2166    case_total: usize,
2167    #[serde(default)]
2168    cmake_check_fail: usize,
2169    #[serde(default)]
2170    cmake_check_pass: usize,
2171    #[serde(default)]
2172    cmake_check_runner_fail: usize,
2173    #[serde(default)]
2174    cmake_check_total: usize,
2175    #[serde(default)]
2176    cmake_check_validation_fail: usize,
2177    #[serde(default)]
2178    cmake_missing_artifact_total: usize,
2179    fail_by_action_kind: BTreeMap<String, usize>,
2180    fail_by_item_id: BTreeMap<String, usize>,
2181    metric_summary: BTreeMap<String, ParsedMetricSummary>,
2182    telemetry_total: usize,
2183}
2184
2185#[derive(Debug, Deserialize)]
2186struct ParsedMetricSummary {
2187    avg: f64,
2188    count: usize,
2189    max: Number,
2190    min: Number,
2191    unit: String,
2192}
2193
2194#[derive(Debug, Deserialize)]
2195struct ParsedBuildCompare {
2196    k: String,
2197    v: String,
2198    builds: Vec<ParsedCompareBuildEntry>,
2199    relations: Vec<ParsedBuildRelation>,
2200    comparisons: ParsedCompareTables,
2201    measures: ParsedCompareMeasures,
2202}
2203
2204#[derive(Debug, Deserialize)]
2205struct ParsedCompareBuildEntry {
2206    build_id: String,
2207    #[serde(default)]
2208    inventory_sha256: Option<String>,
2209    #[serde(default)]
2210    suite_sha256: Option<String>,
2211    #[serde(default)]
2212    model_sha256: Option<String>,
2213    #[serde(default)]
2214    report_sha256: Option<String>,
2215    #[serde(default)]
2216    runner_kind: Option<String>,
2217    report_mode: ReportMode,
2218    meta: BuildMeta,
2219}
2220
2221#[derive(Debug, Deserialize)]
2222struct ParsedCompareTables {
2223    action_fail_change: Vec<ParsedActionFailChangeRow>,
2224    case_status_change: Vec<ParsedCaseStatusChangeRow>,
2225    metric_delta: Vec<ParsedMetricDeltaRow>,
2226    missing_or_added_tests: Vec<ParsedMissingOrAddedTestRow>,
2227}
2228
2229#[derive(Debug, Deserialize)]
2230struct ParsedActionFailChangeRow {
2231    action: String,
2232    baseline_fail: usize,
2233    candidate_fail: usize,
2234    delta: i64,
2235    item_id: String,
2236    test_name: String,
2237}
2238
2239#[derive(Debug, Deserialize)]
2240struct ParsedCaseStatusChangeRow {
2241    baseline_status: CompareStatus,
2242    candidate_status: CompareStatus,
2243    category: CaseChangeCategory,
2244    item_id: String,
2245    test_name: String,
2246}
2247
2248#[derive(Debug, Deserialize)]
2249struct ParsedMetricDeltaRow {
2250    baseline_value: Number,
2251    candidate_value: Number,
2252    delta: Number,
2253    delta_ratio: Option<f64>,
2254    item_id: String,
2255    name: String,
2256    test_name: String,
2257    unit: String,
2258}
2259
2260#[derive(Debug, Deserialize)]
2261struct ParsedMissingOrAddedTestRow {
2262    category: PresenceCategory,
2263    item_id: String,
2264    test_name: String,
2265}
2266
2267#[derive(Debug, Deserialize)]
2268struct ParsedCompareMeasures {
2269    added_tests: usize,
2270    build_count: usize,
2271    fail_to_pass: usize,
2272    pair_count: usize,
2273    pass_to_fail: usize,
2274    removed_tests: usize,
2275}
2276
2277#[derive(Debug, Deserialize)]
2278struct ParsedBuildCompareIndex {
2279    k: String,
2280    v: String,
2281    builds: Vec<ParsedCompareBuildEntry>,
2282    relations: Vec<ParsedBuildRelation>,
2283    pairs: Vec<ParsedCompareIndexPair>,
2284    measures: ParsedCompareIndexMeasures,
2285}
2286
2287#[derive(Debug, Deserialize)]
2288struct ParsedCompareIndexPair {
2289    baseline_build_id: String,
2290    candidate_build_id: String,
2291    compare_artifact: String,
2292}
2293
2294#[derive(Debug, Deserialize)]
2295struct ParsedCompareIndexMeasures {
2296    build_count: usize,
2297    pair_count: usize,
2298    relation_count: usize,
2299}
2300
2301#[derive(Debug, Deserialize)]
2302struct ParsedBuildRelation {
2303    from_build_id: String,
2304    relation_kind: RelationKind,
2305    to_build_id: String,
2306}
2307
2308#[derive(Debug, Deserialize)]
2309struct ParsedCubeBuild {
2310    build_id: Option<String>,
2311    #[serde(default)]
2312    inventory_sha256: Option<String>,
2313    #[serde(default)]
2314    suite_sha256: Option<String>,
2315    #[serde(default)]
2316    model_sha256: Option<String>,
2317    #[serde(default)]
2318    report_sha256: Option<String>,
2319    report_mode: ReportMode,
2320    meta: BuildMeta,
2321}
2322
2323#[derive(Debug, Deserialize)]
2324struct ParsedCubeStage {
2325    runner_kind: String,
2326    status: ProductStatus,
2327    exit_code: i32,
2328    required: bool,
2329    #[serde(default)]
2330    case_pass: Option<usize>,
2331    #[serde(default)]
2332    case_fail: Option<usize>,
2333    #[serde(default)]
2334    assert_pass: Option<usize>,
2335    #[serde(default)]
2336    assert_fail: Option<usize>,
2337    #[serde(default)]
2338    check_pass: Option<usize>,
2339    #[serde(default)]
2340    check_fail: Option<usize>,
2341    #[serde(default)]
2342    check_validation_fail: Option<usize>,
2343    #[serde(default)]
2344    check_runner_fail: Option<usize>,
2345}
2346
2347#[derive(Debug, Deserialize)]
2348struct ParsedCubeFacts {
2349    cases: Vec<ParsedCubeCaseRow>,
2350    actions: Vec<ParsedCubeActionRow>,
2351    asserts: Vec<ParsedCubeAssertRow>,
2352    #[serde(default)]
2353    cmake_checks: Vec<ParsedCubeCmakeCheckRow>,
2354    telemetry: Vec<ParsedCubeTelemetryRow>,
2355}
2356
2357#[derive(Debug, Deserialize)]
2358struct ParsedCubeCmakeCheckRow {
2359    row_ix: usize,
2360    check_id: String,
2361    check_kind: String,
2362    status: CmakeCheckStatus,
2363    #[serde(default)]
2364    fail_class: Option<CmakeCheckFailClass>,
2365    #[serde(default)]
2366    msg: Option<String>,
2367    #[serde(default)]
2368    checked_targets: Vec<CmakeTargetRef>,
2369    #[serde(default)]
2370    missing_artifacts: Vec<CmakeMissingArtifact>,
2371}
2372
2373fn extract_checked_targets(check: &CmakeCheckRecord) -> AnalyticsResult<Vec<CmakeTargetRef>> {
2374    let Some(ok) = check.ok.as_ref() else {
2375        return Ok(Vec::new());
2376    };
2377    let Some(targets) = ok.get("checked_targets") else {
2378        return Ok(Vec::new());
2379    };
2380    serde_json::from_value(targets.clone()).map_err(|error| {
2381        AnalyticsError::new(
2382            AnalyticsErrorCode::InvalidReportShape,
2383            format!(
2384                "cmake check `{}` had invalid checked_targets payload: {error}",
2385                check.check_id
2386            ),
2387        )
2388    })
2389}
2390
2391fn validate_cmake_report_shape(
2392    records: &[CmakeModelReportRecord],
2393) -> AnalyticsResult<(CmakeModelReportHeader, CmakeSummaryRecord)> {
2394    let header_count = records
2395        .iter()
2396        .filter(|record| matches!(record, CmakeModelReportRecord::Header(_)))
2397        .count();
2398    let summary_count = records
2399        .iter()
2400        .filter(|record| matches!(record, CmakeModelReportRecord::Summary(_)))
2401        .count();
2402    if header_count != 1 {
2403        return Err(AnalyticsError::new(
2404            AnalyticsErrorCode::InvalidReportShape,
2405            format!("cmake report must contain exactly one header record, found {header_count}"),
2406        ));
2407    }
2408    if summary_count != 1 {
2409        return Err(AnalyticsError::new(
2410            AnalyticsErrorCode::InvalidReportShape,
2411            format!("cmake report must contain exactly one final summary record, found {summary_count}"),
2412        ));
2413    }
2414    let Some(CmakeModelReportRecord::Header(header)) = records.first() else {
2415        return Err(AnalyticsError::new(
2416            AnalyticsErrorCode::InvalidReportShape,
2417            "cmake report must begin with header record",
2418        ));
2419    };
2420    let Some(CmakeModelReportRecord::Summary(summary)) = records.last() else {
2421        return Err(AnalyticsError::new(
2422            AnalyticsErrorCode::InvalidReportShape,
2423            "cmake report missing final summary record",
2424        ));
2425    };
2426    let expected_status = if summary.check_fail == 0
2427        && summary.check_validation_fail == 0
2428        && summary.check_runner_fail == 0
2429    {
2430        "pass"
2431    } else {
2432        "fail"
2433    };
2434    if summary.status != expected_status {
2435        return Err(AnalyticsError::new(
2436            AnalyticsErrorCode::InvalidReportShape,
2437            format!(
2438                "cmake summary status `{}` did not match summary counters",
2439                summary.status
2440            ),
2441        ));
2442    }
2443    Ok((header.clone(), summary.clone()))
2444}
2445
2446#[derive(Debug, Deserialize)]
2447struct ParsedCubeCaseRow {
2448    row_ix: usize,
2449    case_id: String,
2450    item_id: String,
2451    test_name: String,
2452    status: Status,
2453    assert_pass: usize,
2454    assert_fail: usize,
2455    unhandled_action_fail: usize,
2456}
2457
2458#[derive(Debug, Deserialize)]
2459struct ParsedCubeActionRow {
2460    row_ix: usize,
2461    case_id: String,
2462    item_id: String,
2463    test_name: String,
2464    action_ix: usize,
2465    action: String,
2466    status: ActionStatus,
2467    fail_kind: Option<String>,
2468    fail_code: Option<String>,
2469    exit: Option<i64>,
2470    out_len: Option<u64>,
2471    err_len: Option<u64>,
2472    body_len: Option<u64>,
2473    bytes_len: Option<u64>,
2474}
2475
2476#[derive(Debug, Deserialize)]
2477struct ParsedCubeAssertRow {
2478    row_ix: usize,
2479    case_id: String,
2480    item_id: String,
2481    test_name: String,
2482    assert_ix: usize,
2483    status: Status,
2484    msg: String,
2485}
2486
2487#[derive(Debug, Deserialize)]
2488struct ParsedCubeTelemetryRow {
2489    row_ix: usize,
2490    case_id: String,
2491    item_id: String,
2492    test_name: String,
2493    action_ix: Option<usize>,
2494    scope: TelemetryScope,
2495    name: String,
2496    kind: String,
2497    unit: Option<String>,
2498    value: Option<Number>,
2499    values: Option<Vec<Number>>,
2500    tag_value: Option<String>,
2501    canonical: bool,
2502}
2503
2504fn parse_cube_telemetry_kind(value: &str) -> AnalyticsResult<&'static str> {
2505    match value {
2506        "metric" => Ok("metric"),
2507        "vector" => Ok("vector"),
2508        "tag" => Ok("tag"),
2509        _ => Err(AnalyticsError::new(
2510            AnalyticsErrorCode::InvalidCubeShape,
2511            format!("unsupported telemetry kind `{value}` in cube artifact"),
2512        )),
2513    }
2514}
2515
2516fn static_cube_order_field(value: &str) -> AnalyticsResult<&'static str> {
2517    match value {
2518        "report_case_order_then_action_ix" => Ok("report_case_order_then_action_ix"),
2519        "report_case_order_then_assert_ix" => Ok("report_case_order_then_assert_ix"),
2520        "report_case_order" => Ok("report_case_order"),
2521        "report_check_order" => Ok("report_check_order"),
2522        "utf8_lexicographic" => Ok("utf8_lexicographic"),
2523        "report_case_order_then_action_ix_then_record_order" => {
2524            Ok("report_case_order_then_action_ix_then_record_order")
2525        }
2526        _ => Err(AnalyticsError::new(
2527            AnalyticsErrorCode::InvalidCubeShape,
2528            format!("unsupported cube order field `{value}`"),
2529        )),
2530    }
2531}