Skip to main content

wowlab_engine_sim/telemetry/
mod.rs

1//! Cross-iteration telemetry accumulation, capture, encoding, and parallel merge.
2
3mod encode;
4mod merge;
5mod representative;
6
7use hdrhistogram::Histogram;
8use itertools::Itertools;
9use representative::RepresentativeTimeline;
10use wowlab_engine_telemetry::{
11    AuraEvent, AuraEventKind, AuraTelemetryScope, ResourceEventKind, TargetMetadata,
12    TelemetryScope, TelemetrySink,
13};
14use wowlab_types::{
15    constants::MS_PER_SECOND,
16    numeric::{f64_to_u32_saturating_trunc, f64_to_u64_saturating_round},
17    sim::{ActorId, EnemyIdx, FastMap, IntMap, TargetIdx},
18};
19
20pub(crate) const TIMELINE_BUCKET_MS: u32 = 1_000;
21
22pub(crate) const HISTOGRAM_SIGNIFICANT_DIGITS: u8 = 3;
23pub(crate) const HISTOGRAM_DPS_SCALE: f64 = 10.0;
24
25const HISTOGRAM_MIN_SAMPLE: u64 = 1;
26const ENEMY_ACTOR_SORT_ORDER: u8 = 3;
27const PET_ACTOR_SORT_ORDER: u8 = 2;
28
29#[expect(
30    clippy::cast_possible_truncation,
31    reason = "representative previews deliberately trade precision for compact f32 storage"
32)]
33pub(super) const fn telemetry_preview_f32(value: f64) -> f32 {
34    // #t(rust_lossy_cast) representative previews deliberately use compact f32 storage
35    value as f32
36}
37
38pub(crate) const fn actor_sort_key(actor: ActorId) -> (u8, u16) {
39    match actor {
40        ActorId::Player => (0, 0),
41        ActorId::External => (1, 0),
42        ActorId::Pet(pet) => (PET_ACTOR_SORT_ORDER, pet.0),
43        ActorId::Enemy(enemy) => (ENEMY_ACTOR_SORT_ORDER, enemy.0),
44    }
45}
46
47#[inline]
48pub(crate) fn dps_histogram_sample(dps: f64) -> u64 {
49    let x10 = (dps * HISTOGRAM_DPS_SCALE).round();
50
51    if x10 < 1.0 {
52        HISTOGRAM_MIN_SAMPLE
53    } else {
54        f64_to_u64_saturating_round(x10)
55    }
56}
57
58pub(crate) fn new_dps_histogram() -> Histogram<u64> {
59    Histogram::new(HISTOGRAM_SIGNIFICANT_DIGITS)
60        .expect("HISTOGRAM_SIGNIFICANT_DIGITS is within the valid 0..=5 range")
61}
62
63pub(crate) fn collect_representative_aura_intervals(
64    events: &[AuraEvent],
65    mut emit: impl FnMut(&AuraEvent, u32, Option<u32>),
66) {
67    let mut starts: FastMap<AuraAggregateKey, RepresentativeAuraIntervalStart<'_>> =
68        FastMap::default();
69
70    for event in events {
71        let key = AuraAggregateKey::from(event);
72
73        match event.kind {
74            AuraEventKind::Apply { .. } | AuraEventKind::Refresh { .. } => {
75                starts
76                    .entry(key)
77                    .or_insert(RepresentativeAuraIntervalStart {
78                        time_ms: event.time_ms,
79                        event,
80                    });
81            }
82            AuraEventKind::Expire => {
83                if let Some(start) = starts.remove(&key) {
84                    emit(event, start.time_ms, Some(event.time_ms));
85                }
86            }
87        }
88    }
89
90    for (_, start) in starts.drain().sorted_by_key(|(key, start)| {
91        (
92            key.aura_id,
93            actor_sort_key(key.scope.affected),
94            actor_sort_key(key.scope.source),
95            key.scope.pull,
96            key.scope.group,
97            start.time_ms,
98        )
99    }) {
100        emit(start.event, start.time_ms, None);
101    }
102}
103
104struct RepresentativeAuraIntervalStart<'a> {
105    time_ms: u32,
106    event: &'a AuraEvent,
107}
108
109#[derive(Debug, Default)]
110pub(crate) struct ResourceSourceAggregate {
111    pub(crate) gained: f64,
112    pub(crate) wasted: f64,
113}
114
115#[derive(Debug, Default)]
116pub(crate) struct ResourceAggregate {
117    pub(crate) total_gained: f64,
118    pub(crate) total_spent: f64,
119    pub(crate) total_wasted: f64,
120    /// Income by gaining spell id, or a reserved `RESOURCE_SOURCE_*` id for regen and auto-attacks.
121    pub(crate) gained_by_source: IntMap<u32, ResourceSourceAggregate>,
122}
123
124#[derive(Clone, Debug, Default)]
125pub(crate) struct SpellAggregate {
126    pub(crate) damage: f64,
127    pub(crate) casts: u32,
128    pub(crate) crits: u32,
129    pub(crate) hits: u32,
130    pub(crate) ticks: u32,
131}
132
133#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
134pub(crate) struct ActionAggregateKey {
135    pub(crate) spell_id: u32,
136    pub(crate) scope: TelemetryScope,
137}
138
139impl ActionAggregateKey {
140    const fn from_scope(spell_id: u32, scope: TelemetryScope) -> Self {
141        Self { spell_id, scope }
142    }
143}
144
145#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
146pub(crate) struct AuraAggregateKey {
147    pub(crate) aura_id: u32,
148    pub(crate) scope: AuraTelemetryScope,
149}
150
151impl From<&AuraEvent> for AuraAggregateKey {
152    fn from(event: &AuraEvent) -> Self {
153        Self {
154            aura_id: event.aura_id,
155            scope: AuraTelemetryScope {
156                source: event.source,
157                affected: event.affected,
158                pull: event.pull,
159                group: event.group,
160            },
161        }
162    }
163}
164
165#[derive(Clone, Debug, Default)]
166pub(crate) struct AuraAggregate {
167    pub(crate) uptime_ms: f64,
168    pub(crate) applications: u64,
169    pub(crate) refreshes: u64,
170}
171
172#[derive(Debug, Default)]
173pub(crate) struct CooldownAggregate {
174    pub(crate) total_uses: u32,
175}
176
177/// Running DPS statistics snapshot.
178#[derive(Clone, Debug)]
179pub struct RunningDpsStats {
180    pub mean_dps: f64,
181    /// Population standard deviation of DPS across completed iterations.
182    pub std_dps: f64,
183    pub min_dps: f64,
184    pub max_dps: f64,
185}
186
187/// Deterministic identity and DPS for one completed iteration, the only per-iteration state retained for representative selection.
188#[derive(Clone, Copy, Debug, PartialEq)]
189pub struct RepresentativeIteration {
190    pub seed_prefix: u64,
191    pub iteration: u32,
192    pub dps: f64,
193}
194
195/// Accumulates telemetry incrementally across all iterations.
196#[derive(Debug)]
197// docref:start metrics-telemetry-accumulator
198pub struct TelemetryAccumulator {
199    pub(crate) iteration_count: u32,
200    pub(crate) dps_sum: f64,
201    pub(crate) dps_min: f64,
202    pub(crate) dps_max: f64,
203    pub(crate) spell_totals: FastMap<ActionAggregateKey, SpellAggregate>,
204    pub(crate) dps_welford_mean: f64,
205    pub(crate) dps_m2: f64,
206    pub(crate) dps_histogram: Histogram<u64>,
207    pub(crate) representative_dps: Option<f64>,
208    pub(crate) representative_candidates: Vec<RepresentativeIteration>,
209    pub(crate) aura_totals: FastMap<AuraAggregateKey, AuraAggregate>,
210    aura_interval_starts: FastMap<AuraAggregateKey, u32>,
211    pub(crate) resource_totals: IntMap<u8, ResourceAggregate>,
212    pub(crate) cooldown_totals: IntMap<u32, CooldownAggregate>,
213    pub(crate) total_duration_ms: f64,
214    pub(crate) gcd_locked_ms_total: f64,
215    pub(crate) representative_sink: Option<RepresentativeTimeline>,
216    pub(crate) direct_damage_total: f64,
217    pub(crate) periodic_damage_total: f64,
218    pub(crate) pet_damage_total: f64,
219    pub(crate) damage_by_target: FastMap<EnemyIdx, f64>,
220    pub(crate) targets: FastMap<TargetIdx, TargetMetadata>,
221    pub(crate) bucket_sums: Vec<f64>,
222    /// Per-bucket sample count: divide each `bucket_sums` entry by this, not by total iterations.
223    pub(crate) bucket_samples: Vec<u64>,
224    pub(crate) trace_extras_enabled: bool,
225}
226// docref:end metrics-telemetry-accumulator
227
228impl TelemetryAccumulator {
229    /// Empty accumulator.
230    #[must_use]
231    pub fn new() -> Self {
232        Self {
233            iteration_count: 0,
234            dps_sum: 0.0,
235            dps_min: f64::MAX,
236            dps_max: f64::MIN,
237            spell_totals: FastMap::default(),
238            dps_welford_mean: 0.0,
239            dps_m2: 0.0,
240            dps_histogram: new_dps_histogram(),
241            representative_dps: None,
242            representative_candidates: Vec::new(),
243            aura_totals: FastMap::default(),
244            aura_interval_starts: FastMap::default(),
245            resource_totals: IntMap::default(),
246            cooldown_totals: IntMap::default(),
247            total_duration_ms: 0.0,
248            gcd_locked_ms_total: 0.0,
249            representative_sink: None,
250            direct_damage_total: 0.0,
251            periodic_damage_total: 0.0,
252            pet_damage_total: 0.0,
253            damage_by_target: FastMap::default(),
254            targets: FastMap::default(),
255            bucket_sums: Vec::new(),
256            bucket_samples: Vec::new(),
257            trace_extras_enabled: false,
258        }
259    }
260
261    /// Enable resource-sample + aura-interval collection.
262    pub fn enable_trace_extras(&mut self) {
263        self.trace_extras_enabled = true;
264    }
265
266    /// Running DPS snapshot; `None` before any iterations.
267    #[must_use]
268    pub fn running_stats(&self) -> Option<RunningDpsStats> {
269        if self.iteration_count == 0 {
270            return None;
271        }
272
273        let n = f64::from(self.iteration_count);
274        let mean = self.dps_sum / n;
275        let variance = self.dps_m2 / n;
276        let std = if variance > 0.0 { variance.sqrt() } else { 0.0 };
277
278        Some(RunningDpsStats {
279            mean_dps: mean,
280            std_dps: std,
281            min_dps: self.dps_min,
282            max_dps: self.dps_max,
283        })
284    }
285
286    /// Record one completed iteration without externally supplied RNG identity.
287    #[inline]
288    pub fn record_iteration(&mut self, dps: f64) {
289        let iteration = self.iteration_count;
290
291        self.record_iteration_for(0, iteration, dps);
292    }
293
294    /// Return the completed iteration nearest the final aggregate mean, tie-broken by `(seed_prefix, iteration)` for determinism.
295    #[must_use]
296    pub fn representative_iteration(&self) -> Option<RepresentativeIteration> {
297        let mean = self.running_stats()?.mean_dps;
298
299        // docref:start metrics-representative-pick
300        self.representative_candidates
301            .iter()
302            .copied()
303            .min_by(|a, b| {
304                let distance_order = (a.dps - mean).abs().total_cmp(&(b.dps - mean).abs());
305                distance_order
306                    .then_with(|| a.seed_prefix.cmp(&b.seed_prefix))
307                    .then_with(|| a.iteration.cmp(&b.iteration))
308            })
309        // docref:end metrics-representative-pick
310    }
311
312    /// DPS of the installed exact representative, or `None` until its capture-only rerun completes.
313    #[must_use]
314    pub const fn representative_dps(&self) -> Option<f64> {
315        self.representative_dps
316    }
317
318    /// Aggregate sink events into cross-iteration totals.
319    pub(crate) fn record_sink_events(&mut self, sink: &TelemetrySink, duration_secs: f64) {
320        let duration_ms = duration_secs * MS_PER_SECOND;
321
322        self.total_duration_ms += duration_ms;
323        let duration_ms_u32 = f64_to_u32_saturating_trunc(duration_ms);
324
325        let num_buckets = duration_ms_u32.div_ceil(TIMELINE_BUCKET_MS) as usize;
326
327        if self.bucket_sums.len() < num_buckets {
328            self.bucket_sums.resize(num_buckets, 0.0);
329        }
330
331        if self.bucket_samples.len() < num_buckets {
332            self.bucket_samples.resize(num_buckets, 0);
333        }
334
335        for slot in self.bucket_samples.iter_mut().take(num_buckets) {
336            *slot += 1;
337        }
338
339        self.record_damage_events(sink, duration_ms_u32);
340        self.record_aura_events(sink, duration_ms_u32);
341        self.record_resource_events(sink);
342
343        for event in &sink.cooldowns {
344            let entry = self.cooldown_totals.entry(event.spell_id).or_default();
345
346            entry.total_uses += 1;
347        }
348
349        self.record_action_events(sink);
350
351        if self.targets.is_empty() {
352            self.targets.extend(
353                sink.targets
354                    .iter()
355                    .cloned()
356                    .map(|target| (target.id, target)),
357            );
358        }
359    }
360
361    #[inline]
362    pub(crate) fn record_iteration_for(&mut self, seed_prefix: u64, iteration: u32, dps: f64) {
363        self.iteration_count += 1;
364        self.dps_sum += dps;
365
366        if dps < self.dps_min {
367            self.dps_min = dps;
368        }
369
370        if dps > self.dps_max {
371            self.dps_max = dps;
372        }
373
374        let delta = dps - self.dps_welford_mean;
375
376        self.dps_welford_mean += delta / f64::from(self.iteration_count);
377        let delta2 = dps - self.dps_welford_mean;
378
379        self.dps_m2 += delta * delta2;
380
381        let _ = self.dps_histogram.record(dps_histogram_sample(dps));
382
383        self.representative_candidates
384            .push(RepresentativeIteration {
385                seed_prefix,
386                iteration,
387                dps,
388            });
389    }
390
391    pub(crate) fn install_representative(
392        &mut self,
393        dps: f64,
394        sink: &TelemetrySink,
395        duration_secs: f64,
396    ) {
397        self.representative_dps = Some(dps);
398        let duration_ms = f64_to_u32_saturating_trunc(duration_secs * MS_PER_SECOND);
399
400        self.capture_representative(sink, duration_ms);
401    }
402
403    fn record_damage_events(&mut self, sink: &TelemetrySink, duration_ms: u32) {
404        for event in &sink.damage {
405            let amount = event.amount;
406
407            if event.is_pet {
408                self.pet_damage_total += amount;
409            } else if event.is_periodic {
410                self.periodic_damage_total += amount;
411            } else {
412                self.direct_damage_total += amount;
413            }
414
415            *self
416                .damage_by_target
417                .entry(event.scope.target)
418                .or_insert(0.0) += amount;
419
420            // At an exact bucket boundary, place the terminal damage event in the final elapsed interval, not one past the allocation.
421            let timeline_ms = if event.time_ms == duration_ms && duration_ms > 0 {
422                event.time_ms - 1
423            } else {
424                event.time_ms
425            };
426            let bucket = (timeline_ms / TIMELINE_BUCKET_MS) as usize;
427
428            if let Some(slot) = self.bucket_sums.get_mut(bucket) {
429                *slot += amount;
430            }
431        }
432    }
433
434    fn record_aura_events(&mut self, sink: &TelemetrySink, duration_ms: u32) {
435        self.aura_interval_starts.clear();
436
437        for event in &sink.auras {
438            let key = AuraAggregateKey::from(event);
439            let aggregate = self.aura_totals.entry(key).or_default();
440
441            match event.kind {
442                AuraEventKind::Apply { .. } => {
443                    aggregate.applications += 1;
444                    self.aura_interval_starts
445                        .entry(key)
446                        .or_insert(event.time_ms);
447                }
448                AuraEventKind::Refresh { .. } => {
449                    aggregate.refreshes += 1;
450                    self.aura_interval_starts
451                        .entry(key)
452                        .or_insert(event.time_ms);
453                }
454                AuraEventKind::Expire => {
455                    if let Some(start) = self.aura_interval_starts.remove(&key) {
456                        aggregate.uptime_ms += f64::from(event.time_ms.saturating_sub(start));
457                    }
458                }
459            }
460        }
461
462        for (key, start) in self.aura_interval_starts.drain() {
463            self.aura_totals.entry(key).or_default().uptime_ms +=
464                f64::from(duration_ms.saturating_sub(start));
465        }
466    }
467
468    fn record_resource_events(&mut self, sink: &TelemetrySink) {
469        for event in &sink.resources {
470            let entry = self.resource_totals.entry(event.resource_type).or_default();
471
472            match event.kind {
473                ResourceEventKind::Spend => entry.total_spent += event.amount,
474                ResourceEventKind::Gain { wasted } => {
475                    entry.total_gained += event.amount;
476                    entry.total_wasted += wasted;
477                    let source = entry
478                        .gained_by_source
479                        .entry(event.source_spell_id)
480                        .or_default();
481
482                    source.gained += event.amount;
483                    source.wasted += wasted;
484                }
485            }
486        }
487    }
488
489    fn record_action_events(&mut self, sink: &TelemetrySink) {
490        for cast in &sink.casts {
491            let entry = self
492                .spell_totals
493                .entry(ActionAggregateKey::from_scope(cast.spell_id, cast.scope))
494                .or_default();
495
496            entry.casts += 1;
497            self.gcd_locked_ms_total += f64::from(cast.gcd_ms);
498        }
499
500        for damage in &sink.damage {
501            let entry = self
502                .spell_totals
503                .entry(ActionAggregateKey::from_scope(
504                    damage.spell_id,
505                    damage.scope,
506                ))
507                .or_default();
508
509            entry.damage += damage.amount;
510            // `hits` and `ticks` partition every damage event; `crits` is a subset of the two, so
511            // `hits + ticks` is the row's total event count and never double-counts a critical.
512            entry.ticks += u32::from(damage.is_periodic);
513            entry.crits += u32::from(damage.is_crit);
514            entry.hits += u32::from(!damage.is_periodic);
515        }
516    }
517}
518
519impl Default for TelemetryAccumulator {
520    fn default() -> Self {
521        Self::new()
522    }
523}
524
525#[cfg(test)]
526#[path = "tests.rs"]
527mod tests;