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