Skip to main content

rlx_core/render/
tier.rs

1//! Quality tiers: the engine's capacity constants, resolved once (ADR-0045).
2//!
3//! NFR §1 specifies two quality levels — a reduced tier holding 60 fps
4//! at 1080p on the ~2015-iGPU baseline, and a richer presentation on
5//! capable hardware. This module is where both live.
6//!
7//! # What a tier is
8//!
9//! A [`TierConfig`] is a plain struct of **capacity** values: how many particles,
10//! how many segments, how large an internal grid may get. Nothing here changes
11//! *what* the engine draws, only how much of it — which is what makes
12//! [`Tier::Floor`] byte-identical to the pre-tier engine and what lets captures
13//! pin it (see below). A value that changes the *content* of a frame — the
14//! reaction-diffusion simulation grid, whose pattern scale moves with its
15//! resolution (ADR-0034) — deliberately does **not** live here.
16//!
17//! **That separation is a property of the consuming scene, not of this
18//! struct.** The attractor draws its particles with an *additive* blend
19//! into a linear accumulation, so
20//! [`attractor_particles`](TierConfig::attractor_particles) sets the
21//! total light in the frame as directly as it sets the sample count —
22//! `Rich` rendered every attractor preset three stops hot behind a
23//! green suite, because no capture pins `Rich`. What holds the claim up
24//! is [`deposit_scale`](super::scenes::particles::deposit_scale)
25//! dividing the deposit by the count (ADR-0065). The lesson
26//! generalizes: **a count feeding an accumulating pass is a look value
27//! until something normalizes it.** If a future field lands here for
28//! such a pass, that normalization is part of adding it.
29//!
30//! # Where the numbers come from
31//!
32//! [`TierConfig::FLOOR`] is the pre-tier engine, constant for constant: each
33//! value's former definition site now reads this struct, so no number exists
34//! twice. Its justifications came with it and are on the fields.
35//! [`TierConfig::RICH`] is calibrated against a midrange discrete GPU
36//! (RTX 3060 / RX 6600 class) on device — Plan 0044 Phase 4 — rather than
37//! asserted from a multiplier.
38//!
39//! # Resolution and the governor
40//!
41//! The tier resolves **once, at renderer construction**, from an optional pin
42//! ([`RendererOptions`](super::RendererOptions)); unpinned resolves [`Tier::Rich`].
43//! An unpinned renderer may then be demoted to [`Tier::Floor`] by the frame-time
44//! governor — one way, once per session, never silently. A pinned tier never
45//! moves.
46//!
47//! Headless capture is [`Tier::Floor`] **by construction**:
48//! [`Renderer::new_headless`](super::Renderer::new_headless) cannot produce any
49//! other tier, so every golden baseline stays byte-reproducible on the software
50//! adapter it is captured on (lavapipe, ADR-0242) and the suite's cost does not
51//! scale with the rich tier.
52//! [`Renderer::new_headless_tiered`](super::Renderer::new_headless_tiered) is the
53//! deliberate opt-in the `shot` CLI's `--tier` reaches.
54//!
55//! # The grid scale
56//!
57//! One value in a resolved [`TierConfig`] is not the tier's alone:
58//! [`grid_scale`](TierConfig::grid_scale), the fraction of the render target
59//! the internal grids are drawn at (ADR-0245). It comes from the tier **and**
60//! the adapter's class through `grid_scale_for`, or from an explicit pin, and
61//! `resolve_grid_scale` is the one place the three are weighed. It resolves
62//! when the tier does — at construction, on a tier change and on an adapter
63//! change — and a frame never reads it.
64//!
65//! Pure and GPU-free throughout — a tier is a set of numbers, so it is decided
66//! without a device and tested without one.
67
68// Hot-path panic-denial pragma (Plan 0002 Phase 2; render/ is scanned by the
69// hygiene guard). The governor runs once per displayed frame.
70#![deny(
71    clippy::unwrap_used,
72    clippy::expect_used,
73    clippy::indexing_slicing,
74    clippy::panic,
75    clippy::unreachable
76)]
77
78use super::context::AdapterClass;
79
80/// Which quality tier a renderer is running (ADR-0045).
81///
82/// Two named levels rather than a continuum: the output of a preset has to be
83/// predictable enough to baseline, document, and reproduce in a bug report, which
84/// a load-history-dependent feature-shedding scheme cannot deliver (ADR-0045
85/// Alternative B).
86#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
87pub enum Tier {
88    /// The NFR §1/§2 iGPU floor — the pre-tier engine's exact constants. The
89    /// default here because it is the safe answer: a `Tier` value that appeared
90    /// from nowhere should not raise anyone's budgets.
91    #[default]
92    Floor,
93    /// Calibrated for a midrange discrete GPU: higher particle, segment and
94    /// resolution budgets, same visual grammar.
95    Rich,
96}
97
98impl Tier {
99    /// The lowercase name the CLI, the env var and the config file all use.
100    pub fn as_str(self) -> &'static str {
101        match self {
102            Tier::Floor => "floor",
103            Tier::Rich => "rich",
104        }
105    }
106
107    /// The uppercase name the 5x7 diagnostics overlay paints. Separate from
108    /// [`as_str`](Self::as_str) only because that font has no lowercase glyphs;
109    /// both come off the same match so there is no second spelling to drift.
110    pub fn label(self) -> &'static str {
111        match self {
112            Tier::Floor => "FLOOR",
113            Tier::Rich => "RICH",
114        }
115    }
116
117    /// Parse a tier name, case-insensitively. `None` for anything else — callers
118    /// surface that as a usage error rather than guessing a tier.
119    pub fn from_name(name: &str) -> Option<Self> {
120        match name.trim().to_ascii_lowercase().as_str() {
121            "floor" => Some(Tier::Floor),
122            "rich" => Some(Tier::Rich),
123            _ => None,
124        }
125    }
126}
127
128/// The fraction of the render target every internal grid is drawn at — the post
129/// chain's grid and the attractor's trail field (ADR-0245).
130///
131/// A validated value: only [`new`](Self::new) and [`parse`](Self::parse) make
132/// one, and both refuse anything outside [`MIN`](Self::MIN)`..=`[`MAX`](Self::MAX),
133/// NaN included. That is what makes the `Eq` below honest — a `GridScale` is
134/// never NaN, so it equals itself.
135///
136/// A **capacity**, not a look value, in the sense the module docs use: it sets
137/// how many texels a field holds, and a grid is a resolution rather than a
138/// shape (ADR-0037), so the picture's geometry does not move with it. What does
139/// move is sharpness, which is the trade.
140#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
141pub struct GridScale(f32);
142
143impl Eq for GridScale {}
144
145impl GridScale {
146    /// The smallest fraction accepted. Below a quarter a 1080p field is under
147    /// 480x270 and the grid floor (`grid::MIN_AXIS`) starts deciding the size
148    /// instead of the scale.
149    pub const MIN: f32 = 0.25;
150    /// The largest: the target's own resolution. A grid is never drawn above it.
151    pub const MAX: f32 = 1.0;
152    /// The whole target — every row of the table but Rich on an integrated
153    /// adapter, and what a headless renderer resolves unless it is told
154    /// otherwise.
155    pub const FULL: Self = Self(1.0);
156    /// Rich on an integrated adapter: the measured row of `grid_scale_for`.
157    pub(crate) const RICH_INTEGRATED: Self = Self(0.75);
158
159    /// `value` as a scale, or `None` when it is outside `MIN..=MAX` or not a
160    /// number.
161    pub fn new(value: f32) -> Option<Self> {
162        (Self::MIN..=Self::MAX)
163            .contains(&value)
164            .then_some(Self(value))
165    }
166
167    /// Parse a scale from its written form — the flag's value, the environment
168    /// variable's, or a settings value. The error names the accepted range,
169    /// because a caller surfaces it as a usage error verbatim.
170    pub fn parse(text: &str) -> Result<Self, String> {
171        let trimmed = text.trim();
172        trimmed
173            .parse::<f32>()
174            .ok()
175            .and_then(Self::new)
176            .ok_or_else(|| {
177                format!(
178                    "`{trimmed}` is not a grid scale: expected a number from {} to {}",
179                    Self::MIN,
180                    Self::MAX
181                )
182            })
183    }
184
185    /// The fraction itself.
186    pub fn get(self) -> f32 {
187        self.0
188    }
189
190    /// `px` target pixels, as the texels a grid at this scale asks for before it
191    /// is quantized: `round(px * scale^2)`, saturating.
192    ///
193    /// **Exactly `px` at [`FULL`](Self::FULL)**, because `1.0 * 1.0` is exact and
194    /// every `u32` is exact in `f64` — so a count derived from this at scale 1.0
195    /// is the count derived from the target, byte for byte.
196    pub fn texels(self, px: u32) -> u32 {
197        let scaled = (f64::from(px) * f64::from(self.0) * f64::from(self.0)).round();
198        if scaled >= f64::from(u32::MAX) {
199            u32::MAX
200        } else {
201            scaled as u32
202        }
203    }
204}
205
206impl Default for GridScale {
207    fn default() -> Self {
208        Self::FULL
209    }
210}
211
212impl std::fmt::Display for GridScale {
213    /// Two decimals — `1.00`, `0.75` — the form the overlay and a log line print.
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        write!(f, "{:.2}", self.0)
216    }
217}
218
219/// **The grid-scale table**: the fraction a tier draws its internal grids at on
220/// an adapter of `class` (ADR-0245).
221///
222/// The two integrated rows are a reading, not a choice made here: the largest
223/// scale whose live 1080p window held a 60 fps median with no one-second sample
224/// under 60 across the ten bench presets on the reference laptop's integrated
225/// GPU (Plan 0223 Phase 6). Floor holds at 1.0; Rich needs 0.75. Neither is a
226/// promise above 1080p — at 2560x1440 no Rich scale held. The discrete,
227/// software and other rows are 1.0 **by decision** rather
228/// than by measurement: a discrete GPU holds the display rate
229/// at full resolution, and every golden baseline is taken on a software
230/// rasterizer, so a fraction there would move a picture no measurement asked to
231/// move.
232pub(crate) fn grid_scale_for(tier: Tier, class: AdapterClass) -> GridScale {
233    match (tier, class) {
234        (Tier::Floor, AdapterClass::Integrated) => GridScale::FULL,
235        (Tier::Rich, AdapterClass::Integrated) => GridScale::RICH_INTEGRATED,
236        (_, AdapterClass::Discrete | AdapterClass::Software | AdapterClass::Other) => {
237            GridScale::FULL
238        }
239    }
240}
241
242/// **The one grid-scale resolution in the engine**: an explicit pin wins, a
243/// renderer with no surface takes [`GridScale::FULL`], and a window takes the
244/// table's row.
245///
246/// The headless arm is the same guarantee [`Renderer::new_headless`] makes
247/// about the tier: a capture is a function of its inputs and not of the machine
248/// that took it, so a `shot` on an integrated laptop and one on a discrete
249/// desktop draw the same grid. A pin is how a headless run asks for less.
250///
251/// Pure, so all three arms are asserted without a device.
252///
253/// [`Renderer::new_headless`]: super::Renderer::new_headless
254pub(crate) fn resolve_grid_scale(
255    pin: Option<GridScale>,
256    tier: Tier,
257    class: AdapterClass,
258    has_surface: bool,
259) -> GridScale {
260    match pin {
261        Some(scale) => scale,
262        None if has_surface => grid_scale_for(tier, class),
263        None => GridScale::FULL,
264    }
265}
266
267/// The capacity values a tier sets. Resolved once at renderer construction and
268/// read at construction/reconfigure time only — never branched on per frame.
269#[derive(Clone, Copy, Debug, PartialEq, Eq)]
270pub struct TierConfig {
271    /// Which tier these values are, so a demotion and the overlay have one thing
272    /// to read rather than a parallel field to keep in step.
273    pub tier: Tier,
274
275    /// The fraction of the render target both internal grids are drawn at
276    /// ([`GridScale`], ADR-0245) — the one value here that is not the tier's
277    /// alone.
278    ///
279    /// The constants below carry [`GridScale::FULL`]; the renderer replaces it
280    /// with `resolve_grid_scale`'s answer for its own adapter and pin whenever
281    /// it takes a config, so a `TierConfig` a renderer holds names the scale its
282    /// grids were actually built at. It rides here rather than beside the struct
283    /// because both grids are built from a `TierConfig` already, and a rebuild
284    /// that took the tier and forgot the scale is then not writable.
285    pub grid_scale: GridScale,
286
287    /// Cap on a post stage's internal grid (ADR-0034), width then height.
288    ///
289    /// The floor value is NFR §12 memory arithmetic, **redone for the linear-light
290    /// composite** (Plan 0045 Phase 3 / ADR-0046). Every intermediate upstream of
291    /// the tonemap is now `COMPOSITE_FORMAT` — 8
292    /// bytes/texel, not 4 — so a stage offscreen costs twice what the surface
293    /// format would charge, while the trails accumulation
294    /// (`PingPongField`, two textures) was
295    /// already float and did not move.
296    ///
297    /// Per chain, both stages live, at this cap (1920x1080, 8 bytes/texel =
298    /// 16.6 MB a texture):
299    ///
300    /// | buffer                     | before | after |
301    /// |----------------------------|--------|-------|
302    /// | trails composited          | 8.3    | 16.6  |
303    /// | trails accumulation (x2)   | 33.2   | 33.2  |
304    /// | kaleidoscope source        | 8.3    | 16.6  |
305    /// | **per chain**              | **50** | **66** |
306    ///
307    /// Plan 0023's dual-live dissolve holds two whole `PostChain`s, so the peak is
308    /// ~133 MB rather than ~100. Outside the chains the frame carries one more
309    /// surface-sized float buffer beyond the chains — the tonemap's input, 16.6
310    /// MB, the one allocation ADR-0046 genuinely adds — plus the blend's
311    /// snapshot/live pair at 16.6 MB each while a dissolve runs (8.3 at 8-bit),
312    /// and ink's 8.3 MB input, which stays 8-bit because the tonemap hands it
313    /// display-referred pixels. Worst case — dual-live, both stages, ink on — is
314    /// ~191 MB against NFR §12's ~350 MB soft ceiling, which is mostly driver
315    /// floor already.
316    ///
317    /// At the rich cap (2560x1440) the same arithmetic is ~118 MB per chain and
318    /// ~236 MB dual-live, up from ~88 and ~177 — the trade ADR-0034 priced and
319    /// declined at floor budgets and the rich tier takes.
320    ///
321    /// **This cap is the relief lever** if the float chain misses NFR §1 on a
322    /// floor-tier iGPU: lower it rather than re-fixing the grids (ADR-0046), since
323    /// bandwidth roughly doubled with the format and the grid policy is shared.
324    ///
325    /// **Bloom adds to this only when a preset switches it on** (Plan 0045
326    /// Phase 4), and it is **two** allocations, not one. Its pyramid is two
327    /// textures per level, each level a quarter of the last, so the pyramid
328    /// converges to `2 * (1/4 + 1/16 + …) ≈ 2/3` of one grid-sized texture — ~11 MB
329    /// at this cap. On top of that the stage owns its own **grid-sized `bloom-src`
330    /// offscreen**, a full 16.6 MB at this cap, because a `PostStage` reads its
331    /// input from a texture it owns. So the stage costs **16.6 + ~11 ≈ 28 MB** on
332    /// top of the ~66 MB per chain above, and ~55 MB in the dual-live worst case —
333    /// which is what NFR §12's table charges and what the ~246 MB worst case there
334    /// is computed from. It is charged only against presets that bind
335    /// `bloom_amount`, since an inactive stage builds nothing.
336    pub post_cap: (u32, u32),
337
338    /// How many levels deep the bloom pyramid goes
339    /// (`Bloom`, ADR-0046).
340    ///
341    /// This is a **capacity**, not a look: each level doubles the halo's reach and
342    /// costs three passes at a quarter of the previous level's area, so the tail
343    /// is cheap in pixels and not free in passes. The floor runs four (a halo
344    /// reaching ~16 of the grid's texels at the default radius); rich runs six,
345    /// which is where the widest levels start to matter on a 1440p-class grid.
346    ///
347    /// `level_sizes` clamps this down on a small render target, so
348    /// a value here is an upper bound rather than a promise.
349    pub bloom_levels: u32,
350
351    /// The attractor's sample budget **at [`REFERENCE_PX`]** — the anchor of the
352    /// density law, not the count drawn (ADR-0140).
353    ///
354    /// [`attractor_budget`] scales this by `target_px / REFERENCE_PX` and clamps
355    /// it between this value and one of the two ceilings below, so a target at or
356    /// under the reference draws exactly this many and a larger one draws more.
357    /// What a *preset* then draws out of that budget is
358    /// `round(budget * density)` (ADR-0069), which is a different and smaller
359    /// number again.
360    ///
361    /// State is 48 bytes each ([`Particle`](super::scenes::particles)), and the
362    /// real ceiling is **additive-blend fill rate**, which is why the floor value
363    /// was described as the number to validate against the 60 fps @ 1080p floor
364    /// (ADR-0015 Risks).
365    ///
366    /// This is a sample count and **not** a brightness: the additive draw divides
367    /// its deposit by the *active* count
368    /// ([`deposit_scale`](super::scenes::particles::deposit_scale), ADR-0065), so
369    /// raising it buys a smoother figure rather than a brighter one. Changing it
370    /// changes shot noise and cost; it does not change exposure.
371    pub attractor_particles: u32,
372
373    /// The largest budget [`attractor_budget`] may resolve for a scene drawing
374    /// into a **live surface** — a window, or the plugin's host surface.
375    ///
376    /// Frame-time bound, and it is the number that keeps the law from spending a
377    /// display's whole budget on sample count. It is also the **allocation**: the
378    /// particle buffer is sized here at construction and never resized, so a
379    /// resize changes the active count and rebuilds no GPU resource. That costs
380    /// `ceiling * 48 B` of GPU storage plus the same again for the CPU seed
381    /// scatter the scene holds for re-upload, in **every** window, whether or not
382    /// the target is large and whether or not an attractor preset is loaded
383    /// (`create_all` builds every scene up front).
384    ///
385    /// # Where these numbers come from
386    ///
387    /// Measured, not chosen — Plan 0128 Phase 1, at 1920x1080 on
388    /// `attractor_leviathan`, counts interleaved in one process so a throttling
389    /// laptop GPU could not read as signal.
390    ///
391    /// **`Rich`: 600 000**, four times the anchor. On the midrange-discrete
392    /// reference NFR §1 calibrates `Rich` against, the rule was *the largest swept
393    /// count whose marginal p99 over today's anchor stays inside 10 % of the
394    /// 16.67 ms budget*: 600 000 reads +1.454 ms (8.7 %) and the next step up,
395    /// 1 200 000, reads +4.703 ms (28.2 %).
396    ///
397    /// **`Floor`: its own anchor**, so the law is a no-op there. On integrated
398    /// hardware — the baseline NFR §1's floor commitment is about — 1080p at
399    /// `Floor` already sits *on* the 16.67 ms budget at today's 50 000 (p99
400    /// 16.854 ms), and the law's own 1080p `Floor` value of 450 000 takes it to
401    /// 31.942 ms. NFR §1 promises `Floor` "values exactly the pre-tier engine's";
402    /// this is what keeps that true at every target size.
403    pub attractor_particles_live_ceiling: u32,
404
405    /// The same ceiling for a **headless render** — `shot --render`, where there
406    /// is no present deadline and no governor, and the only bound is memory.
407    ///
408    /// # Where these numbers come from
409    ///
410    /// The bound is the **device's storage-buffer binding limit**, not process
411    /// memory, because it is reached first: at 48 B a particle, wgpu's default
412    /// `max_storage_buffer_binding_size` of 134 217 728 B holds 2 796 202
413    /// particles, and 5 400 000 — the law's own unclamped 4K value — fails
414    /// outright with `Buffer binding 0 range 259200000 exceeds
415    /// max_*_buffer_binding_size limit 134217728` (Plan 0128 Phase 1).
416    ///
417    /// `Rich` takes **2 700 000**, the largest whole multiple of its anchor under
418    /// that wall — 18x, 129.6 MB, 3.4 % of headroom. `Floor` takes the **same
419    /// multiple** rather than the same number, so a tier still means something
420    /// offline: at one shared ceiling `--tier floor --render` and
421    /// `--tier rich --render` would draw an identical count at 4K.
422    pub attractor_particles_offline_ceiling: u32,
423
424    /// Upper bound on each axis of the attractor's trail accumulation grid.
425    ///
426    /// The floor is the ceiling Plan 0027/0029 chose for a high-DPI display while
427    /// keeping the worst case bounded on the iGPU: every frame pays a decay pass
428    /// plus the full additive instance draw over this grid, so cost scales with
429    /// its area. Rich lifts it to 4K so a 4K or ultrawide display sizes near 1:1
430    /// instead of degrading to a uniform upscale.
431    pub attractor_trail_cap: (u32, u32),
432
433    /// How many particles the swarm simulates.
434    ///
435    /// Plan 0043 left the floor value at an **unmeasured** iGPU cost (+0.5 ms per
436    /// frame of depth math on the dev box, and not fill rate), which is why the
437    /// plans index calls this a live tier candidate rather than a settled
438    /// constant. If the on-device floor check misses, this is the lever — on the
439    /// floor value, and that routes back through `architect`.
440    pub swarm_particles: usize,
441
442    /// How many objects the emitter's pool holds (ADR-0057).
443    ///
444    /// Unlike every other count here this is a **ceiling on a varying
445    /// population**, not the population: the emitter spawns and retires, so a
446    /// preset's `spawn_rate * lifetime` decides how many objects are actually
447    /// alive and this decides how many *can* be. Spawns past it are dropped
448    /// rather than queued or allocated for — that is the phase's whole real-time
449    /// hazard — so raising it does not brighten a preset that never reaches it,
450    /// and lowering it below one that does thins the shower rather than changing
451    /// its motion.
452    ///
453    /// Not an accumulating count in the sense the module docs warn about: each
454    /// object is one sprite drawn once per frame, so the light in the frame is
455    /// `population * brightness` and the population is a preset's own arithmetic.
456    /// The tier only says where that arithmetic is cut off.
457    ///
458    /// The floor holds a shipped preset's shower with room to spare (the emitter
459    /// family runs a few hundred objects live); rich triples it for the denser
460    /// looks a discrete GPU can carry. Cheap either way — see NFR §12: the pool
461    /// and its instance buffer are well under a megabyte at both tiers.
462    pub emitter_objects: usize,
463
464    /// Ceiling on the warp mesh's grid, in **cells**, width then height
465    /// (Plan 0100 Phase 1).
466    ///
467    /// A capacity in the strictest sense: the grid is a *resolution* for the
468    /// per-vertex program, not a shape (ADR-0037), so raising it refines the
469    /// warp's spatial detail and changes nothing about what the scene draws. The
470    /// vertex count is `(x + 1) * (y + 1)`, and every one of those vertices costs
471    /// one evaluation of each `[per_vertex]` binding **on the render thread**,
472    /// which is what this bounds.
473    ///
474    /// The upper bound either tier may name is the `.milk` format's own —
475    /// `meshx <= 128`, `meshy <= 96` — so a converted preset's requested grid is
476    /// representable at the top of the range and clamped below it.
477    /// [`warp_mesh::clamp_grid`](super::scenes::warp_mesh::clamp_grid) is the one
478    /// place the clamp happens, shared by the loader and the scene.
479    ///
480    /// # Where these numbers come from
481    ///
482    /// **Measured, not chosen** — Plan 0100 Phase 1's done-when. The rule it set
483    /// was: raise the grid until one frame of per-vertex evaluation costs more
484    /// than **1 ms** — 6 % of the 16.67 ms NFR §1 commits to at 1080p — and cap
485    /// the floor one step below.
486    ///
487    /// `mesh_cost_by_grid` in `scenes/warp_mesh/tests.rs` is the measurement and
488    /// prints the ladder on every run. Taken **2026-08-16 on the development box
489    /// (Windows 10, desktop CPU, `--release`)**, evaluating a four-binding
490    /// `[per_vertex]` program of the shape a real preset writes — two runs,
491    /// agreeing to about 1 %:
492    ///
493    /// ```text
494    ///  grid      vertices    per frame     share of 16.67 ms
495    ///  16x12        221       0.036 ms      0.2 %
496    ///  32x24        825       0.129 ms      0.8 %
497    ///  48x36      1 813       0.280 ms      1.7 %
498    ///  64x48      3 185       0.488 ms      2.9 %   <- Floor
499    ///  72x54      4 015       0.616 ms      3.7 %
500    ///  80x60      4 941       0.755 ms      4.5 %
501    ///  88x66      5 963       0.909 ms      5.5 %   <- Rich
502    ///  96x72      7 081       1.081 ms      6.5 %   <- the bar is crossed here
503    /// 112x84      9 605       1.483 ms      8.9 %
504    /// 128x96     12 513       1.918 ms     11.5 %
505    /// ```
506    ///
507    /// **The bar is crossed between `88x66` and `96x72`**, so `88x66` is the
508    /// largest grid the rule admits and it is what `Rich` takes. The format's own
509    /// maximum is therefore **refused**: at 1.92 ms it is 11.5 % of the frame on
510    /// a desktop CPU, which is not a number any tier should spend on one
511    /// parameter surface. The grid is lowered because it did not measure clean,
512    /// which is the whole of the rule.
513    ///
514    /// **`Floor` sits a step further down than the rule alone would put it, and
515    /// deliberately.** The rig above is a desktop CPU; NFR §1's floor tier
516    /// targets a ~2015 iGPU-class machine whose single-thread performance this
517    /// box does not model, and this is CPU work on the render thread, so a
518    /// slower machine pays proportionally more of a budget it is already
519    /// struggling to hold. `64x48` is 2.9 % here and leaves room for that
520    /// machine to be several times slower before the surface is a problem.
521    ///
522    /// **When the floor tier is next exercised on real target hardware this is
523    /// the constant to re-measure**, and the ladder prints exactly what that
524    /// needs.
525    pub mesh_grid: (u32, u32),
526
527    /// The one capacity value here that a preset can *see*: past it geometry is
528    /// truncated, and ADR-0007 requires that be surfaced rather than silently cut.
529    /// So a preset whose mirror pushes over the floor cap reports an overflow at
530    /// the floor and not at rich — the message is the tier's most visible edge for
531    /// the content lane, which is why shipped presets are authored against the
532    /// floor.
533    pub max_segments: usize,
534
535    /// Cap on how many flat elements a `shape_collage` canvas may hold
536    /// (ADR-0123).
537    ///
538    /// **The one capacity here that bounds a per-pixel loop**, which is what
539    /// makes it load-bearing rather than a memory number. Every other count in
540    /// this struct bounds work paid once per particle, per vertex or per
541    /// segment; this one bounds work every *fragment* pays, so a frame costs
542    /// `elements x pixels` and ADR-0123 prices the bounding-box reject alone —
543    /// before anything is drawn — at roughly `6N` operations per pixel. The
544    /// buffer is irrelevant at any value either tier would take: 64 bytes an
545    /// element, so 128 elements is 8 KB.
546    ///
547    /// # Where the floor value comes from
548    ///
549    /// **Measured, then decided by a human** — Plan 0113 Phases 2 and 3.
550    /// `core/tests/collage_cost.rs` sweeps the count on hardware, prints
551    /// the ladder on every run, and its module docs own the readings and
552    /// the trap in quoting them. **Two tables are not interchangeable**:
553    /// the pre-roster ladder is what the Phase 3 gate read, and the
554    /// post-roster table is what a canvas costs today — the eight-kind
555    /// roster made the loop cheaper, because rings, sectors and checker
556    /// patches shade far less of their own bounding box than a quad
557    /// does.
558    ///
559    /// **40 is the reference set's own top, not a budget line.** The
560    /// gate was a look judgement and the cost was not the binding
561    /// constraint: the user's working density is **8 to 14 elements**,
562    /// which on the system as shipped costs **8.2 % of a 60 Hz frame at
563    /// eight and 10.7 % at sixteen**, and denser canvases were rejected
564    /// on sight long before they were rejected on cost. The ceiling is
565    /// the densest canvas in ADR-0123's roster, Kandinsky's *On White
566    /// II*, counted at just above 40 once its lines and arcs are
567    /// included — so this value sits **exactly on** that canvas, and a
568    /// `collage_onwhite` needing a forty-first element moves this
569    /// number rather than being quietly truncated.
570    ///
571    /// `Rich` is provisional in the sense every [`RICH`](TierConfig::RICH) value
572    /// is — see that constant's own note.
573    ///
574    /// # It clamps, and it does not yet say so
575    ///
576    /// `shape_collage::applied_count` holds a bound `count` to this value
577    /// **silently**, unlike [`max_segments`](Self::max_segments), which
578    /// ADR-0007 requires surface an overflow. That was harmless while the cap
579    /// sat far above any authored canvas and is not harmless now that it sits on
580    /// one. Recorded as a followup on Plan 0113 rather than fixed there: the
581    /// surfaced channel is [`CapOverflow`](super::scenes::lines::CapOverflow),
582    /// whose context enum is shared with the line scenes, so widening it is an
583    /// architect call.
584    pub collage_elements: usize,
585
586    /// The most iterations an `analytic_field` escape-time preset may follow an
587    /// orbit for (ADR-0045, ADR-0180 rule 3).
588    ///
589    /// **The one value here that can change a preset's picture**, which is
590    /// why it is a *cap on what the preset asks for* rather than a count the
591    /// tier chooses. An escape-time boundary at 48 iterations and at 512 is not
592    /// the same image, where a particle cloud at two densities is. So a preset
593    /// asks for `iterations`, this bounds it, and a preset asking within the
594    /// `Floor` value renders identically on both tiers. One that asks for more
595    /// is clamped and **says so**, through the same per-frame overflow the
596    /// segment cap surfaces through
597    /// ([`OverflowContext::Iterations`](super::scenes::OverflowContext::Iterations)),
598    /// which the standalone reports on the transition exactly as it reports a
599    /// tier demotion.
600    ///
601    /// # Where the numbers come from
602    ///
603    /// **Arithmetic, not a measurement.** It is a fullscreen per-pixel loop, so
604    /// the worst frame is one whose every pixel runs the whole budget: 1080p is
605    /// 2.07 M pixels, and one step of `z^2 + c` plus its escape test is about a
606    /// dozen floating-point operations, so 64 iterations is ~1.6 GFLOP a frame.
607    /// A ~2015 integrated GPU — the baseline NFR §1's floor is about — peaks at a
608    /// few hundred GFLOPS, which puts that frame at single-digit milliseconds at
609    /// peak and roughly two to three times that in practice: inside the 16.67 ms
610    /// budget with the composite still to pay for. 128 would sit on the budget.
611    /// `Rich` takes the top of `iterations`' own declared range.
612    ///
613    /// **When the floor tier is next exercised on real target hardware this is
614    /// a constant to measure**, on the heaviest escape-time frame: a zoom whose
615    /// whole view is the set's interior.
616    pub field_iterations: u32,
617
618    /// The widest neighbourhood a `cellular` `larger_than_life` preset may ask
619    /// for, as a `radius` in cells (ADR-0045, ADR-0180).
620    ///
621    /// Like [`field_iterations`](Self::field_iterations) this is a **cap on what
622    /// the preset asks for**, because a radius is content: the same rule at two
623    /// radii grows two different worlds, where a particle count at two densities
624    /// is one. A preset asking within the `Floor` value runs identically on both
625    /// tiers.
626    ///
627    /// # Where the numbers come from
628    ///
629    /// **Arithmetic, not a measurement.** The neighbourhood sum is separated — a
630    /// row pass and a column sum — so one generation costs `2 * (2r + 1) + 1`
631    /// texel reads a cell rather than `(2r + 1)^2`. At `Floor`'s 6 that is 27
632    /// reads; on a 512-cell grid at 60 generations a second, 425 M reads a
633    /// second, a few percent of what a ~2015 integrated GPU — the baseline NFR
634    /// §1's floor is about — fetches, and the grid and rate both have to sit at
635    /// their tops to reach it. `Rich` takes the top of `radius`' declared range,
636    /// 10: 43 reads, 2.7 G a second at a 1024 grid and the same rate. Unseparated,
637    /// radius 10 would be 441 reads a cell — ten times that.
638    ///
639    /// `Floor` is 6 rather than the whole range because the default rule (at
640    /// radius 5) fits under it with one to spare, and a floor
641    /// machine pays for every step past it at every generation.
642    ///
643    /// **When the floor tier is next exercised on real target hardware this is
644    /// a constant to measure**, at the tier's own grid cap and 60 generations a
645    /// second.
646    pub cellular_radius: u32,
647
648    /// The largest `[cellular] grid` a preset may run on, in cells a side
649    /// (ADR-0045).
650    ///
651    /// **A cap on content, like [`cellular_radius`](Self::cellular_radius),
652    /// and it is clamped and announced rather than silently reduced**: a
653    /// pattern is a fixed number of cells, so a grid clamped from 1024 to 512
654    /// draws every glider twice as large. A preset asking within the `Floor`
655    /// value runs identically on both tiers; one asking past it runs on the cap
656    /// and says so at load, through the cap overflow the standalone prints
657    /// ([`OverflowContext::Grid`](super::scenes::OverflowContext::Grid)).
658    ///
659    /// # Where the numbers come from
660    ///
661    /// **Arithmetic, not a measurement**, from the same read count
662    /// [`cellular_radius`](Self::cellular_radius) states. `Floor`'s 512 is the
663    /// grid that figure is quoted at — 262 144 cells, 425 M texel reads a
664    /// second at `Floor`'s radius and 60 generations a second — and its state
665    /// textures are 4 MB of the ping-pong pair and the row counts. `Rich` takes
666    /// the loader's own ceiling, 1024: four times the cells, 16 MB.
667    ///
668    /// **When the floor tier is next exercised on real target hardware this is
669    /// a constant to measure**, with `cellular_radius`, on one frame.
670    pub cellular_grid: u32,
671
672    /// How many relax passes a second the `cellular` route searches with
673    /// (ADR-0266), over injected `dt`, so its GPU cost is proportional to wall
674    /// time and not to the refresh rate.
675    ///
676    /// **A budget, not a cap on content**: nothing is clamped or announced. A
677    /// smaller rate lengthens an epoch, and an epoch longer than the maze's
678    /// still stretch draws no route that stretch.
679    ///
680    /// # Where the numbers come from
681    ///
682    /// **Measured** (Plan 0253 Phase 1), on the reference laptop's integrated
683    /// adapter (AMD Radeon Graphics, RADV RENOIR) with the ADR-0245 per-pass
684    /// timer. The rule: at the tier's own [`cellular_grid`](Self::cellular_grid)
685    /// and a 60 Hz frame, the route's relax passes cost no more GPU time a frame
686    /// than the `MAX_GENERATIONS_PER_FRAME` (8) step passes on the same grid.
687    /// `Floor` at 512: a step 0.1062 ms, a relax pass 0.2150 ms, so
688    /// `60 * 8 * 0.1062 / 0.2150 = 237`. `Rich` at 1024: a step 0.3268 ms, a
689    /// relax pass 0.8154 ms, so `60 * 8 * 0.3268 / 0.8154 = 192`.
690    pub cellular_route_rate: u32,
691
692    /// The most points a `plexus` preset draws (ADR-0257), holding its
693    /// `[plexus] points` at load.
694    ///
695    /// **A cap on content, clamped and announced at load**
696    /// ([`OverflowContext::Points`](super::scenes::OverflowContext::Points)):
697    /// fewer points is a sparser network, not the same one cheaper.
698    ///
699    /// The graph is built by testing every pair, so the per-frame cost is
700    /// quadratic in this: `N (N - 1) / 2` distance checks. `Floor`'s 600 is
701    /// 180 thousand checks a frame and `Rich`'s 1500 is 1.1 million.
702    ///
703    /// # Where the numbers come from
704    ///
705    /// `Floor`'s three plexus caps were **measured together** (Plan 0235 Phase
706    /// 6): a preset at 600 points with links saturating the edge cap, nodes on
707    /// and an aperture past the blur cap costs under 4 ms a frame at 1080p on an
708    /// integrated GPU, which leaves the frame budget its composite. `Rich`'s are
709    /// the same proportions scaled, not measured.
710    pub plexus_points: u32,
711
712    /// The most edges a `plexus` frame draws (ADR-0257): the size of its
713    /// instance buffer, and so the fill the graph can cost.
714    ///
715    /// A frame whose graph links more pairs than this draws the first this many
716    /// in index order and announces the rest
717    /// ([`OverflowContext::Edges`](super::scenes::OverflowContext::Edges)).
718    /// Measured with [`plexus_points`](Self::plexus_points).
719    pub plexus_edges: u32,
720
721    /// The largest circle of confusion a 3D primitive is blurred by, as a
722    /// radius in pixels (ADR-0257).
723    ///
724    /// A blurred stroke costs fill in proportion to how far it is widened, so
725    /// this is the lever that keeps a wide-open aperture inside the frame
726    /// budget: past it a line stops spreading, and an operator on a lower tier
727    /// sees a shallower blur rather than a slower frame — and is told so
728    /// ([`OverflowContext::Blur`](super::scenes::OverflowContext::Blur)).
729    /// Measured with [`plexus_points`](Self::plexus_points).
730    pub max_coc_px: u32,
731
732    /// The most 3D segments a non-plexus line system draws in a frame: the size
733    /// of the `seg3d` instance buffer each such scene owns (ADR-0258), and so
734    /// the blurred fill it can cost.
735    ///
736    /// One cap shared by every 3D line system but the plexus, whose edge cap
737    /// is its own. A frame asking for more is clamped and announced, never
738    /// silently cut (ADR-0045).
739    ///
740    /// # Where the numbers come from
741    ///
742    /// `Floor`'s 8000 was **measured** (Plan 0236 Phase 4): a `parametric_curve`
743    /// space walk of 8000 chords, 12 px thick, with an aperture past the blur
744    /// cap so every far chord is widened by the whole of
745    /// [`max_coc_px`](Self::max_coc_px), filling up to 70 % of the frame, costs
746    /// about 1.1 ms a frame at 1080p on an integrated GPU — far inside the frame
747    /// budget, which leaves the systems that share this cap their own headroom.
748    /// The `waterfall`'s two fill shapes at this cap — near rows spanning the
749    /// frame, and far rows piled above the horizon, both blurred past the cap —
750    /// were measured the same way (Plan 0238 Phase 2) and stay under twice their
751    /// sharp cost. `Rich`'s 20 000 is not measured.
752    pub seg3d_segments: u32,
753
754    /// The largest circle of confusion a swarm sprite is blurred by, as a
755    /// radius in pixels (ADR-0259): the swarm's own ceiling beside
756    /// [`max_coc_px`](Self::max_coc_px), and never above it.
757    ///
758    /// Blur grows a sprite's **area**, and the swarm blurs its whole tier of
759    /// sprites, so it is the one 3D population whose cap may need to sit below
760    /// the shared one. Both values below are the shared cap: measured with the
761    /// whole population defocused past the cap under `trails` at 1080p, each
762    /// tier's blurred frame stayed under twice its sharp one and inside the
763    /// frame budget (Plan 0239 Phase 3). A clamp to this cap is announced as
764    /// the shared one is ([`OverflowContext::Blur`](super::scenes::OverflowContext::Blur)).
765    pub swarm_max_coc_px: u32,
766
767    /// The largest `[voxel] grid` a preset may run on, in cells a side
768    /// (ADR-0268).
769    ///
770    /// **A cap on content, like [`cellular_grid`](Self::cellular_grid), clamped
771    /// and announced at load** ([`OverflowContext::Voxels`](super::scenes::OverflowContext::Voxels)):
772    /// a structure is a fixed number of cells, so a cube clamped from 128 to 64
773    /// draws every structure twice as large. A preset asking within the `Floor`
774    /// value runs the same automaton on both tiers.
775    ///
776    /// The step costs in proportion to the cells, and the march in proportion
777    /// to the cells a ray crosses: `Floor`'s 64³ is an eighth of `Rich`'s 128³
778    /// to step and half of it to cross.
779    pub voxel_grid: u32,
780
781    /// The fraction of the render target the voxel march resolves (ADR-0268),
782    /// before [`voxel_march_cap`](Self::voxel_march_cap) bounds it and on top of
783    /// the renderer's own [`grid_scale`](Self::grid_scale).
784    ///
785    /// **A resolution, not content** (ADR-0037): the march's target is a plain
786    /// stretch onto the render target, so a smaller one draws the same volume
787    /// softer and nothing is announced.
788    pub voxel_march_scale: GridScale,
789
790    /// The largest target the voxel march draws into, in pixels; one past it
791    /// is scaled down on both axes by one factor (`grid::grid_size`).
792    pub voxel_march_cap: (u32, u32),
793}
794
795impl TierConfig {
796    /// The iGPU floor: the pre-tier engine's constants, unchanged.
797    pub const FLOOR: Self = Self {
798        tier: Tier::Floor,
799        grid_scale: GridScale::FULL,
800        post_cap: (1920, 1080),
801        bloom_levels: 4,
802        attractor_particles: 50_000,
803        attractor_particles_live_ceiling: 50_000,
804        attractor_particles_offline_ceiling: 900_000,
805        attractor_trail_cap: (2560, 1440),
806        swarm_particles: 10_000,
807        emitter_objects: 2_000,
808        mesh_grid: (64, 48),
809        max_segments: 20_000,
810        collage_elements: 40,
811        field_iterations: 64,
812        cellular_radius: 6,
813        cellular_grid: 512,
814        cellular_route_rate: 237,
815        plexus_points: 600,
816        plexus_edges: 6_000,
817        max_coc_px: 12,
818        seg3d_segments: 8_000,
819        swarm_max_coc_px: 12,
820        voxel_grid: 64,
821        voxel_march_scale: GridScale::FULL,
822        voxel_march_cap: (1920, 1080),
823    };
824
825    /// The midrange-discrete tier.
826    ///
827    /// **These are provisional multipliers, not measurements.** Plan 0044 Phase 4
828    /// runs the standalone pinned here on the target GPU at native fullscreen
829    /// across the heaviest preset of each family and records the frame times; the
830    /// values that ship are the ones that hold the display rate. A number that
831    /// misses gets lowered, and no number here is invented upward to look good.
832    /// Until that phase closes, treat every field below as a starting point.
833    pub const RICH: Self = Self {
834        tier: Tier::Rich,
835        grid_scale: GridScale::FULL,
836        post_cap: (2560, 1440),
837        bloom_levels: 6,
838        attractor_particles: 150_000,
839        attractor_particles_live_ceiling: 600_000,
840        attractor_particles_offline_ceiling: 2_700_000,
841        attractor_trail_cap: (3840, 2160),
842        swarm_particles: 30_000,
843        emitter_objects: 6_000,
844        mesh_grid: (88, 66),
845        max_segments: 60_000,
846        collage_elements: 96,
847        field_iterations: 512,
848        cellular_radius: 10,
849        cellular_grid: 1024,
850        cellular_route_rate: 192,
851        plexus_points: 1_500,
852        plexus_edges: 20_000,
853        max_coc_px: 24,
854        seg3d_segments: 20_000,
855        swarm_max_coc_px: 24,
856        voxel_grid: 128,
857        voxel_march_scale: GridScale::FULL,
858        voxel_march_cap: (2560, 1440),
859    };
860
861    /// The config for `tier`.
862    pub const fn for_tier(tier: Tier) -> Self {
863        match tier {
864            Tier::Floor => Self::FLOOR,
865            Tier::Rich => Self::RICH,
866        }
867    }
868
869    /// These values with their grids drawn at `scale`.
870    pub const fn with_grid_scale(self, scale: GridScale) -> Self {
871        Self {
872            grid_scale: scale,
873            ..self
874        }
875    }
876}
877
878impl Default for TierConfig {
879    fn default() -> Self {
880        Self::FLOOR
881    }
882}
883
884// ---------------------------------------------------------------------------
885// The attractor's sample budget (ADR-0140)
886// ---------------------------------------------------------------------------
887
888/// The render target the attractor's sample density is anchored to: 640x360,
889/// 230 400 pixels.
890///
891/// The attractor's trail grid is surface-sized, so its deposit spreads over
892/// whatever the target holds and a flat count therefore *falls* in density as the
893/// target grows — 0.651 particles per pixel per frame here, 0.072 at 1080p, which
894/// is the whole of the "it just looks like Leviathan upscaled" verdict. This is
895/// the size whose density is already accepted, so it is where
896/// [`attractor_budget`] resolves to exactly the tier's own anchor.
897///
898/// **The numerator is the grid's texels before quantization** —
899/// [`GridScale::texels`] of the target's pixels, which at [`GridScale::FULL`] is
900/// the target's pixel count exactly — so a field drawn at a fraction of the
901/// target is sampled at the density this reference names rather than
902/// over-sampled for texels it does not have (ADR-0245). What it still does not
903/// count is the grid's 128-texel rounding, a bounded difference either way —
904/// 1.07x here and at 720p, 0.95x at 1080p, 1.00x at a capped 4K — named
905/// because the deposit lands per texel.
906pub const REFERENCE_PX: u32 = 230_400;
907
908/// The attractor's drawn sample budget for a target of `target_px` pixels, before
909/// a preset's `[particles] density` narrows it further (ADR-0140).
910///
911/// `clamp(round(anchor * target_px / REFERENCE_PX), anchor, ceiling)`.
912///
913/// **The lower clamp is load-bearing.** The law can only ever *add* samples above
914/// [`REFERENCE_PX`], never remove them below it, so every existing capture — the
915/// 128x128 golden suite, the 96x96 sanity suite, every small `shot` still —
916/// resolves to exactly the count it resolved before this function existed and
917/// stays byte-identical. That is assertable on the value rather than inferred
918/// from pixels, which is the same shape of argument ADR-0065 used for
919/// `deposit_scale` being exactly `1.0` at `Floor`.
920///
921/// `f64` throughout: `anchor * target_px` reaches 41 bits at a 4K target, past
922/// `f32`'s 24-bit mantissa, so the product would be rounded before the divide.
923///
924/// A `ceiling` below `anchor` is raised to it rather than inverting the clamp —
925/// `u32::clamp` panics when `min > max`, and this runs on the resize path.
926pub fn attractor_budget(anchor: u32, target_px: u32, ceiling: u32) -> u32 {
927    let scaled = (f64::from(anchor) * f64::from(target_px) / f64::from(REFERENCE_PX)).round();
928    let scaled = if scaled >= f64::from(u32::MAX) {
929        u32::MAX
930    } else {
931        scaled as u32
932    };
933    scaled.clamp(anchor, ceiling.max(anchor))
934}
935
936impl TierConfig {
937    /// [`attractor_budget`] against this tier's **live** ceiling — what a window
938    /// and the plugin's host surface resolve.
939    pub fn attractor_budget_live(&self, target_px: u32) -> u32 {
940        attractor_budget(
941            self.attractor_particles,
942            target_px,
943            self.attractor_particles_live_ceiling,
944        )
945    }
946
947    /// [`attractor_budget`] against this tier's **offline** ceiling — what a
948    /// headless render resolves, where the bound is memory rather than frame time.
949    pub fn attractor_budget_offline(&self, target_px: u32) -> u32 {
950        attractor_budget(
951            self.attractor_particles,
952            target_px,
953            self.attractor_particles_offline_ceiling,
954        )
955    }
956}
957
958// ---------------------------------------------------------------------------
959// The frame-time governor (Plan 0044 Phase 2)
960// ---------------------------------------------------------------------------
961
962/// The display budget assumed when the frontend has not named a refresh rate —
963/// 60 Hz, the rate NFR §1's floor is quoted at.
964pub const DEFAULT_DISPLAY_HZ: f32 = 60.0;
965
966/// How far past the display budget a single frame must run to count as a miss.
967///
968/// Not 1.0. A frame landing a hair over the budget is the ordinary condition of a
969/// vsynced renderer — the measured interval *is* the refresh interval, plus
970/// scheduling noise — so a bare comparison would read a perfectly healthy 60 fps
971/// run as missing on half its frames. 1.25 is "missing the budget by a quarter",
972/// which at 60 Hz is 20.8 ms: past it the run is visibly not holding the rate.
973pub const MISS_FACTOR: f32 = 1.25;
974
975/// What fraction of the observed frames must be misses before the governor
976/// demotes. Three quarters: high enough that an intermittently-heavy passage
977/// rides through, low enough that a genuine overload does not have to be
978/// unanimous (a demotion is triggered by *sustained* pressure, and a real
979/// overload still has fast frames in it — a cheap preset in the rotation, a
980/// dissolve that ended).
981pub const MISS_FRACTION: f32 = 0.75;
982
983/// Frames of history required before the governor will demote at all.
984///
985/// This is the hysteresis, and it is a *count* rather than a smoothing constant
986/// on purpose: it makes "a single spike must not demote" true by arithmetic
987/// instead of by tuning. With 180 frames required and 75 % of them needing to
988/// miss, no run of fewer than 135 consecutive bad frames can demote — so a window
989/// drag, a driver hiccup, or a shader compile cannot, whatever their magnitude.
990/// At 60 Hz it is also a 3 s warm-up, which keeps the pathological first frames
991/// of a session (pipeline creation, first-use resource builds) out of the verdict.
992///
993/// **This number is only satisfiable because of a constant in another module.**
994/// The only series the renderer ever hands [`sustained_miss`] is
995/// [`FrameStats::samples`](crate::diag::FrameStats::samples), which yields at
996/// most `crate::diag::RING` items — so a `MIN_SAMPLES` above the ring's
997/// capacity makes the governor a permanent no-op. See the assertion below.
998pub const MIN_SAMPLES: usize = 180;
999
1000/// The governor must be able to *reach* its own threshold from its real input.
1001///
1002/// A build failure rather than a test, because the failure it guards is silent:
1003/// nothing observable happens when the governor stops demoting — a machine that
1004/// cannot hold the rich budget simply stutters for the rest of the session, which
1005/// is the exact NFR §1 outcome ADR-0045 built the governor to prevent. A runtime
1006/// check could not fire (there is nothing to fire *on*), and a unit test over an
1007/// injected series cannot see this at all: the series in the tests below are
1008/// `Vec`s of any length we like, so they would keep passing while the real
1009/// producer had gone too short to ever trigger a demotion.
1010const _: () = assert!(
1011    MIN_SAMPLES <= crate::diag::RING,
1012    "the frame-time ring is shorter than the governor's minimum sample count, \
1013     so the governor can never demote"
1014);
1015
1016/// Whether a frame-time series shows a **sustained** miss of the display budget,
1017/// which is the one condition that demotes [`Tier::Rich`] to [`Tier::Floor`]
1018/// (ADR-0045).
1019///
1020/// Pure and total: a function of the series and the budget with no clock, no
1021/// state and no allocation, so the policy is unit-testable against injected
1022/// series (which is how the spike-versus-overload distinction above is checked
1023/// rather than asserted). `frame_secs` is the rolling history in **seconds**, the
1024/// unit [`FrameStats::samples`](crate::diag::FrameStats::samples) yields; order
1025/// does not matter, only the counts.
1026///
1027/// Says `false` for a non-positive or non-finite budget, and for a series shorter
1028/// than [`MIN_SAMPLES`] — the safe direction, since a wrong `true` costs the user
1029/// the rich tier for the rest of the session and a wrong `false` costs nothing but
1030/// another second of measurement.
1031pub fn sustained_miss(frame_secs: impl Iterator<Item = f32>, budget_secs: f32) -> bool {
1032    if !budget_secs.is_finite() || budget_secs <= 0.0 {
1033        return false;
1034    }
1035    let threshold = budget_secs * MISS_FACTOR;
1036    let mut total = 0usize;
1037    let mut missed = 0usize;
1038    for dt in frame_secs {
1039        total += 1;
1040        if dt.is_finite() && dt > threshold {
1041            missed += 1;
1042        }
1043    }
1044    total >= MIN_SAMPLES && missed as f32 >= total as f32 * MISS_FRACTION
1045}
1046
1047/// **The governor's whole decision**: whether to demote `tier` right now.
1048///
1049/// Pure — every input is a value, so the three properties ADR-0045 asks of the
1050/// governor are unit-testable together rather than one being a fact about
1051/// `Renderer`'s field layout: a pin never demotes, an already-demoted session
1052/// never demotes again (the one-way latch), and only a sustained miss demotes.
1053///
1054/// The caller owns the latch: it sets its "demoted" flag and rebuilds when this
1055/// says `true`, and passes that flag back in on every later frame. Keeping the
1056/// flag out here is what makes "exactly once" a property of the decision instead
1057/// of a property of the call site.
1058pub fn should_demote(
1059    tier: Tier,
1060    pinned: bool,
1061    already_demoted: bool,
1062    frame_secs: impl Iterator<Item = f32>,
1063    budget_secs: f32,
1064) -> bool {
1065    // Ordered cheapest-first: three flag reads settle the steady state, and the
1066    // series is only walked on a governed rich session that has not yet demoted.
1067    if pinned || already_demoted || tier == Tier::Floor {
1068        return false;
1069    }
1070    sustained_miss(frame_secs, budget_secs)
1071}
1072
1073/// **Whether a runtime tier change is allowed at all** (ADR-0054): only on a
1074/// context that has a surface.
1075///
1076/// A surface-less context is exactly the headless capture path, and ADR-0045's
1077/// guarantee is that a capture is `Tier::Floor` **by construction** —
1078/// `Renderer::new_headless` takes no tier argument, so no baseline can be blessed
1079/// at another tier by forgetting a field. `Renderer::set_tier` is a public
1080/// mutator on the very type the golden suite renders through, so it is the one
1081/// hole that guarantee was shaped to exclude, and this predicate is what keeps it
1082/// closed.
1083///
1084/// Pure, and separate from `set_tier`, deliberately. A `Renderer` **with** a
1085/// surface cannot be constructed in CI — there is no window — so a test that only
1086/// observed the headless no-op would pass equally well against a `set_tier` that
1087/// did nothing at all. Expressed as a value-in/value-out function, both
1088/// directions are assertable.
1089pub fn tier_change_permitted(has_surface: bool) -> bool {
1090    has_surface
1091}
1092
1093/// The frame budget for a display running at `hz`, in seconds. Falls back to
1094/// [`DEFAULT_DISPLAY_HZ`] for a value that is not a usable rate, so a frontend
1095/// that cannot read its monitor still gets a governed session rather than an
1096/// ungoverned one.
1097pub fn budget_secs(hz: f32) -> f32 {
1098    let hz = if hz.is_finite() && hz > 0.0 {
1099        hz
1100    } else {
1101        DEFAULT_DISPLAY_HZ
1102    };
1103    1.0 / hz
1104}
1105
1106#[cfg(test)]
1107mod tests;