Skip to main content

kimun_notes/components/text_editor/
widener_metrics.rs

1//! Counters for the hybrid widener's per-keystroke outcomes.
2//!
3//! Always on (atomic increments cost ~5ns). Surfaced when the env
4//! var `KIMUN_DUMP_WIDENER_METRICS=1` is set: [`dump_if_enabled`]
5//! prints the snapshot to stderr at app exit.
6//!
7//! Categories are exclusive — each call to
8//! `MarkdownEditorView::try_incremental_parse` increments exactly one
9//! of {`incremental_reset`, `incremental_fallback`,
10//! `full_line_count_change`, `full_kind_guard`, `full_lazy_depth`,
11//! `full_blank_transition`, `full_cap_trip`, `full_verify_failed`,
12//! `full_no_damage`}. `attempted` is the sum.
13//!
14//! Derived metrics the consumer cares about:
15//!
16//! - successful_incremental_rate
17//!   = (reset + fallback) / attempted
18//! - fast_path_share
19//!   = reset / (reset + fallback)
20//!   — climbing toward 1 means Option A (tighten reset_boundaries)
21//!   would eliminate the fallback path's overhead with low impact.
22//! - guard_sprawl
23//!   = (kind_guard + lazy_depth + blank_transition) / attempted
24//!   — high values mean the call-site guard tower is the bottleneck
25//!   and the underlying boundary model is too loose.
26//! - verify_hit_rate
27//!   = verify_failed / fallback
28//!   — non-zero proves widen_to_safe needs the verify in release;
29//!   zero across many sessions argues for demoting verify to debug.
30
31use std::sync::atomic::{AtomicU64, Ordering};
32
33/// Why `try_incremental_parse` did NOT take the splice path. One of
34/// these is recorded for every full rebuild; on success a separate
35/// `IncrementalReset` / `IncrementalFallback` is recorded.
36#[derive(Debug, Clone, Copy)]
37pub enum BailReason {
38    /// Line-count gate at the top of `try_incremental_parse`.
39    LineCountChange,
40    /// `compute_damage_range` returned None — no actual text change
41    /// despite the generation bump.
42    NoDamage,
43    /// One of the v1 `looks_like_*` flip checks or kind-was-marker
44    /// guards triggered.
45    KindGuard,
46    /// V2 `lazy_depth[row±1] > 0` guard triggered.
47    LazyDepth,
48    /// V2 blank ↔ non-blank transition with non-blank neighbour
49    /// triggered.
50    BlankTransition,
51    /// Both `expand_to_reset_boundary` AND `widen_to_safe` returned
52    /// `FullRebuild` — no widening fits under the caps.
53    CapTrip,
54    /// Post-slice undamaged-row verify (widen_to_safe fallback path)
55    /// detected a kinds/elements/content_vis divergence.
56    VerifyFailed,
57}
58
59/// Which widener produced the splice that succeeded.
60#[derive(Debug, Clone, Copy)]
61pub enum SuccessPath {
62    /// `expand_to_reset_boundary(reset_boundaries, ...)` succeeded —
63    /// the boundary set is known reset, so no post-slice verify
64    /// ran. Provably equivalent to a fresh parse.
65    ResetBoundary,
66    /// `widen_to_safe` succeeded after the strict reset-boundary
67    /// widener returned `FullRebuild`. The post-slice verify ran
68    /// and passed.
69    WidenToSafe,
70}
71
72pub struct WidenerMetrics {
73    pub incremental_reset: AtomicU64,
74    pub incremental_fallback: AtomicU64,
75    pub full_line_count_change: AtomicU64,
76    pub full_no_damage: AtomicU64,
77    pub full_kind_guard: AtomicU64,
78    pub full_lazy_depth: AtomicU64,
79    pub full_blank_transition: AtomicU64,
80    pub full_cap_trip: AtomicU64,
81    pub full_verify_failed: AtomicU64,
82}
83
84impl WidenerMetrics {
85    const fn new() -> Self {
86        Self {
87            incremental_reset: AtomicU64::new(0),
88            incremental_fallback: AtomicU64::new(0),
89            full_line_count_change: AtomicU64::new(0),
90            full_no_damage: AtomicU64::new(0),
91            full_kind_guard: AtomicU64::new(0),
92            full_lazy_depth: AtomicU64::new(0),
93            full_blank_transition: AtomicU64::new(0),
94            full_cap_trip: AtomicU64::new(0),
95            full_verify_failed: AtomicU64::new(0),
96        }
97    }
98
99    /// Record a full-rebuild outcome and return `None` so callers can
100    /// `return METRICS.bail(...)` in one line.
101    pub fn bail<T>(&self, reason: BailReason) -> Option<T> {
102        let counter = match reason {
103            BailReason::LineCountChange => &self.full_line_count_change,
104            BailReason::NoDamage => &self.full_no_damage,
105            BailReason::KindGuard => &self.full_kind_guard,
106            BailReason::LazyDepth => &self.full_lazy_depth,
107            BailReason::BlankTransition => &self.full_blank_transition,
108            BailReason::CapTrip => &self.full_cap_trip,
109            BailReason::VerifyFailed => &self.full_verify_failed,
110        };
111        counter.fetch_add(1, Ordering::Relaxed);
112        None
113    }
114
115    /// Record a successful incremental splice.
116    pub fn ok(&self, path: SuccessPath) {
117        let counter = match path {
118            SuccessPath::ResetBoundary => &self.incremental_reset,
119            SuccessPath::WidenToSafe => &self.incremental_fallback,
120        };
121        counter.fetch_add(1, Ordering::Relaxed);
122    }
123
124    /// Read every counter into a snapshot for printing/derivations.
125    pub fn snapshot(&self) -> Snapshot {
126        Snapshot {
127            incremental_reset: self.incremental_reset.load(Ordering::Relaxed),
128            incremental_fallback: self.incremental_fallback.load(Ordering::Relaxed),
129            full_line_count_change: self.full_line_count_change.load(Ordering::Relaxed),
130            full_no_damage: self.full_no_damage.load(Ordering::Relaxed),
131            full_kind_guard: self.full_kind_guard.load(Ordering::Relaxed),
132            full_lazy_depth: self.full_lazy_depth.load(Ordering::Relaxed),
133            full_blank_transition: self.full_blank_transition.load(Ordering::Relaxed),
134            full_cap_trip: self.full_cap_trip.load(Ordering::Relaxed),
135            full_verify_failed: self.full_verify_failed.load(Ordering::Relaxed),
136        }
137    }
138}
139
140pub static METRICS: WidenerMetrics = WidenerMetrics::new();
141
142#[derive(Debug, Clone, Copy)]
143pub struct Snapshot {
144    pub incremental_reset: u64,
145    pub incremental_fallback: u64,
146    pub full_line_count_change: u64,
147    pub full_no_damage: u64,
148    pub full_kind_guard: u64,
149    pub full_lazy_depth: u64,
150    pub full_blank_transition: u64,
151    pub full_cap_trip: u64,
152    pub full_verify_failed: u64,
153}
154
155impl Snapshot {
156    pub fn attempted(&self) -> u64 {
157        self.incremental_reset
158            + self.incremental_fallback
159            + self.full_line_count_change
160            + self.full_no_damage
161            + self.full_kind_guard
162            + self.full_lazy_depth
163            + self.full_blank_transition
164            + self.full_cap_trip
165            + self.full_verify_failed
166    }
167
168    pub fn successful_incremental(&self) -> u64 {
169        self.incremental_reset + self.incremental_fallback
170    }
171
172    pub fn successful_incremental_rate(&self) -> f64 {
173        let denom = self.attempted();
174        if denom == 0 {
175            0.0
176        } else {
177            self.successful_incremental() as f64 / denom as f64
178        }
179    }
180
181    /// Share of successful incremental splices taken by the strict
182    /// reset-boundary path. Mirror `heuristic_path_share` for the
183    /// other tier — together they sum to ≤ 1 (rounding aside).
184    pub fn fast_path_share(&self) -> f64 {
185        let denom = self.successful_incremental();
186        if denom == 0 {
187            0.0
188        } else {
189            self.incremental_reset as f64 / denom as f64
190        }
191    }
192
193    pub fn heuristic_path_share(&self) -> f64 {
194        let denom = self.successful_incremental();
195        if denom == 0 {
196            0.0
197        } else {
198            self.incremental_fallback as f64 / denom as f64
199        }
200    }
201
202    pub fn guard_sprawl_rate(&self) -> f64 {
203        let denom = self.attempted();
204        if denom == 0 {
205            0.0
206        } else {
207            (self.full_kind_guard + self.full_lazy_depth + self.full_blank_transition) as f64
208                / denom as f64
209        }
210    }
211
212    pub fn verify_hit_rate(&self) -> f64 {
213        // Verify runs on the widen_to_safe (heuristic) path only.
214        // Hit rate = verify_failed / (verify_failed + verify-eligible-success).
215        let denom = self.full_verify_failed + self.incremental_fallback;
216        if denom == 0 {
217            0.0
218        } else {
219            self.full_verify_failed as f64 / denom as f64
220        }
221    }
222}
223
224/// Print the snapshot to stderr when `KIMUN_DUMP_WIDENER_METRICS=1`.
225/// No-op otherwise. Intended for the app shutdown path.
226pub fn dump_if_enabled() {
227    if std::env::var("KIMUN_DUMP_WIDENER_METRICS").as_deref() != Ok("1") {
228        return;
229    }
230    let s = METRICS.snapshot();
231    eprintln!(
232        "[widener-metrics] session totals\n  \
233         incremental_reset           = {:>10}  ({:5.1}%)\n  \
234         incremental_fallback        = {:>10}  ({:5.1}%)\n  \
235         full_line_count_change      = {:>10}  ({:5.1}%)\n  \
236         full_no_damage              = {:>10}  ({:5.1}%)\n  \
237         full_kind_guard             = {:>10}  ({:5.1}%)\n  \
238         full_lazy_depth             = {:>10}  ({:5.1}%)\n  \
239         full_blank_transition       = {:>10}  ({:5.1}%)\n  \
240         full_cap_trip               = {:>10}  ({:5.1}%)\n  \
241         full_verify_failed          = {:>10}  ({:5.1}%)\n  \
242         attempted (categorised)     = {:>10}\n  \
243         ---\n  \
244         successful_incremental_rate = {:5.1}%\n  \
245         fast_path_share             = {:5.1}%\n  \
246         heuristic_path_share        = {:5.1}%\n  \
247         guard_sprawl_rate           = {:5.1}%\n  \
248         verify_hit_rate             = {:5.1}%",
249        s.incremental_reset,
250        pct(s.incremental_reset, s.attempted()),
251        s.incremental_fallback,
252        pct(s.incremental_fallback, s.attempted()),
253        s.full_line_count_change,
254        pct(s.full_line_count_change, s.attempted()),
255        s.full_no_damage,
256        pct(s.full_no_damage, s.attempted()),
257        s.full_kind_guard,
258        pct(s.full_kind_guard, s.attempted()),
259        s.full_lazy_depth,
260        pct(s.full_lazy_depth, s.attempted()),
261        s.full_blank_transition,
262        pct(s.full_blank_transition, s.attempted()),
263        s.full_cap_trip,
264        pct(s.full_cap_trip, s.attempted()),
265        s.full_verify_failed,
266        pct(s.full_verify_failed, s.attempted()),
267        s.attempted(),
268        s.successful_incremental_rate() * 100.0,
269        s.fast_path_share() * 100.0,
270        s.heuristic_path_share() * 100.0,
271        s.guard_sprawl_rate() * 100.0,
272        s.verify_hit_rate() * 100.0,
273    );
274}
275
276fn pct(numer: u64, denom: u64) -> f64 {
277    if denom == 0 {
278        0.0
279    } else {
280        (numer as f64 / denom as f64) * 100.0
281    }
282}