ailloli_ui_runtime/app/
diagnostics.rs

1//! Bounded invalidation provenance and retained-work counters.
2
3use std::collections::{HashMap, VecDeque};
4
5use ailloli_ui_core::ElementId;
6
7use crate::popup::ElementTreeId;
8
9use super::Invalidation;
10
11/// Maximum number of newest invalidation provenance records retained.
12///
13/// Aggregate counters continue saturating after old records are discarded.
14///
15/// # Examples
16///
17/// ```
18/// use ailloli_ui_runtime::app::INVALIDATION_PROVENANCE_CAPACITY;
19/// assert_eq!(INVALIDATION_PROVENANCE_CAPACITY, 256);
20/// ```
21pub const INVALIDATION_PROVENANCE_CAPACITY: usize = 256;
22
23/// Origin of a retained-work request. This is diagnostic metadata only; it
24/// never changes invalidation semantics.
25/// The enum is non-exhaustive so downstream matches require a wildcard arm.
26///
27/// # Examples
28///
29/// ```
30/// use ailloli_ui_runtime::app::InvalidationSource;
31/// assert_ne!(InvalidationSource::Event, InvalidationSource::Timer);
32/// ```
33#[non_exhaustive]
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum InvalidationSource {
36    /// Direct request from runtime orchestration.
37    Runtime,
38    /// Request made through a component build context.
39    Context,
40    /// Request made while routing an input event.
41    Event,
42    /// Reactive signal mutation.
43    Signal,
44    /// Delayed or scheduled request.
45    Timer,
46    /// External retained model notification.
47    Model,
48    /// Presentation host request.
49    Host,
50    /// Legacy compatibility API.
51    Compatibility,
52}
53
54/// One bounded provenance record for an invalidation request.
55///
56/// Records are immutable snapshots. Sequence values start at one and saturate
57/// at `u64::MAX`; they can therefore cease being unique only after saturation.
58/// `was_coalesced` says an element already had pending work, not that the new
59/// request had the same strength.
60///
61/// # Examples
62///
63/// ```
64/// use ailloli_ui_core::ElementId;
65/// use ailloli_ui_runtime::app::{Invalidation, InvalidationSource, RuntimeHandle};
66/// let handle = RuntimeHandle::<()>::new();
67/// handle.invalidate_from(ElementId(7), Invalidation::Layout, InvalidationSource::Event);
68/// let snapshot = handle.invalidation_diagnostics();
69/// assert_eq!(snapshot.records.len(), 1);
70/// assert_eq!(snapshot.records[0].element_id(), ElementId(7));
71/// ```
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct InvalidationRecord {
74    /// Saturating request sequence.
75    sequence: u64,
76    /// Namespace of the retained tree receiving the request.
77    element_tree_id: ElementTreeId,
78    /// Tree-local target element.
79    element_id: ElementId,
80    /// Requested work strength before coalescing.
81    invalidation: Invalidation,
82    /// Diagnostic origin.
83    source: InvalidationSource,
84    /// Whether this tree/element already had pending work.
85    coalesced: bool,
86}
87
88/// Provides the operations defined for InvalidationRecord.
89impl InvalidationRecord {
90    /// Returns the one-based saturating request sequence.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// use ailloli_ui_core::ElementId;
96    /// use ailloli_ui_runtime::app::{Invalidation, RuntimeHandle};
97    /// let handle = RuntimeHandle::<()>::new();
98    /// handle.invalidate(ElementId(1), Invalidation::Paint);
99    /// assert_eq!(handle.invalidation_diagnostics().records[0].sequence(), 1);
100    /// ```
101    pub const fn sequence(&self) -> u64 {
102        self.sequence
103    }
104
105    /// Returns the retained-tree namespace that received the request.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use ailloli_ui_core::ElementId;
111    /// use ailloli_ui_runtime::app::{Invalidation, RuntimeHandle};
112    /// let handle = RuntimeHandle::<()>::new();
113    /// handle.invalidate(ElementId(1), Invalidation::Paint);
114    /// assert_eq!(handle.invalidation_diagnostics().records[0].element_tree_id(), handle.element_tree_id());
115    /// ```
116    pub const fn element_tree_id(&self) -> ElementTreeId {
117        self.element_tree_id
118    }
119
120    /// Returns the tree-local target element ID.
121    ///
122    /// # Examples
123    ///
124    /// ```
125    /// use ailloli_ui_core::ElementId;
126    /// use ailloli_ui_runtime::app::{Invalidation, RuntimeHandle};
127    /// let handle = RuntimeHandle::<()>::new();
128    /// handle.invalidate(ElementId(9), Invalidation::Paint);
129    /// assert_eq!(handle.invalidation_diagnostics().records[0].element_id(), ElementId(9));
130    /// ```
131    pub const fn element_id(&self) -> ElementId {
132        self.element_id
133    }
134
135    /// Returns the requested retained-work strength before coalescing.
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// use ailloli_ui_core::ElementId;
141    /// use ailloli_ui_runtime::app::{Invalidation, RuntimeHandle};
142    /// let handle = RuntimeHandle::<()>::new();
143    /// handle.invalidate(ElementId(1), Invalidation::Build);
144    /// assert_eq!(handle.invalidation_diagnostics().records[0].invalidation(), Invalidation::Build);
145    /// ```
146    pub const fn invalidation(&self) -> Invalidation {
147        self.invalidation
148    }
149
150    /// Returns the diagnostic origin supplied by the caller.
151    ///
152    /// # Examples
153    ///
154    /// ```
155    /// use ailloli_ui_core::ElementId;
156    /// use ailloli_ui_runtime::app::{Invalidation, InvalidationSource, RuntimeHandle};
157    /// let handle = RuntimeHandle::<()>::new();
158    /// handle.invalidate_from(ElementId(1), Invalidation::Paint, InvalidationSource::Model);
159    /// assert_eq!(handle.invalidation_diagnostics().records[0].source(), InvalidationSource::Model);
160    /// ```
161    pub const fn source(&self) -> InvalidationSource {
162        self.source
163    }
164
165    /// Returns whether the target already had a pending invalidation.
166    ///
167    /// The first request is false; subsequent requests before pending work is
168    /// taken are true even when they strengthen the retained-work level.
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use ailloli_ui_core::ElementId;
174    /// use ailloli_ui_runtime::app::{Invalidation, RuntimeHandle};
175    /// let handle = RuntimeHandle::<()>::new();
176    /// handle.invalidate(ElementId(1), Invalidation::Paint);
177    /// handle.invalidate(ElementId(1), Invalidation::Build);
178    /// let records = handle.invalidation_diagnostics().records;
179    /// assert!(!records[0].was_coalesced());
180    /// assert!(records[1].was_coalesced());
181    /// ```
182    pub const fn was_coalesced(&self) -> bool {
183        self.coalesced
184    }
185}
186
187/// Snapshot of requested work. The provenance ring is bounded even during a
188/// long-running chat or terminal session.
189///
190/// All aggregate counters saturate at `u64::MAX`. `records` contains at most
191/// [`INVALIDATION_PROVENANCE_CAPACITY`] newest requests in chronological order;
192/// `dropped_provenance_records` counts evictions, not lost aggregate requests.
193///
194/// # Examples
195///
196/// ```
197/// use ailloli_ui_runtime::app::InvalidationDiagnosticsSnapshot;
198/// let snapshot = InvalidationDiagnosticsSnapshot::default();
199/// assert_eq!(snapshot.requests, 0);
200/// assert!(snapshot.records.is_empty());
201/// ```
202#[derive(Debug, Clone, Default, PartialEq, Eq)]
203pub struct InvalidationDiagnosticsSnapshot {
204    /// Total recorded requests, saturating at `u64::MAX`.
205    pub requests: u64,
206    /// Requests whose tree/element already had pending work.
207    pub coalesced_requests: u64,
208    /// Paint-strength requests before coalescing.
209    pub paint_requests: u64,
210    /// Layout-strength requests before coalescing.
211    pub layout_requests: u64,
212    /// Build-strength requests before coalescing.
213    pub build_requests: u64,
214    /// Number of oldest provenance entries evicted from the bounded ring.
215    pub dropped_provenance_records: u64,
216    /// Newest bounded provenance entries in request order.
217    pub records: Vec<InvalidationRecord>,
218}
219
220#[derive(Default)]
221/// Mutable bounded invalidation counters owned by [`super::RuntimeInner`].
222///
223/// # Examples
224///
225/// Public callers inspect its immutable snapshot through a runtime handle:
226///
227/// ```
228/// use ailloli_ui_runtime::app::RuntimeHandle;
229/// let handle = RuntimeHandle::<()>::new();
230/// assert_eq!(handle.invalidation_diagnostics().requests, 0);
231/// ```
232pub(crate) struct InvalidationDiagnostics {
233    /// Next saturating sequence seed.
234    next_sequence: u64,
235    /// Aggregate counters; its records field is populated only during snapshot.
236    snapshot: InvalidationDiagnosticsSnapshot,
237    /// Bounded oldest-to-newest provenance ring.
238    records: VecDeque<InvalidationRecord>,
239}
240
241/// Provides the operations defined for InvalidationDiagnostics.
242impl InvalidationDiagnostics {
243    /// Saturating-increments aggregate counters and appends one bounded record.
244    ///
245    /// At capacity, the oldest record is removed before insertion and the
246    /// dropped counter advances. Sequence and all counters saturate rather than
247    /// wrap.
248    ///
249    /// # Examples
250    ///
251    /// The public runtime path invokes this recorder:
252    ///
253    /// ```
254    /// use ailloli_ui_core::ElementId;
255    /// use ailloli_ui_runtime::app::{Invalidation, RuntimeHandle};
256    /// let handle = RuntimeHandle::<()>::new();
257    /// handle.invalidate(ElementId(1), Invalidation::Layout);
258    /// assert_eq!(handle.invalidation_diagnostics().layout_requests, 1);
259    /// ```
260    pub(crate) fn record(
261        &mut self,
262        element_tree_id: ElementTreeId,
263        element_id: ElementId,
264        invalidation: Invalidation,
265        source: InvalidationSource,
266        coalesced: bool,
267    ) {
268        self.next_sequence = self.next_sequence.saturating_add(1);
269        self.snapshot.requests = self.snapshot.requests.saturating_add(1);
270        self.snapshot.coalesced_requests = self
271            .snapshot
272            .coalesced_requests
273            .saturating_add(u64::from(coalesced));
274        match invalidation {
275            Invalidation::Paint => {
276                self.snapshot.paint_requests = self.snapshot.paint_requests.saturating_add(1)
277            }
278            Invalidation::Layout => {
279                self.snapshot.layout_requests = self.snapshot.layout_requests.saturating_add(1)
280            }
281            Invalidation::Build => {
282                self.snapshot.build_requests = self.snapshot.build_requests.saturating_add(1)
283            }
284        }
285        if self.records.len() == INVALIDATION_PROVENANCE_CAPACITY {
286            self.records.pop_front();
287            self.snapshot.dropped_provenance_records =
288                self.snapshot.dropped_provenance_records.saturating_add(1);
289        }
290        self.records.push_back(InvalidationRecord {
291            sequence: self.next_sequence,
292            element_tree_id,
293            element_id,
294            invalidation,
295            source,
296            coalesced,
297        });
298    }
299
300    /// Clones aggregate counters and materializes the current provenance ring.
301    ///
302    /// Snapshotting is O(number of retained records), at most 256, and does not
303    /// clear or otherwise mutate diagnostics.
304    ///
305    /// # Examples
306    ///
307    /// ```
308    /// use ailloli_ui_runtime::app::RuntimeHandle;
309    /// let handle = RuntimeHandle::<()>::new();
310    /// let a = handle.invalidation_diagnostics();
311    /// let b = handle.invalidation_diagnostics();
312    /// assert_eq!(a, b);
313    /// ```
314    pub(crate) fn snapshot(&self) -> InvalidationDiagnosticsSnapshot {
315        let mut snapshot = self.snapshot.clone();
316        snapshot.records = self.records.iter().copied().collect();
317        snapshot
318    }
319}
320
321/// Permanent per-element counters for retained work actually executed.
322///
323/// Every field saturates at `u64::MAX`. Counters remain associated with an
324/// element until its retained-tree entry is removed; they count runtime work,
325/// not invalidation requests.
326///
327/// # Examples
328///
329/// ```
330/// use ailloli_ui_runtime::app::ElementWorkCounters;
331/// let counters = ElementWorkCounters::default();
332/// assert_eq!(counters.builds, 0);
333/// assert_eq!(counters.layout_cache_hits, 0);
334/// ```
335#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
336pub struct ElementWorkCounters {
337    /// Component build/reconciliation executions.
338    pub builds: u64,
339    /// Widget layout executions.
340    pub layouts: u64,
341    /// Widget paint executions.
342    pub paints: u64,
343    /// Element hit-test visits.
344    pub hit_tests: u64,
345    /// Retained layout cache hits.
346    pub layout_cache_hits: u64,
347    /// Retained layout cache misses.
348    pub layout_cache_misses: u64,
349    /// Layout invalidations propagated through the tree.
350    pub layout_propagations: u64,
351    /// Post-layout geometry commits.
352    pub layout_commits: u64,
353}
354
355/// Provides the operations defined for ElementWorkCounters.
356impl ElementWorkCounters {
357    /// Saturating-adds every category from `other`.
358    fn add_assign(&mut self, other: Self) {
359        self.builds = self.builds.saturating_add(other.builds);
360        self.layouts = self.layouts.saturating_add(other.layouts);
361        self.paints = self.paints.saturating_add(other.paints);
362        self.hit_tests = self.hit_tests.saturating_add(other.hit_tests);
363        self.layout_cache_hits = self
364            .layout_cache_hits
365            .saturating_add(other.layout_cache_hits);
366        self.layout_cache_misses = self
367            .layout_cache_misses
368            .saturating_add(other.layout_cache_misses);
369        self.layout_propagations = self
370            .layout_propagations
371            .saturating_add(other.layout_propagations);
372        self.layout_commits = self.layout_commits.saturating_add(other.layout_commits);
373    }
374}
375
376/// Snapshot for one retained element tree.
377///
378/// `totals` is computed by saturating-summing every value in `elements` at
379/// snapshot time. The map contains only elements for which work has been
380/// recorded and uses tree-local [`ElementId`] keys.
381///
382/// # Examples
383///
384/// ```
385/// use ailloli_ui_runtime::app::ElementTreeDiagnosticsSnapshot;
386/// let snapshot = ElementTreeDiagnosticsSnapshot::default();
387/// assert_eq!(snapshot.totals, Default::default());
388/// assert!(snapshot.elements.is_empty());
389/// ```
390#[derive(Debug, Clone, Default, PartialEq, Eq)]
391pub struct ElementTreeDiagnosticsSnapshot {
392    /// Saturating sum across all retained element counters.
393    pub totals: ElementWorkCounters,
394    /// Per-element counters for entries with recorded work.
395    pub elements: HashMap<ElementId, ElementWorkCounters>,
396}
397
398#[derive(Default)]
399/// Interior-mutable per-tree work-counter store.
400///
401/// # Examples
402///
403/// Public callers obtain snapshots from an [`crate::element::ElementTree`]:
404///
405/// ```
406/// use ailloli_ui_runtime::element::ElementTree;
407/// let tree = ElementTree::<()>::new();
408/// assert!(tree.diagnostics().elements.is_empty());
409/// ```
410pub(crate) struct ElementTreeDiagnostics {
411    /// Sparse tree-local counters behind runtime-internal interior mutability.
412    counters: std::cell::RefCell<HashMap<ElementId, ElementWorkCounters>>,
413}
414
415/// Provides the operations defined for ElementTreeDiagnostics.
416impl ElementTreeDiagnostics {
417    /// Creates a counter entry if needed and applies one synchronous update.
418    ///
419    /// # Panics
420    ///
421    /// Panics on a conflicting borrow of the internal [`std::cell::RefCell`].
422    fn update(&self, element_id: ElementId, update: impl FnOnce(&mut ElementWorkCounters)) {
423        update(self.counters.borrow_mut().entry(element_id).or_default());
424    }
425
426    /// Saturating-increments the element's build counter.
427    ///
428    /// # Examples
429    ///
430    /// ```
431    /// use ailloli_ui_runtime::element::ElementTree;
432    /// let tree = ElementTree::<()>::new();
433    /// assert_eq!(tree.diagnostics().totals.builds, 0);
434    /// ```
435    pub(crate) fn build(&self, id: ElementId) {
436        self.update(id, |value| value.builds = value.builds.saturating_add(1));
437    }
438
439    /// Saturating-increments the element's layout counter.
440    ///
441    /// # Examples
442    ///
443    /// ```
444    /// use ailloli_ui_runtime::element::ElementTree;
445    /// assert_eq!(ElementTree::<()>::new().diagnostics().totals.layouts, 0);
446    /// ```
447    pub(crate) fn layout(&self, id: ElementId) {
448        self.update(id, |value| value.layouts = value.layouts.saturating_add(1));
449    }
450
451    /// Saturating-increments the element's paint counter.
452    ///
453    /// # Examples
454    ///
455    /// ```
456    /// use ailloli_ui_runtime::element::ElementTree;
457    /// assert_eq!(ElementTree::<()>::new().diagnostics().totals.paints, 0);
458    /// ```
459    pub(crate) fn paint(&self, id: ElementId) {
460        self.update(id, |value| value.paints = value.paints.saturating_add(1));
461    }
462
463    /// Saturating-increments the element's hit-test counter.
464    ///
465    /// # Examples
466    ///
467    /// ```
468    /// use ailloli_ui_runtime::element::ElementTree;
469    /// assert_eq!(ElementTree::<()>::new().diagnostics().totals.hit_tests, 0);
470    /// ```
471    pub(crate) fn hit_test(&self, id: ElementId) {
472        self.update(id, |value| {
473            value.hit_tests = value.hit_tests.saturating_add(1)
474        });
475    }
476
477    /// Saturating-increments the element's layout-cache-hit counter.
478    ///
479    /// # Examples
480    ///
481    /// ```
482    /// use ailloli_ui_runtime::element::ElementTree;
483    /// assert_eq!(ElementTree::<()>::new().diagnostics().totals.layout_cache_hits, 0);
484    /// ```
485    pub(crate) fn layout_cache_hit(&self, id: ElementId) {
486        self.update(id, |value| {
487            value.layout_cache_hits = value.layout_cache_hits.saturating_add(1)
488        });
489    }
490
491    /// Saturating-increments the element's layout-cache-miss counter.
492    ///
493    /// # Examples
494    ///
495    /// ```
496    /// use ailloli_ui_runtime::element::ElementTree;
497    /// assert_eq!(ElementTree::<()>::new().diagnostics().totals.layout_cache_misses, 0);
498    /// ```
499    pub(crate) fn layout_cache_miss(&self, id: ElementId) {
500        self.update(id, |value| {
501            value.layout_cache_misses = value.layout_cache_misses.saturating_add(1)
502        });
503    }
504
505    /// Saturating-increments the element's layout-propagation counter.
506    ///
507    /// # Examples
508    ///
509    /// ```
510    /// use ailloli_ui_runtime::element::ElementTree;
511    /// assert_eq!(ElementTree::<()>::new().diagnostics().totals.layout_propagations, 0);
512    /// ```
513    pub(crate) fn layout_propagation(&self, id: ElementId) {
514        self.update(id, |value| {
515            value.layout_propagations = value.layout_propagations.saturating_add(1)
516        });
517    }
518
519    /// Saturating-increments the element's layout-commit counter.
520    ///
521    /// # Examples
522    ///
523    /// ```
524    /// use ailloli_ui_runtime::element::ElementTree;
525    /// assert_eq!(ElementTree::<()>::new().diagnostics().totals.layout_commits, 0);
526    /// ```
527    pub(crate) fn layout_commit(&self, id: ElementId) {
528        self.update(id, |value| {
529            value.layout_commits = value.layout_commits.saturating_add(1)
530        });
531    }
532
533    /// Removes all counters for one element, returning no missing-ID signal.
534    ///
535    /// # Examples
536    ///
537    /// ```
538    /// use ailloli_ui_runtime::element::ElementTree;
539    /// let tree = ElementTree::<()>::new();
540    /// assert!(tree.diagnostics().elements.is_empty());
541    /// ```
542    pub(crate) fn remove(&self, id: ElementId) {
543        self.counters.borrow_mut().remove(&id);
544    }
545
546    /// Clones sparse counters and computes saturating totals.
547    ///
548    /// The operation is O(number of elements with recorded work) and leaves the
549    /// live counters unchanged.
550    ///
551    /// # Examples
552    ///
553    /// ```
554    /// use ailloli_ui_runtime::element::ElementTree;
555    /// let tree = ElementTree::<()>::new();
556    /// assert_eq!(tree.diagnostics(), tree.diagnostics());
557    /// ```
558    pub(crate) fn snapshot(&self) -> ElementTreeDiagnosticsSnapshot {
559        let elements = self.counters.borrow().clone();
560        let mut totals = ElementWorkCounters::default();
561        for counters in elements.values().copied() {
562            totals.add_assign(counters);
563        }
564        ElementTreeDiagnosticsSnapshot { totals, elements }
565    }
566}