Skip to main content

wowlab_analytics/analytics/
decode.rs

1use prost::Message;
2use wowlab_types::{
3    constants::{HUNDRED, MS_PER_SECOND, PROTO_DPS_SCALE},
4    numeric::u64_to_f64,
5    proto,
6};
7
8use super::{
9    convert::{
10        convert_actions, convert_auras, convert_cooldowns, convert_damage_profile,
11        convert_dictionary, convert_execution, convert_resources, convert_timeline, decode_hdr,
12        histogram_view_from_hdr, pct, percentiles_from_hdr,
13    },
14    tournament::{
15        JobResultView, TournamentStatsView, TournamentView, convert_permutation_summaries,
16        convert_slot_rankings,
17    },
18    views::{AnalyticsView, CoreView, DistributionView, HistogramView, PercentilesView},
19};
20
21const X10000_SCALE: f64 = 10000.0;
22const Z_SCORE_95: f64 = 1.96;
23
24/// Failure to decode or validate an analytics protobuf payload.
25#[derive(Debug, thiserror::Error)]
26#[error("{kind}")]
27pub struct AnalyticsDecodeError {
28    #[source]
29    kind: AnalyticsDecodeErrorKind,
30}
31
32#[derive(Debug, thiserror::Error)]
33enum AnalyticsDecodeErrorKind {
34    #[error("decode ResultViewV1: {0}")]
35    ResultView(#[source] prost::DecodeError),
36    #[error("decode TimelineViewV1: {0}")]
37    TimelineView(#[source] prost::DecodeError),
38    #[error("decode ChunkTelemetry: {0}")]
39    ChunkTelemetry(#[source] prost::DecodeError),
40    #[error("ResultViewV1 missing core")]
41    MissingResultCore,
42}
43
44/// Protobuf or validation stage that rejected an analytics payload.
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46#[non_exhaustive]
47pub enum AnalyticsDecodeStage {
48    ResultView,
49    TimelineView,
50    ChunkTelemetry,
51    ResultCore,
52}
53
54impl AnalyticsDecodeError {
55    const fn result_view(source: prost::DecodeError) -> Self {
56        Self {
57            kind: AnalyticsDecodeErrorKind::ResultView(source),
58        }
59    }
60
61    const fn timeline_view(source: prost::DecodeError) -> Self {
62        Self {
63            kind: AnalyticsDecodeErrorKind::TimelineView(source),
64        }
65    }
66
67    const fn chunk_telemetry(source: prost::DecodeError) -> Self {
68        Self {
69            kind: AnalyticsDecodeErrorKind::ChunkTelemetry(source),
70        }
71    }
72
73    const fn missing_result_core() -> Self {
74        Self {
75            kind: AnalyticsDecodeErrorKind::MissingResultCore,
76        }
77    }
78
79    /// Return the stage that rejected the payload.
80    #[must_use]
81    pub const fn stage(&self) -> AnalyticsDecodeStage {
82        match self.kind {
83            AnalyticsDecodeErrorKind::ResultView(_) => AnalyticsDecodeStage::ResultView,
84            AnalyticsDecodeErrorKind::TimelineView(_) => AnalyticsDecodeStage::TimelineView,
85            AnalyticsDecodeErrorKind::ChunkTelemetry(_) => AnalyticsDecodeStage::ChunkTelemetry,
86            AnalyticsDecodeErrorKind::MissingResultCore => AnalyticsDecodeStage::ResultCore,
87        }
88    }
89
90    /// Return whether the decoded result omitted its required core section.
91    #[must_use]
92    pub const fn is_missing_result_core(&self) -> bool {
93        matches!(self.kind, AnalyticsDecodeErrorKind::MissingResultCore)
94    }
95}
96
97fn decode_protobuf<M>(
98    bytes: &[u8],
99    context: fn(prost::DecodeError) -> AnalyticsDecodeError,
100) -> Result<M, AnalyticsDecodeError>
101where
102    M: Message + Default,
103{
104    match M::decode(bytes) {
105        Ok(message) => Ok(message),
106        Err(source) => Err(context(source)),
107    }
108}
109
110/// Decode `ResultViewV1` and `TimelineViewV1` protobuf bytes into a chart-ready [`AnalyticsView`].
111///
112/// # Errors
113///
114/// Returns an error when either protobuf payload is invalid or the result has no core section.
115pub fn decode_and_derive(
116    result_bytes: &[u8],
117    timeline_bytes: &[u8],
118) -> Result<AnalyticsView, AnalyticsDecodeError> {
119    let result_view: proto::ResultViewV1 =
120        decode_protobuf(result_bytes, AnalyticsDecodeError::result_view)?;
121
122    let timeline_view: proto::TimelineViewV1 =
123        decode_protobuf(timeline_bytes, AnalyticsDecodeError::timeline_view)?;
124
125    let Some(core_pb) = result_view.core else {
126        return Err(AnalyticsDecodeError::missing_result_core());
127    };
128
129    let total_fight_time_ms = core_pb.total_fight_time_ms;
130    let fight_time_s = u64_to_f64(total_fight_time_ms) / MS_PER_SECOND;
131
132    let core = CoreView {
133        iterations: core_pb.iterations,
134        chunks_completed: core_pb.chunks_completed,
135        chunks_total: core_pb.chunks_total,
136        total_fight_time_ms,
137        mean_dps: f64::from(core_pb.mean_dps_x10) / PROTO_DPS_SCALE,
138        min_dps: f64::from(core_pb.min_dps_x10) / PROTO_DPS_SCALE,
139        max_dps: f64::from(core_pb.max_dps_x10) / PROTO_DPS_SCALE,
140        std_dps: f64::from(core_pb.std_dps_x10) / PROTO_DPS_SCALE,
141        ci95_half_pct: f64::from(core_pb.ci95_half_pct_x10000) / X10000_SCALE,
142    };
143
144    let distribution = result_view.distribution.map(|dist| {
145        let histogram = dist.histogram.as_ref().and_then(histogram_view_from_hdr);
146
147        let percentiles = dist.percentiles.map(|p| PercentilesView {
148            p01: f64::from(p.p01_x10) / PROTO_DPS_SCALE,
149            p05: f64::from(p.p05_x10) / PROTO_DPS_SCALE,
150            p10: f64::from(p.p10_x10) / PROTO_DPS_SCALE,
151            p25: f64::from(p.p25_x10) / PROTO_DPS_SCALE,
152            p50: f64::from(p.p50_x10) / PROTO_DPS_SCALE,
153            p75: f64::from(p.p75_x10) / PROTO_DPS_SCALE,
154            p90: f64::from(p.p90_x10) / PROTO_DPS_SCALE,
155            p95: f64::from(p.p95_x10) / PROTO_DPS_SCALE,
156            p99: f64::from(p.p99_x10) / PROTO_DPS_SCALE,
157        });
158
159        DistributionView {
160            sample_count: dist.sample_count,
161            histogram: histogram.unwrap_or_else(|| HistogramView {
162                bin_count: 0,
163                min_dps: 0.0,
164                max_dps: 0.0,
165                counts: vec![],
166                underflow: 0,
167                overflow: 0,
168            }),
169            percentiles: percentiles.unwrap_or(PercentilesView {
170                p01: 0.0,
171                p05: 0.0,
172                p10: 0.0,
173                p25: 0.0,
174                p50: 0.0,
175                p75: 0.0,
176                p90: 0.0,
177                p95: 0.0,
178                p99: 0.0,
179            }),
180        }
181    });
182
183    let action_rows = result_view.actions.map(|a| a.rows).unwrap_or_default();
184    let actions = convert_actions(&action_rows, fight_time_s);
185
186    let aura_rows = result_view.auras.map(|a| a.rows).unwrap_or_default();
187    let auras = convert_auras(&aura_rows, total_fight_time_ms);
188
189    let resource_rows = result_view.resources.map(|r| r.rows).unwrap_or_default();
190    let resources = convert_resources(&resource_rows, total_fight_time_ms);
191
192    let cooldown_rows = result_view.cooldowns.map(|c| c.rows).unwrap_or_default();
193    let cooldowns = convert_cooldowns(&cooldown_rows);
194
195    let execution = result_view.execution.as_ref().map(convert_execution);
196
197    let damage_profile = result_view
198        .damage_profile
199        .as_ref()
200        .map(convert_damage_profile);
201
202    let timeline = convert_timeline(timeline_view);
203
204    let dictionary = result_view
205        .dictionary
206        .map(convert_dictionary)
207        .unwrap_or_default();
208
209    Ok(AnalyticsView {
210        core,
211        distribution,
212        actions,
213        auras,
214        resources,
215        cooldowns,
216        execution,
217        damage_profile,
218        timeline,
219        dictionary,
220    })
221}
222
223fn analytics_from_result_view(
224    result_view: &proto::ResultViewV1,
225    timeline_view: &proto::TimelineViewV1,
226) -> Result<AnalyticsView, AnalyticsDecodeError> {
227    let result_bytes = result_view.encode_to_vec();
228    let timeline_bytes = timeline_view.encode_to_vec();
229
230    decode_and_derive(&result_bytes, &timeline_bytes)
231}
232
233/// Decode the `JobResult` + `JobTimeline` envelope, falling back to [`decode_and_derive`] when the oneof is unset.
234///
235/// # Errors
236///
237/// Returns an error when the envelope or its embedded analytics payload cannot be decoded.
238pub fn decode_job_result(
239    result_bytes: &[u8],
240    timeline_bytes: &[u8],
241) -> Result<JobResultView, AnalyticsDecodeError> {
242    let envelope = proto::JobResult::decode(result_bytes).ok();
243    let timeline_envelope = proto::JobTimeline::decode(timeline_bytes).ok();
244
245    let oneof = envelope.as_ref().and_then(|e| e.result.as_ref());
246    let Some(oneof) = oneof else {
247        let analytics = decode_and_derive(result_bytes, timeline_bytes)?;
248
249        return Ok(JobResultView::Single { analytics });
250    };
251
252    match oneof {
253        proto::job_result::Result::Single(single) => {
254            let result_view = single.result.clone().unwrap_or_default();
255            let timeline_view = match timeline_envelope.and_then(|t| t.timeline) {
256                Some(proto::job_timeline::Timeline::Single(st)) => st.timeline.unwrap_or_default(),
257                Some(proto::job_timeline::Timeline::Tournament(tt)) => {
258                    tt.winner_timeline.unwrap_or_default()
259                }
260                None => proto::TimelineViewV1::default(),
261            };
262            let analytics = analytics_from_result_view(&result_view, &timeline_view)?;
263
264            Ok(JobResultView::Single { analytics })
265        }
266        proto::job_result::Result::Tournament(tournament) => {
267            let winner_timeline = match timeline_envelope.and_then(|t| t.timeline) {
268                Some(proto::job_timeline::Timeline::Tournament(tt)) => tt.winner_timeline,
269                Some(proto::job_timeline::Timeline::Single(st)) => st.timeline,
270                None => None,
271            };
272            let winner_analytics = match tournament.winner_result.as_ref() {
273                Some(result_view) => {
274                    let tl = winner_timeline.unwrap_or_default();
275
276                    Some(analytics_from_result_view(result_view, &tl)?)
277                }
278                None => None,
279            };
280
281            let stats_pb = tournament.stats.unwrap_or_default();
282            let winner_dps = f64::from(stats_pb.winner_dps_x10) / PROTO_DPS_SCALE;
283            let baseline_dps = f64::from(stats_pb.baseline_dps_x10) / PROTO_DPS_SCALE;
284            let dps_gain = f64::from(stats_pb.dps_gain_x10) / PROTO_DPS_SCALE;
285            let dps_gain_pct = pct(dps_gain, baseline_dps);
286
287            let stats = TournamentStatsView {
288                winner_dps,
289                baseline_dps,
290                dps_gain,
291                dps_gain_pct,
292                total_permutations: stats_pb.total_permutations,
293                total_iterations: stats_pb.total_iterations,
294                phases_completed: stats_pb.phases_completed,
295            };
296
297            let top_permutations = convert_permutation_summaries(
298                &tournament.top_permutations,
299                stats_pb.winner_dps_x10,
300            );
301            let slot_rankings = convert_slot_rankings(&tournament.slot_rankings);
302
303            Ok(JobResultView::Tournament {
304                analytics: winner_analytics,
305                tournament: TournamentView {
306                    stats,
307                    top_permutations,
308                    slot_rankings,
309                },
310            })
311        }
312    }
313}
314
315/// Decode a single `ChunkTelemetry` blob into an [`AnalyticsView`] (timeline always `None`).
316///
317/// # Errors
318///
319/// Returns an error when `chunk_bytes` is not a valid `ChunkTelemetry` protobuf payload.
320pub fn decode_chunk_telemetry(chunk_bytes: &[u8]) -> Result<AnalyticsView, AnalyticsDecodeError> {
321    let chunk: proto::ChunkTelemetry =
322        decode_protobuf(chunk_bytes, AnalyticsDecodeError::chunk_telemetry)?;
323
324    let total_fight_time_ms = chunk.total_fight_time_ms;
325    let fight_time_s = u64_to_f64(total_fight_time_ms) / MS_PER_SECOND;
326    let iterations = u64::from(chunk.iterations);
327
328    let m2 = f64::from_bits(chunk.m2_dps_bits);
329    let std_dps_x10 = if iterations > 1 {
330        let var_sample = m2 / (u64_to_f64(iterations) - 1.0);
331
332        var_sample.sqrt()
333    } else {
334        0.0
335    };
336
337    let mean_dps = f64::from(chunk.mean_dps_x10) / PROTO_DPS_SCALE;
338    let ci95_half_pct = if iterations > 1 && mean_dps > 0.0 {
339        let se = std_dps_x10 / (u64_to_f64(iterations)).sqrt();
340
341        Z_SCORE_95 * se / f64::from(chunk.mean_dps_x10) * HUNDRED
342    } else {
343        0.0
344    };
345
346    let core = CoreView {
347        iterations,
348        chunks_completed: 1,
349        chunks_total: 1,
350        total_fight_time_ms,
351        mean_dps,
352        min_dps: f64::from(chunk.min_dps_x10) / PROTO_DPS_SCALE,
353        max_dps: f64::from(chunk.max_dps_x10) / PROTO_DPS_SCALE,
354        std_dps: std_dps_x10 / PROTO_DPS_SCALE,
355        ci95_half_pct,
356    };
357
358    let distribution = chunk.histogram.as_ref().and_then(|hist| {
359        let hdr = decode_hdr(hist)?;
360
361        if hdr.is_empty() {
362            return None;
363        }
364
365        let histogram_view = histogram_view_from_hdr(hist)?;
366        let percentiles = percentiles_from_hdr(&hdr);
367
368        Some(DistributionView {
369            sample_count: hdr.len(),
370            histogram: histogram_view,
371            percentiles,
372        })
373    });
374
375    let actions = convert_actions(&chunk.actions, fight_time_s);
376    let auras = convert_auras(&chunk.auras, total_fight_time_ms);
377    let resources = convert_resources(&chunk.resources, total_fight_time_ms);
378    let cooldowns = convert_cooldowns(&chunk.cooldowns);
379    let execution = chunk.execution.as_ref().map(convert_execution);
380    let damage_profile = chunk.damage_profile.as_ref().map(convert_damage_profile);
381    let dictionary = chunk.dictionary.map(convert_dictionary).unwrap_or_default();
382
383    Ok(AnalyticsView {
384        core,
385        distribution,
386        actions,
387        auras,
388        resources,
389        cooldowns,
390        execution,
391        damage_profile,
392        timeline: None,
393        dictionary,
394    })
395}