rlx_core/render/metrics.rs
1//! Pure image metrics over [`CaptureImage`]s (Plan 0013): pixel and shape
2//! difference plus coverage/spread, shared by the differential visual-QA tests
3//! and the `shot` CLI report.
4//!
5//! Everything here is a pure function of its input pixels — no GPU, no clock, no
6//! allocation beyond the small working buffers. Not a per-frame hot path, but it
7//! lives under `render/` so it carries the panic-denial pragma (and the hygiene
8//! guard needs it): written index- and panic-free throughout.
9
10#![deny(
11 clippy::unwrap_used,
12 clippy::expect_used,
13 clippy::indexing_slicing,
14 clippy::panic,
15 clippy::unreachable
16)]
17
18use super::CaptureImage;
19
20/// Grid the shape metric downscales to before edge detection (~32×32).
21const STRUCT_GRID: usize = 32;
22
23/// Mean absolute per-channel (RGB) difference between two images, normalized to
24/// `0.0..=1.0` (0 = identical, 1 = every channel maximally different). Mismatched
25/// dimensions read as fully different (`1.0`). Alpha is ignored — the capture
26/// background is opaque, so alpha carries no signal.
27pub fn frame_diff(a: &CaptureImage, b: &CaptureImage) -> f32 {
28 if a.width != b.width || a.height != b.height || a.rgba.len() != b.rgba.len() {
29 return 1.0;
30 }
31 let mut sum: u64 = 0;
32 let mut count: u64 = 0;
33 for (pa, pb) in a.rgba.chunks_exact(4).zip(b.rgba.chunks_exact(4)) {
34 for c in 0..3 {
35 if let (Some(&x), Some(&y)) = (pa.get(c), pb.get(c)) {
36 sum += x.abs_diff(y) as u64;
37 count += 1;
38 }
39 }
40 }
41 if count == 0 {
42 return 0.0;
43 }
44 sum as f32 / (count as f32 * 255.0)
45}
46
47/// Mean absolute per-channel (RGB) difference measured **over the union of lit
48/// pixels in the two frames** rather than over the whole frame, normalized to
49/// `0.0..=1.0` — the footprint statistic of ADR-0091 (Plan 0077 Phase 1).
50///
51/// [`frame_diff`] is a mean over every pixel, so a sparse figure's motion is
52/// averaged against the empty frame around it and the statistic scores
53/// *occupancy* — which Plan 0067 Phase 1d measured to be scale-invariant, so no
54/// render size recovers the dilution. This is the masked form ADR-0091 offers
55/// (chosen over `frame_diff / max(occupancy, eps)` because the quotient form
56/// keeps the whole-frame numerator, so backdrop drift outside the figure still
57/// leaks into a statistic that claims to be about the figure): a pixel is in
58/// the mask if it differs from `bg` by more than `eps` on any RGB channel in
59/// **either** frame, and the mean is taken over the mask only.
60///
61/// The denominator is floored at `min_lit_frac` of the frame's pixels — the
62/// guard ADR-0091 requires, without which a one-pixel flicker in a nearly-empty
63/// frame reads as strong animation (one full-swing pixel over a mask of one is
64/// `1.0`). Callers state their bound and its derivation; a mask at or under the
65/// floor means the statistic is reporting "near-invisible at this size" rather
66/// than measuring motion, which is a finding for the *coverage* gate, not this
67/// one.
68///
69/// Mismatched dimensions read as fully different (`1.0`); an empty mask over a
70/// zero floor reads `0.0` (nothing lit in either frame is no motion, not a
71/// division). Alpha is ignored, as in [`frame_diff`].
72pub fn footprint_diff(
73 a: &CaptureImage,
74 b: &CaptureImage,
75 bg: [u8; 4],
76 eps: u8,
77 min_lit_frac: f32,
78) -> f32 {
79 if a.width != b.width || a.height != b.height || a.rgba.len() != b.rgba.len() {
80 return 1.0;
81 }
82 let mut sum: u64 = 0;
83 let mut mask: u64 = 0;
84 let mut total: u64 = 0;
85 for (pa, pb) in a.rgba.chunks_exact(4).zip(b.rgba.chunks_exact(4)) {
86 total += 1;
87 if !is_lit(pa, bg, eps) && !is_lit(pb, bg, eps) {
88 continue;
89 }
90 mask += 1;
91 for c in 0..3 {
92 if let (Some(&x), Some(&y)) = (pa.get(c), pb.get(c)) {
93 sum += x.abs_diff(y) as u64;
94 }
95 }
96 }
97 let floor = (total as f32 * min_lit_frac.clamp(0.0, 1.0)).ceil() as u64;
98 let denom = mask.max(floor);
99 if denom == 0 {
100 return 0.0;
101 }
102 sum as f32 / (denom as f32 * 3.0 * 255.0)
103}
104
105/// Shape-aware difference in `0.0..=1.0`: downscale each image to a small
106/// grayscale grid, take the Sobel edge magnitude, normalize each edge map by its
107/// own peak, and mean-abs-diff them. Normalizing per-image cancels overall
108/// contrast, so a **recolor of the same shape** scores low while a **different
109/// shape** scores high — the near-duplicate probe (an approximation of SSIM).
110pub fn struct_diff(a: &CaptureImage, b: &CaptureImage) -> f32 {
111 let ea = normalize_max(&sobel(&downscale_gray(a)));
112 let eb = normalize_max(&sobel(&downscale_gray(b)));
113 let mut sum = 0.0f32;
114 let mut count = 0.0f32;
115 for (x, y) in ea.iter().zip(eb.iter()) {
116 sum += (x - y).abs();
117 count += 1.0;
118 }
119 if count == 0.0 {
120 return 0.0;
121 }
122 (sum / count).clamp(0.0, 1.0)
123}
124
125/// Fraction of pixels whose RGB differs from `bg` by more than `eps` on any
126/// channel — "how much of the frame is lit" (`0.0..=1.0`). Alpha is ignored.
127pub fn coverage(img: &CaptureImage, bg: [u8; 4], eps: u8) -> f32 {
128 let mut lit: u64 = 0;
129 let mut total: u64 = 0;
130 for px in img.rgba.chunks_exact(4) {
131 total += 1;
132 if is_lit(px, bg, eps) {
133 lit += 1;
134 }
135 }
136 if total == 0 {
137 return 0.0;
138 }
139 lit as f32 / total as f32
140}
141
142/// How many of the four image quadrants contain at least one lit pixel
143/// (`0..=4`) — a cheap "not just a dot in one corner" spread check.
144pub fn quadrant_spread(img: &CaptureImage, bg: [u8; 4], eps: u8) -> u8 {
145 let w = img.width as usize;
146 let h = img.height as usize;
147 if w == 0 || h == 0 {
148 return 0;
149 }
150 let mut hit = [false; 4];
151 for (i, px) in img.rgba.chunks_exact(4).enumerate() {
152 if !is_lit(px, bg, eps) {
153 continue;
154 }
155 let x = i % w;
156 let y = i / w;
157 let qx = usize::from(x >= w / 2);
158 let qy = usize::from(y >= h / 2);
159 if let Some(slot) = hit.get_mut(qy * 2 + qx) {
160 *slot = true;
161 }
162 }
163 hit.iter().filter(|&&b| b).count() as u8
164}
165
166/// Luminance buckets [`tonal_flatness`] histograms into. 16 over the 0..255
167/// range makes each bucket 16 levels wide — narrow enough that a figure with any
168/// modelling at all spreads across several, wide enough that dithering and
169/// 8-bit quantization do not split one tone in two.
170pub const TONE_BANDS: usize = 16;
171
172/// Share of the **lit** figure whose luminance falls inside the single most
173/// populated narrow luminance band (`0.0..=1.0`) — "does this picture have any
174/// tonal structure".
175///
176/// `coverage` and `quadrant_spread` answer *is something there* and *is it more
177/// than a dot*, and a fully saturated single-tone mass satisfies both: it is a
178/// real shape, of the right size, in every quadrant. This asks the question they
179/// cannot — whether the shape has any interior. A figure with falloff, depth or
180/// modelling spreads across several buckets; one driven past the tonemap knee
181/// collapses into one and reads near `1.0`.
182///
183/// Measured over lit pixels only, against the frame's own sampled background,
184/// for the same reason `coverage` is: a sparse figure on a wide ground would
185/// otherwise report the *background's* flatness, which is total by construction
186/// and says nothing about the scene.
187///
188/// `0.0` for a frame with no lit pixels at all — an empty picture makes no claim
189/// here, and `coverage` is the metric that already convicts it.
190pub fn tonal_flatness(img: &CaptureImage, bg: [u8; 4], eps: u8) -> f32 {
191 let mut buckets = [0u64; TONE_BANDS];
192 let mut lit: u64 = 0;
193 for px in img.rgba.chunks_exact(4) {
194 if !is_lit(px, bg, eps) {
195 continue;
196 }
197 lit += 1;
198 let bucket = ((luma(px) / 256.0) * TONE_BANDS as f32) as usize;
199 if let Some(slot) = buckets.get_mut(bucket.min(TONE_BANDS - 1)) {
200 *slot += 1;
201 }
202 }
203 if lit == 0 {
204 return 0.0;
205 }
206 buckets.iter().copied().max().unwrap_or(0) as f32 / lit as f32
207}
208
209/// Perimeter of the lit figure over its area: the share of lit pixels having at
210/// least one **unlit** 4-neighbour (`0.0..=1.0`) — "is the lit set a solid mass,
211/// or does it have interior?"
212///
213/// This is the second term of the flatness gate (ADR-0128, settled by ADR-0130).
214/// [`tonal_flatness`] asks whether the figure has any *tonal* structure and
215/// convicts a two-ink print for having exactly two tones, which is what that
216/// idiom is; this asks the orthogonal question, and a picture is called a blot
217/// only when both say so.
218///
219/// A solid mass carries only its rim on the boundary and reads low; a hatched,
220/// stroked or tiled figure is almost all rim and reads near one. **Both halves of
221/// that are claims at one capture size and not properties of the figure** — the
222/// resolution paragraph below is what qualifies them, and a solid mass small
223/// enough reads `1.0000` exactly as a hatched one does.
224/// **The denominator is the lit area, not the frame's**,
225/// which is what keeps the statistic asking one question: normalizing by frame
226/// area would make a frame score higher merely for having more lit material,
227/// and *how much is lit* is [`coverage`], another term of the same gate.
228///
229/// Frame edges count as **unlit**, so a figure running off the frame counts that
230/// as boundary. The alternative — edges as lit — would let a fullscreen fill
231/// read as having no perimeter at all, which is the one answer this statistic
232/// must not give.
233///
234/// **It is bound to the capture's resolution and is comparable only at a fixed
235/// one.** Perimeter over area goes as ~`1/L` in the capture's linear size, so
236/// the same scene at 192×192 reads roughly half what it reads at 96×96, and a
237/// solid disc of radius `r` px reads about `2/r` — which is why a 4×4 solid
238/// block reads `1.0000` and a large one does not. Every floor derived from this
239/// statistic is measured at the sanity suite's 96×96 capture, and neither the
240/// numbers nor the ordering carry to another size.
241///
242/// It reads pixel-scale perimeter, so a **ragged** mass defeats it: a particle
243/// blot noisier than the fixture the threshold was measured on has more
244/// perimeter per lit pixel than a composition does. That is the known decay mode
245/// and ADR-0130 records it as accepted rather than solved.
246///
247/// `0.0` for a frame with no lit pixels — the convention [`tonal_flatness`]
248/// uses, and [`coverage`] is the metric that already convicts an empty picture.
249pub fn boundary_density(img: &CaptureImage, bg: [u8; 4], eps: u8) -> f32 {
250 let (w, h) = (img.width as usize, img.height as usize);
251 if w == 0 || h == 0 {
252 return 0.0;
253 }
254 let mask: Vec<bool> = img
255 .rgba
256 .chunks_exact(4)
257 .map(|px| is_lit(px, bg, eps))
258 .collect();
259 let at = |x: isize, y: isize| -> bool {
260 if x < 0 || y < 0 || x >= w as isize || y >= h as isize {
261 return false;
262 }
263 mask.get(y as usize * w + x as usize)
264 .copied()
265 .unwrap_or(false)
266 };
267 let (mut lit, mut edge) = (0u64, 0u64);
268 for y in 0..h as isize {
269 for x in 0..w as isize {
270 if !at(x, y) {
271 continue;
272 }
273 lit += 1;
274 if !at(x - 1, y) || !at(x + 1, y) || !at(x, y - 1) || !at(x, y + 1) {
275 edge += 1;
276 }
277 }
278 }
279 if lit == 0 {
280 return 0.0;
281 }
282 edge as f32 / lit as f32
283}
284
285/// Ratio of the frame's **peak** departure from its background luminance to the
286/// **mean** departure over every pixel — the crest factor, and the direct
287/// reading of *has the population piled onto a few places?* (Plan 0085 Phase 1).
288///
289/// A pixel's departure is `|luma - luma(bg)|`, and zero for any pixel
290/// [`coverage`] would not call lit (so 8-bit dither and a vignette's own
291/// gradient do not inflate the denominator). The **absolute** difference, not
292/// the signed one, because a two-tone world draws its figure *darker* than its
293/// ground (ADR-0106) and an ink figure piling up is the same event as a
294/// particle field piling up. The mean is taken over **every** pixel, not over
295/// the lit ones — that is what makes concentration visible. Move a fixed amount
296/// of contrast from many pixels into few and the sum barely changes while the
297/// peak rises, so the ratio rises with it; spread it back out and it falls
298/// toward `1.0`.
299///
300/// Range: `1.0` for a perfectly uniform lit frame, up to the frame's pixel count
301/// for a single lit pixel, and exactly `0.0` for a frame that does not depart
302/// from its own background at all — an empty picture makes no claim about
303/// concentration, the same convention [`tonal_flatness`] uses. Total by
304/// construction: the peak is itself part of the sum, so a non-zero peak
305/// guarantees a non-zero denominator.
306///
307/// **It saturates, and a caller must know that.** The peak is 8-bit, so once the
308/// brightest pixel reaches white the numerator stops growing and further piling
309/// registers only through the falling mean. Read it as a *trend* — which is why
310/// the horizon mode reports a series and never a threshold (ADR-0099).
311pub fn peak_to_mean(img: &CaptureImage, bg: [u8; 4], eps: u8) -> f32 {
312 let bg_luma = luma(&bg);
313 let mut peak = 0.0f32;
314 let mut sum = 0.0f64;
315 let mut total: u64 = 0;
316 for px in img.rgba.chunks_exact(4) {
317 total += 1;
318 if !is_lit(px, bg, eps) {
319 continue;
320 }
321 let departure = (luma(px) - bg_luma).abs();
322 peak = peak.max(departure);
323 sum += f64::from(departure);
324 }
325 if total == 0 || peak <= 0.0 {
326 return 0.0;
327 }
328 let mean = (sum / total as f64) as f32;
329 // `peak` is one of the terms of `sum`, so `mean >= peak / total > 0` here.
330 peak / mean
331}
332
333/// Mean **linear light** over the lit set — the level statistic (ADR-0150), in
334/// `0.0..=1.0`. `0.0` for a frame with no lit pixels, the convention
335/// [`tonal_flatness`] and [`boundary_density`] use.
336///
337/// Every other statistic in this module answers a *shape* question. This one
338/// answers *how much light does the picture carry*, which is the question a
339/// retune asks when it wants to hold level constant across a change, and it is
340/// the one question that cannot be asked on the stored bytes: sRGB's transfer
341/// curve is concave, so an encoded mean under-reports a trim by roughly half.
342/// `linear_diff` carries the same reasoning for a two-frame comparison. It is
343/// private, so this names it rather than linking it.
344///
345/// **The lit predicate is [`coverage`]'s, so this is linear light over a
346/// CODE-SPACE-selected set.** ADR-0150 records why that seam is accepted rather
347/// than solved: a lit predicate in linear light would change `coverage` itself
348/// and move blessed baselines across the whole suite, for no gain to the
349/// question being asked here. A reader who does not know that will eventually
350/// "fix" the wrong half.
351///
352/// **Restricted to the lit set, not the frame**, which is the substantive half.
353/// A preset's background is a deliberate authored constant; folding it into the
354/// level makes the reading mostly a measurement of that constant, and a frame
355/// mean read a 30 % source trim as 3 % on the fixture that motivated this.
356/// The corollary is a blind spot: a preset that goes wrong *by changing its
357/// background* is invisible here.
358///
359/// Luminance weights are Rec.709 (`0.2126/0.7152/0.0722`), not the Rec.601 that
360/// `luma` applies to code values — those are the luminance coefficients of
361/// sRGB's own primaries, and they are what every other linear-light reading in
362/// this workspace uses.
363pub fn mean_lit_level(img: &CaptureImage, bg: [u8; 4], eps: u8) -> f32 {
364 let lut = srgb_decode_lut();
365 let decode = |px: &[u8], c: usize| -> f32 {
366 lut.get(px.get(c).copied().unwrap_or(0) as usize)
367 .copied()
368 .unwrap_or(0.0)
369 };
370 let mut sum = 0.0f64;
371 let mut lit: u64 = 0;
372 for px in img.rgba.chunks_exact(4) {
373 if !is_lit(px, bg, eps) {
374 continue;
375 }
376 lit += 1;
377 sum += f64::from(0.2126 * decode(px, 0) + 0.7152 * decode(px, 1) + 0.0722 * decode(px, 2));
378 }
379 if lit == 0 {
380 return 0.0;
381 }
382 (sum / lit as f64) as f32
383}
384
385/// Rec.601 luma of a pixel's first three channels — the same weights
386/// [`downscale_gray`] and [`tonal_flatness`] use, so "luminance" means one thing
387/// across this module. Tolerates a short slice (missing channels read zero).
388fn luma(px: &[u8]) -> f32 {
389 0.299 * px.first().copied().unwrap_or(0) as f32
390 + 0.587 * px.get(1).copied().unwrap_or(0) as f32
391 + 0.114 * px.get(2).copied().unwrap_or(0) as f32
392}
393
394// ---------------------------------------------------------------------------
395// The frame's own ground (Plan 0116 Phase 3, ADR-0126)
396// ---------------------------------------------------------------------------
397
398/// The reference tone [`modal_ground`] returns for a frame that has no ground —
399/// black, the same value a caller with no ground of its own supplies.
400///
401/// A groundless frame is therefore measured against black either way, so the
402/// fallback is a no-op rather than a second behaviour to reason about.
403pub const NO_GROUND: [u8; 4] = [0, 0, 0, 255];
404
405/// Minimum share of a frame that its modal luminance band must hold before that
406/// band is called a ground.
407///
408/// **Derived, not tuned.** A uniform luminance distribution puts exactly
409/// `1 / TONE_BANDS` of the frame in each band, so a modal band holding no more
410/// than that share is the definition of *no band is dominant* — there is
411/// nothing to call a ground, and [`modal_ground`] returns [`NO_GROUND`].
412///
413/// **It is a floor on a maximum, so it is inert against real content, and that
414/// is the honest reading of it.** The largest of `TONE_BANDS` counts is at least
415/// their mean, with equality only for a perfectly flat histogram, so this fires
416/// only on a frame whose luminance is near-exactly uniform. Measured over the
417/// shipped library at `LOUD` (Plan 0116 Phase 3, 2026-08-26): the smallest modal
418/// band share is `Clifford`'s `0.1590`, two and a half times this line, and
419/// **no shipped preset falls back**. The rule defines the boundary case rather
420/// than reaching any content — which is what Phase 3 asked for, a behaviour
421/// defined in code rather than discovered later.
422pub const MIN_GROUND_SHARE: f32 = 1.0 / TONE_BANDS as f32;
423
424/// The frame's own ground: the mean RGB of its most populous luminance band, or
425/// [`NO_GROUND`] when no band holds more than [`MIN_GROUND_SHARE`] of it.
426///
427/// Everything built on `is_lit` — [`coverage`], [`quadrant_spread`],
428/// [`radial_shell_occupancy`], [`tonal_flatness`] — asks *how far does this
429/// pixel depart from the ground*, and passing a constant `BLACK` encodes an
430/// unstated precondition: that the scene draws light onto a ground it does not
431/// own. A scene that paints its own paper breaks it, and reads `coverage`
432/// exactly `1.0` whatever it drew (ADR-0126). This derives the reference from
433/// the frame instead, so the same question is asked correctly in both worlds.
434///
435/// **The mean of the band's members, not the band's centre.** An ink-on-paper
436/// world's paper is a specific off-white; rounding it to the middle of a
437/// 16-level band would hand `is_lit` a reference the frame does not contain,
438/// and at `EPS`-scale tolerances that is the difference between a ground and a
439/// second figure.
440///
441/// **Luminance, not RGB.** Plan 0116 Phase 1 tabled a coarse-RGB cluster and a
442/// border-only band beside this one over the whole shipped library: all three
443/// re-based the same presets, cost the same zero verdict changes, and repaired
444/// the same nothing. This is the simplest of the three that measured
445/// equivalent — the border variant assumes the ground reaches the frame edge,
446/// and the RGB variant buys a sparser histogram, neither for any measured
447/// return.
448///
449/// Ties resolve to the **brightest** tied band (the last maximum), which is
450/// arbitrary but deterministic — a duotone at equal populations has two grounds
451/// and no estimator over one histogram can pick between them.
452pub fn modal_ground(img: &CaptureImage) -> [u8; 4] {
453 let mut counts = [0u64; TONE_BANDS];
454 let mut sums = [[0u64; 3]; TONE_BANDS];
455 let mut total: u64 = 0;
456 for px in img.rgba.chunks_exact(4) {
457 total += 1;
458 let band = ((luma(px) / 256.0) * TONE_BANDS as f32) as usize;
459 let band = band.min(TONE_BANDS - 1);
460 if let (Some(count), Some(sum)) = (counts.get_mut(band), sums.get_mut(band)) {
461 *count += 1;
462 for c in 0..3 {
463 if let (Some(slot), Some(&v)) = (sum.get_mut(c), px.get(c)) {
464 *slot += u64::from(v);
465 }
466 }
467 }
468 }
469 let Some((best, &n)) = counts.iter().enumerate().max_by_key(|&(_, &n)| n) else {
470 return NO_GROUND;
471 };
472 // `n * TONE_BANDS <= total` is `n / total <= MIN_GROUND_SHARE` without the
473 // division — exact in integers, where the f32 quotient is not.
474 if n == 0 || n * TONE_BANDS as u64 <= total {
475 return NO_GROUND;
476 }
477 match sums.get(best) {
478 Some(s) => [
479 (s.first().copied().unwrap_or(0) / n) as u8,
480 (s.get(1).copied().unwrap_or(0) / n) as u8,
481 (s.get(2).copied().unwrap_or(0) / n) as u8,
482 255,
483 ],
484 None => NO_GROUND,
485 }
486}
487/// Concentric annuli [`radial_shell_occupancy`] divides the frame's inscribed
488/// disc into. Ten equal-radius shells is the granularity the Plan 0065 lane's
489/// one-off prototype measured with when it separated the four-ring mandala from
490/// the bare rosette (9 shells against 1, design-backlog 0072), and it is kept:
491/// coarse enough that a 96×96 capture gives the innermost shell a usable pixel
492/// count (~70), fine enough that "occupies most shells" cannot be satisfied by
493/// one ring and a halo.
494pub const RADIAL_SHELLS: usize = 10;
495
496/// Minimum share of a shell's own pixels that must be lit for the shell to
497/// count as occupied in [`radial_shell_occupancy`].
498///
499/// **Checked against both sides by measurement** (Plan 0075 Phase 1). The
500/// failure this guards against is a stray near-threshold pixel marking an
501/// empty shell occupied; the content it must not disenfranchise is a hairline
502/// stroke crossing a shell. At the sanity suite's 96×96 capture, shell `k`
503/// holds ~72·(2k+1) pixels (~72 innermost, ~1370 outermost), so 2 % asks for
504/// roughly 2–28 lit pixels per shell — above stray-pixel scale, far below any
505/// real stroke. Measured at this threshold: the three honest ring-mandala
506/// tunings (backlog 0072's evidence — `glow = 1.0`, no `trails`) read
507/// **10 / 10 / 9** occupied shells, every shipped preset reads ≥ 3, and the
508/// frozen renders-nothing defect (the pre-repair `spectrum_ridge`, its contour
509/// off frame) reads exactly **0** — the threshold separates honest-thin from
510/// absent by the measure's whole range.
511pub const MIN_SHELL_LIT: f32 = 0.02;
512
513/// How many of [`RADIAL_SHELLS`] concentric equal-radius annuli over the
514/// frame's inscribed disc contain a meaningful share of lit pixels
515/// (`0..=RADIAL_SHELLS`) — a **structural occupancy** measure: *at how many
516/// radii does this picture exist?*
517///
518/// [`coverage`] counts lit pixels, which at capture size measures a thin-stroke
519/// figure's halo rather than its geometry: at 96×96 the bare rosette and a
520/// 46×-denser four-ring mandala score identically, and 54 % more geometry moves
521/// the number 2.6 % (design-backlog 0072). This asks the question that actually
522/// separates them — the mandala exists at nine of ten radii, the rosette's
523/// interlace band at one to three — and it cannot be bought with `glow` or
524/// `trails`, because inflating the halo around a stroke does not move which
525/// shells the stroke lives in.
526///
527/// Pixels outside the inscribed disc (the frame's corners) are ignored: the
528/// measure is radial, and the corners exist at radii only a diagonal figure
529/// reaches. `0` for a frame with no lit pixels — a scene that renders nothing
530/// occupies nothing, which is the one conviction the coverage floor demonstrably
531/// gets right and this measure must preserve.
532pub fn radial_shell_occupancy(img: &CaptureImage, bg: [u8; 4], eps: u8) -> usize {
533 let w = img.width as usize;
534 let h = img.height as usize;
535 if w == 0 || h == 0 {
536 return 0;
537 }
538 let (cx, cy) = (w as f32 * 0.5, h as f32 * 0.5);
539 let radius = w.min(h) as f32 * 0.5;
540 let mut lit = [0u32; RADIAL_SHELLS];
541 let mut total = [0u32; RADIAL_SHELLS];
542 for (i, px) in img.rgba.chunks_exact(4).enumerate() {
543 let x = (i % w) as f32 + 0.5;
544 let y = (i / w) as f32 + 0.5;
545 let r = ((x - cx).powi(2) + (y - cy).powi(2)).sqrt() / radius;
546 if r >= 1.0 {
547 continue;
548 }
549 let shell = ((r * RADIAL_SHELLS as f32) as usize).min(RADIAL_SHELLS - 1);
550 if let Some(t) = total.get_mut(shell) {
551 *t += 1;
552 }
553 if is_lit(px, bg, eps)
554 && let Some(l) = lit.get_mut(shell)
555 {
556 *l += 1;
557 }
558 }
559 lit.iter()
560 .zip(total.iter())
561 .filter(|&(&l, &t)| t > 0 && l as f32 / t as f32 >= MIN_SHELL_LIT)
562 .count()
563}
564
565// ---------------------------------------------------------------------------
566// Step response — how fast the frame reaches its new steady state (Plan 0037)
567// ---------------------------------------------------------------------------
568
569/// Fraction of a step's total change the response must reach to count as
570/// settled. 0.9 is the textbook rise-time convention, and it is the one the
571/// one-pole arithmetic in ADR-0019 is quoted against: a smoother with time
572/// constant `tau` reaches it at `t = tau * ln(10) = 2.303 * tau`.
573pub const SETTLE_FRAC: f32 = 0.9;
574
575/// How many frames a captured response took to settle after a step up and after
576/// the matching step down (Plan 0037, ADR-0039).
577///
578/// The whole point of ADR-0035's `{ attack, release }` pair is that these two
579/// differ; a scalar `[smoothing]` entry makes them equal by construction.
580#[derive(Debug, Clone, Copy, PartialEq, Eq)]
581pub struct StepResponse {
582 /// Frames from the step up until the frame settled at [`SETTLE_FRAC`].
583 pub rise_frames: u32,
584 /// Frames from the step down until the frame settled at [`SETTLE_FRAC`].
585 pub fall_frames: u32,
586}
587
588impl StepResponse {
589 /// `fall / rise` — the asymmetry, which is the number that reads.
590 ///
591 /// **This is a pixel-domain ratio, not a parameter-domain one.** A scene's
592 /// response to its own parameter is rarely linear, so the value differs from
593 /// the ratio of the `[smoothing]` constants themselves (ADR-0039); only its
594 /// distance from 1.0 is meaningful. A frame that never moved reports
595 /// `0.0` rather than dividing by zero.
596 pub fn ratio(self) -> f32 {
597 self.fall_frames as f32 / self.rise_frames.max(1) as f32
598 }
599}
600
601/// Measure a step response from two captured segments: `rise` starting at the
602/// last frame *before* the step up, `fall` starting at the last frame before the
603/// step down. Each segment's own last frame is taken as its settled state.
604///
605/// Both segments should be the **same length**, because each is normalized
606/// against its own final frame: a segment that has not fully settled
607/// underestimates the total change and so settles early.
608///
609/// **Equal windows do not make that bias cancel** (Plan 0038 Phase 8 corrected
610/// the reverse claim, which had been written here). Cancellation would need both
611/// directions to be truncated by the same fraction, which is exactly what an `{
612/// attack, release }` pair is built not to do: at `attack = 0.02` against a
613/// `release = 0.5` the rise finishes in 80 τ and carries **no** bias at all, so
614/// the fall's has nothing to cancel against and passes straight into
615/// [`StepResponse::ratio`]. That is not hypothetical — it is how this repo's own
616/// asymmetric probe reported a fall of 61 frames where the settled answer is 69.
617///
618/// Equal windows remain the right default. They are just not a guarantee:
619/// **gate on [`segment_settled`] before trusting either number.**
620pub fn step_response(rise: &[CaptureImage], fall: &[CaptureImage]) -> StepResponse {
621 StepResponse {
622 rise_frames: frames_to_settle(rise, SETTLE_FRAC),
623 fall_frames: frames_to_settle(fall, SETTLE_FRAC),
624 }
625}
626
627/// Index of the first frame in `segment` whose distance from `segment[0]` has
628/// reached `settle_frac` of the distance between the first and last frames.
629///
630/// `segment[0]` is the state at the step and the last entry is the settled
631/// state, so the answer is in frames-since-the-step. A segment that never moves
632/// (total change at or below the float epsilon) reports `0` — the honest answer
633/// for a preset the stimulus does not reach, and the one that keeps
634/// [`StepResponse::ratio`] finite.
635pub fn frames_to_settle(segment: &[CaptureImage], settle_frac: f32) -> u32 {
636 let (Some(start), Some(end)) = (segment.first(), segment.last()) else {
637 return 0;
638 };
639 let total = linear_diff(start, end);
640 if total <= f32::EPSILON {
641 return 0;
642 }
643 let target = total * settle_frac.clamp(0.0, 1.0);
644 for (i, img) in segment.iter().enumerate() {
645 if linear_diff(start, img) >= target {
646 return i as u32;
647 }
648 }
649 segment.len().saturating_sub(1) as u32
650}
651
652/// Whether `segment`'s last frame is close enough to its asymptote for
653/// [`frames_to_settle`] to mean anything — the question that function cannot
654/// answer about itself (Plan 0038 Phase 7).
655///
656/// **Why this is needed at all.** [`frames_to_settle`] normalizes against the
657/// segment's *own last frame*. When that frame is still travelling, the measured
658/// total is short and every threshold is crossed early — and the returned frame
659/// count is a plausible-looking number rather than an obvious failure, because
660/// normalizing against the last frame *guarantees* the threshold is reached
661/// inside the segment. So `frames_to_settle(seg, f) < seg.len()` is a tautology,
662/// not a check, and a caller has no way to tell *settled at frame k* from *still
663/// moving at frame k*. Plan 0038 Phase 3 read a truncated window as a shape
664/// difference between two orderings on exactly this basis; see ADR-0040's
665/// Outcome.
666///
667/// **The rule.** A settling response's change per unit time decays
668/// geometrically, so the tail beyond the last frame can be extrapolated without
669/// knowing the time constant. Sample three points at equal spacing `h` — the
670/// first frame `A`, the midpoint `B`, the last `C` — and for an exponential
671/// approach `|C - B| / |B - A|` is `exp(-h/tau)`, whatever `tau` is. The travel
672/// still to come after `C` is then `|C - B| * rho / (1 - rho)`. Settled means
673/// that estimate is under `tol` of the change measured so far.
674///
675/// **The three points are spread across the whole segment on purpose, not taken
676/// from the end.** Captures are 8-bit, and a response slow enough to outrun its
677/// window moves by *less than one code value per frame* near the end — the
678/// residual is large but each individual step is sub-quantum, so consecutive
679/// frames decode as identical and any estimator reading adjacent deltas concludes
680/// "flat, therefore settled" precisely in the case worth catching. Half a segment
681/// of travel is always far above the quantum. (Measured while building this: at
682/// `tau` = 2 s over a 2 s window the per-frame step is ~0.003 linear against a
683/// ~0.004 quantum at that brightness, and the adjacent-frame version of this
684/// function reported the response settled with 37 % of its travel left.)
685///
686/// Assumes a monotone approach, which every one-pole in this engine is — a
687/// response that overshoots is outside what this can judge.
688///
689/// This deliberately does **not** change [`frames_to_settle`] or
690/// [`step_response`], whose numbers `shot --report` publishes for the whole
691/// shipped library. Use it as the gate *before* trusting one of those numbers.
692pub fn segment_settled(segment: &[CaptureImage], tol: f32) -> bool {
693 let (Some(start), Some(end)) = (segment.first(), segment.last()) else {
694 return true; // Nothing captured: no claim to invalidate.
695 };
696 let total = linear_diff(start, end);
697 if total <= f32::EPSILON {
698 return true; // Never moved; `frames_to_settle` reports 0 and says so.
699 }
700 // Equal spacing is what makes the ratio below a pure function of tau.
701 let Some(mid) = segment.get(segment.len() / 2) else {
702 return false; // Too short to see a trend — assume nothing.
703 };
704 let (first_half, second_half) = (linear_diff(start, mid), linear_diff(mid, end));
705 if first_half <= f32::EPSILON {
706 return false; // No motion in the first half: nothing to extrapolate from.
707 }
708 let rho = second_half / first_half;
709 if !(0.0..1.0).contains(&rho) {
710 return false; // Not decaying: still ramping, or accelerating.
711 }
712 let remaining = second_half * rho / (1.0 - rho);
713 remaining <= tol * total
714}
715
716/// Mean absolute per-channel difference between two images in **linear light**,
717/// normalized to `0.0..=1.0`. Mismatched dimensions read as fully different.
718///
719/// [`frame_diff`] works on the stored sRGB bytes, which is right for "how
720/// different do these two look". It is *wrong* for a step response: sRGB's
721/// transfer curve is concave, so a parameter easing linearly toward its target
722/// crosses 90 % of its pixel change early on the way up and late on the way
723/// down, and a symmetric `[smoothing]` entry would measure asymmetric. Decoding
724/// first makes the probe's response proportional to the parameter for a scene
725/// whose shader is, which is exactly what the purpose-built easing fixtures are.
726fn linear_diff(a: &CaptureImage, b: &CaptureImage) -> f32 {
727 if a.width != b.width || a.height != b.height || a.rgba.len() != b.rgba.len() {
728 return 1.0;
729 }
730 let lut = srgb_decode_lut();
731 let mut sum = 0.0f64;
732 let mut count: u64 = 0;
733 for (pa, pb) in a.rgba.chunks_exact(4).zip(b.rgba.chunks_exact(4)) {
734 for c in 0..3 {
735 let (Some(&x), Some(&y)) = (pa.get(c), pb.get(c)) else {
736 continue;
737 };
738 let lx = lut.get(x as usize).copied().unwrap_or(0.0);
739 let ly = lut.get(y as usize).copied().unwrap_or(0.0);
740 sum += f64::from((lx - ly).abs());
741 count += 1;
742 }
743 }
744 if count == 0 {
745 return 0.0;
746 }
747 (sum / count as f64) as f32
748}
749
750/// The 256-entry sRGB→linear decode table, built once — the workspace's one
751/// sRGB decode. A table rather than a `powf` per channel because
752/// [`frames_to_settle`] runs a full-frame difference per captured frame, and the
753/// probe is a whole sequence of them.
754///
755/// Index it by the stored byte: `lut[b]` is the linear light of code value `b`.
756/// `linear_diff`'s doc comment carries why a level comparison decodes first
757/// (private, hence named rather than linked).
758pub fn srgb_decode_lut() -> &'static [f32; 256] {
759 static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new();
760 LUT.get_or_init(|| {
761 let mut table = [0.0f32; 256];
762 for (i, slot) in table.iter_mut().enumerate() {
763 let c = i as f32 / 255.0;
764 *slot = if c <= 0.040_45 {
765 c / 12.92
766 } else {
767 ((c + 0.055) / 1.055).powf(2.4)
768 };
769 }
770 table
771 })
772}
773
774/// Whether a pixel's RGB differs from `bg` by more than `eps` on any channel.
775fn is_lit(px: &[u8], bg: [u8; 4], eps: u8) -> bool {
776 px.iter()
777 .zip(bg.iter())
778 .take(3)
779 .any(|(&c, &b)| c.abs_diff(b) > eps)
780}
781
782/// Box-average an image down to a `STRUCT_GRID`×`STRUCT_GRID` grid of grayscale
783/// luma in `0.0..=1.0`.
784fn downscale_gray(img: &CaptureImage) -> Vec<f32> {
785 let g = STRUCT_GRID;
786 let mut cells = vec![0.0f32; g * g];
787 let mut counts = vec![0u32; g * g];
788 let w = img.width as usize;
789 let h = img.height as usize;
790 if w == 0 || h == 0 {
791 return cells;
792 }
793 for (i, px) in img.rgba.chunks_exact(4).enumerate() {
794 let x = i % w;
795 let y = i / w;
796 let cx = (x * g / w).min(g - 1);
797 let cy = (y * g / h).min(g - 1);
798 let idx = cy * g + cx;
799 let luma = 0.299 * px.first().copied().unwrap_or(0) as f32
800 + 0.587 * px.get(1).copied().unwrap_or(0) as f32
801 + 0.114 * px.get(2).copied().unwrap_or(0) as f32;
802 if let (Some(cell), Some(cnt)) = (cells.get_mut(idx), counts.get_mut(idx)) {
803 *cell += luma;
804 *cnt += 1;
805 }
806 }
807 for (cell, cnt) in cells.iter_mut().zip(counts.iter()) {
808 if *cnt > 0 {
809 *cell /= *cnt as f32 * 255.0;
810 }
811 }
812 cells
813}
814
815/// Sobel gradient magnitude over a `STRUCT_GRID`×`STRUCT_GRID` grayscale grid.
816/// Border cells stay zero (no wrap).
817fn sobel(gray: &[f32]) -> Vec<f32> {
818 let g = STRUCT_GRID;
819 let mut edges = vec![0.0f32; g * g];
820 let at = |x: usize, y: usize| -> f32 { gray.get(y * g + x).copied().unwrap_or(0.0) };
821 for y in 1..g.saturating_sub(1) {
822 for x in 1..g.saturating_sub(1) {
823 let gx = at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1)
824 - at(x - 1, y - 1)
825 - 2.0 * at(x - 1, y)
826 - at(x - 1, y + 1);
827 let gy = at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1)
828 - at(x - 1, y - 1)
829 - 2.0 * at(x, y - 1)
830 - at(x + 1, y - 1);
831 if let Some(e) = edges.get_mut(y * g + x) {
832 *e = (gx * gx + gy * gy).sqrt();
833 }
834 }
835 }
836 edges
837}
838
839/// Scale a map so its peak is 1.0; an all-zero map is returned unchanged.
840fn normalize_max(v: &[f32]) -> Vec<f32> {
841 let max = v.iter().copied().fold(0.0f32, f32::max);
842 if max <= f32::EPSILON {
843 return v.to_vec();
844 }
845 v.iter().map(|x| x / max).collect()
846}
847
848#[cfg(test)]
849mod tests;
850
851// ---------------------------------------------------------------------------
852// The in-frame geometry diagnostic (ADR-0083)
853// ---------------------------------------------------------------------------
854
855/// How much of the drawn segment length landed inside the render target, summed
856/// over one [`LineRenderer::draw`](crate::render::scenes::lines::LineRenderer::draw)
857/// call (Plan 0069, ADR-0083).
858///
859/// Pixel coverage cannot see an over-scaled figure: a comb roots every bar on a
860/// shared baseline and a corona roots every spoke at a centre, so clipping the
861/// tips costs a rounding error of lit pixels and the statistic goes the *wrong
862/// way*. Length does see it — a bar that overshoots loses in-frame length in
863/// exact proportion to the overshoot.
864///
865/// **Length, not area.** The stroke's width and the ADR-0041 join extensions are
866/// not counted, so a thick stroke leaving the frame is under-counted. That is
867/// the right measure for *overshoot* and a poor one for anything else.
868///
869/// **Arcs count too** (Plan 0087 Phase 2). An
870/// [`ArcInstance`](crate::render::scenes::lines::ArcInstance) contributes its
871/// own arc length, `|sweep| * radius`, to both sums. This is a correctness
872/// obligation of the arc primitive rather than a feature: an arc contributing
873/// nothing would shrink the denominator, and every arc-drawing preset would
874/// read better-framed than it is — the more so as the primitive replaces whole
875/// motifs, where the missing length is most of the figure.
876#[derive(Clone, Copy, Debug, Default, PartialEq)]
877pub struct DrawExtent {
878 /// World-space length of every segment actually drawn (post view transform).
879 pub total_len: f32,
880 /// The share of that length lying inside `[-aspect, aspect] x [-1, 1]`.
881 pub in_frame_len: f32,
882}
883
884impl DrawExtent {
885 /// The in-frame fraction — exactly `1.0` when nothing was clipped, exactly
886 /// `0.0` when the whole figure is outside.
887 ///
888 /// `None` when nothing was drawn at all: that is a `0/0`, and inventing a
889 /// number for it is what made Plan 0058's table print `inf`. "Nothing drawn"
890 /// is the *total* case and `core/tests/sanity.rs` is its instrument, not this
891 /// one.
892 pub fn fraction(self) -> Option<f32> {
893 (self.total_len > 0.0).then(|| self.in_frame_len / self.total_len)
894 }
895}
896
897// **Thread-local rather than a field on anything**, and the reason is a
898// reachability one. The measurement happens inside `LineRenderer::draw`, which
899// the four line scenes reach through an `Rc<RefCell<..>>` owned by the scene
900// registry (`scenes::create_all`); nothing outside `render` holds a handle to
901// it, and no `&mut` path runs from the `Renderer` down to that call without a
902// `Scene` trait parameter for a diagnostic that is off in every shipped frame.
903// Thread-local rather than a global: the renderer is single-threaded by
904// construction (`Rc`), so this is the cheapest correct sink, and it keeps one
905// test's switch out of another's capture when the harness runs test threads in
906// parallel.
907//
908// It lives here rather than beside the measuring code so that the shell reads a
909// diagnostic out of `render::metrics`, where every other reading it takes comes
910// from, instead of reaching five modules deep into a scene's renderer.
911thread_local! {
912 /// Whether `draw` measures. **Off in the shipped render path** — that is the
913 /// whole of the switch, and `core/tests/geometry_extent.rs` asserts "off"
914 /// means byte-identical output.
915 static EXTENT_ON: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
916 /// The most recent measured draw, if any.
917 static LAST_EXTENT: std::cell::Cell<Option<DrawExtent>> = const { std::cell::Cell::new(None) };
918}
919
920/// Turn the in-frame geometry diagnostic on or off for **this thread**, clearing
921/// any measurement already recorded. Off by default; the shipped render path
922/// never calls this.
923pub fn set_extent_diagnostic(on: bool) {
924 EXTENT_ON.with(|flag| flag.set(on));
925 LAST_EXTENT.with(|slot| slot.set(None));
926}
927
928/// Take the extent of the **most recent** measured `draw`, leaving the slot
929/// empty. `None` when no line scene has drawn since the diagnostic was enabled
930/// (or when it is off) — distinct from a recorded draw whose
931/// [`fraction`](DrawExtent::fraction) is `None` because nothing was drawn.
932///
933/// A frame usually holds one line draw ([`scenes::shares_resources`] forbids
934/// two *roster* line scenes in a frame), and then "the most recent draw" is
935/// "this frame's figure". A preset may layer a second line scene (Plan 0076)
936/// through its own per-preset `LineRenderer`
937/// (`scenes::create_layer_scene`) — the layer draws **after** the main
938/// scene, so on a layered line-on-line preset this slot holds the *layer's*
939/// figure. The harness reads this around single-figure captures; a consumer
940/// measuring a layered preset must know which draw it is measuring.
941///
942/// [`scenes::shares_resources`]: crate::render::scenes
943pub fn take_draw_extent() -> Option<DrawExtent> {
944 LAST_EXTENT.with(|slot| slot.take())
945}
946
947/// Whether the in-frame geometry diagnostic is measuring on this thread.
948///
949/// Read once per `LineRenderer::draw`. Off in every shipped frame, which is what
950/// `core/tests/geometry_extent.rs` asserts by comparing output with the switch
951/// off against the committed goldens.
952pub fn extent_diagnostic_on() -> bool {
953 EXTENT_ON.with(std::cell::Cell::get)
954}
955
956/// Record one measured draw, replacing whatever the slot held.
957pub fn record_draw_extent(extent: DrawExtent) {
958 LAST_EXTENT.with(|slot| slot.set(Some(extent)));
959}