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#[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#[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 #[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 #[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
110pub 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
233pub 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
315pub 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}