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}