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