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 WARP
50//! software adapter and the suite's cost does not scale with the rich tier.
51//! [`Renderer::new_headless_tiered`](super::Renderer::new_headless_tiered) is the
52//! deliberate opt-in the `shot` CLI's `--tier` reaches.
53//!
54//! Pure and GPU-free throughout — a tier is a set of numbers, so it is decided
55//! without a device and tested without one.
56
57// Hot-path panic-denial pragma (Plan 0002 Phase 2; render/ is scanned by the
58// hygiene guard). The governor runs once per displayed frame.
59#![deny(
60    clippy::unwrap_used,
61    clippy::expect_used,
62    clippy::indexing_slicing,
63    clippy::panic,
64    clippy::unreachable
65)]
66
67/// Which quality tier a renderer is running (ADR-0045).
68///
69/// Two named levels rather than a continuum: the output of a preset has to be
70/// predictable enough to baseline, document, and reproduce in a bug report, which
71/// a load-history-dependent feature-shedding scheme cannot deliver (ADR-0045
72/// Alternative B).
73#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
74pub enum Tier {
75    /// The NFR §1/§2 iGPU floor — the pre-tier engine's exact constants. The
76    /// default here because it is the safe answer: a `Tier` value that appeared
77    /// from nowhere should not raise anyone's budgets.
78    #[default]
79    Floor,
80    /// Calibrated for a midrange discrete GPU: higher particle, segment and
81    /// resolution budgets, same visual grammar.
82    Rich,
83}
84
85impl Tier {
86    /// The lowercase name the CLI, the env var and the config file all use.
87    pub fn as_str(self) -> &'static str {
88        match self {
89            Tier::Floor => "floor",
90            Tier::Rich => "rich",
91        }
92    }
93
94    /// The uppercase name the 5x7 diagnostics overlay paints. Separate from
95    /// [`as_str`](Self::as_str) only because that font has no lowercase glyphs;
96    /// both come off the same match so there is no second spelling to drift.
97    pub fn label(self) -> &'static str {
98        match self {
99            Tier::Floor => "FLOOR",
100            Tier::Rich => "RICH",
101        }
102    }
103
104    /// Parse a tier name, case-insensitively. `None` for anything else — callers
105    /// surface that as a usage error rather than guessing a tier.
106    pub fn from_name(name: &str) -> Option<Self> {
107        match name.trim().to_ascii_lowercase().as_str() {
108            "floor" => Some(Tier::Floor),
109            "rich" => Some(Tier::Rich),
110            _ => None,
111        }
112    }
113}
114
115/// The capacity values a tier sets. Resolved once at renderer construction and
116/// read at construction/reconfigure time only — never branched on per frame.
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118pub struct TierConfig {
119    /// Which tier these values are, so a demotion and the overlay have one thing
120    /// to read rather than a parallel field to keep in step.
121    pub tier: Tier,
122
123    /// Cap on a post stage's internal grid (ADR-0034), width then height.
124    ///
125    /// The floor value is NFR §12 memory arithmetic, **redone for the linear-light
126    /// composite** (Plan 0045 Phase 3 / ADR-0046). Every intermediate upstream of
127    /// the tonemap is now `COMPOSITE_FORMAT` — 8
128    /// bytes/texel, not 4 — so a stage offscreen costs twice what the surface
129    /// format would charge, while the trails accumulation
130    /// (`PingPongField`, two textures) was
131    /// already float and did not move.
132    ///
133    /// Per chain, both stages live, at this cap (1920x1080, 8 bytes/texel =
134    /// 16.6 MB a texture):
135    ///
136    /// | buffer                     | before | after |
137    /// |----------------------------|--------|-------|
138    /// | trails composited          | 8.3    | 16.6  |
139    /// | trails accumulation (x2)   | 33.2   | 33.2  |
140    /// | kaleidoscope source        | 8.3    | 16.6  |
141    /// | **per chain**              | **50** | **66** |
142    ///
143    /// Plan 0023's dual-live dissolve holds two whole `PostChain`s, so the peak is
144    /// ~133 MB rather than ~100. Outside the chains the frame carries one more
145    /// surface-sized float buffer beyond the chains — the tonemap's input, 16.6
146    /// MB, the one allocation ADR-0046 genuinely adds — plus the blend's
147    /// snapshot/live pair at 16.6 MB each while a dissolve runs (8.3 at 8-bit),
148    /// and ink's 8.3 MB input, which stays 8-bit because the tonemap hands it
149    /// display-referred pixels. Worst case — dual-live, both stages, ink on — is
150    /// ~191 MB against NFR §12's ~350 MB soft ceiling, which is mostly driver
151    /// floor already.
152    ///
153    /// At the rich cap (2560x1440) the same arithmetic is ~118 MB per chain and
154    /// ~236 MB dual-live, up from ~88 and ~177 — the trade ADR-0034 priced and
155    /// declined at floor budgets and the rich tier takes.
156    ///
157    /// **This cap is the relief lever** if the float chain misses NFR §1 on a
158    /// floor-tier iGPU: lower it rather than re-fixing the grids (ADR-0046), since
159    /// bandwidth roughly doubled with the format and the grid policy is shared.
160    ///
161    /// **Bloom adds to this only when a preset switches it on** (Plan 0045
162    /// Phase 4), and it is **two** allocations, not one. Its pyramid is two
163    /// textures per level, each level a quarter of the last, so the pyramid
164    /// converges to `2 * (1/4 + 1/16 + …) ≈ 2/3` of one grid-sized texture — ~11 MB
165    /// at this cap. On top of that the stage owns its own **grid-sized `bloom-src`
166    /// offscreen**, a full 16.6 MB at this cap, because a `PostStage` reads its
167    /// input from a texture it owns. So the stage costs **16.6 + ~11 ≈ 28 MB** on
168    /// top of the ~66 MB per chain above, and ~55 MB in the dual-live worst case —
169    /// which is what NFR §12's table charges and what the ~246 MB worst case there
170    /// is computed from. It is charged only against presets that bind
171    /// `bloom_amount`, since an inactive stage builds nothing.
172    pub post_cap: (u32, u32),
173
174    /// How many levels deep the bloom pyramid goes
175    /// (`Bloom`, ADR-0046).
176    ///
177    /// This is a **capacity**, not a look: each level doubles the halo's reach and
178    /// costs three passes at a quarter of the previous level's area, so the tail
179    /// is cheap in pixels and not free in passes. The floor runs four (a halo
180    /// reaching ~16 of the grid's texels at the default radius); rich runs six,
181    /// which is where the widest levels start to matter on a 1440p-class grid.
182    ///
183    /// `level_sizes` clamps this down on a small render target, so
184    /// a value here is an upper bound rather than a promise.
185    pub bloom_levels: u32,
186
187    /// The attractor's sample budget **at [`REFERENCE_PX`]** — the anchor of the
188    /// density law, not the count drawn (ADR-0140).
189    ///
190    /// [`attractor_budget`] scales this by `target_px / REFERENCE_PX` and clamps
191    /// it between this value and one of the two ceilings below, so a target at or
192    /// under the reference draws exactly this many and a larger one draws more.
193    /// What a *preset* then draws out of that budget is
194    /// `round(budget * density)` (ADR-0069), which is a different and smaller
195    /// number again.
196    ///
197    /// State is 48 bytes each ([`Particle`](super::scenes::particles)), and the
198    /// real ceiling is **additive-blend fill rate**, which is why the floor value
199    /// was described as the number to validate against the 60 fps @ 1080p floor
200    /// (ADR-0015 Risks).
201    ///
202    /// This is a sample count and **not** a brightness: the additive draw divides
203    /// its deposit by the *active* count
204    /// ([`deposit_scale`](super::scenes::particles::deposit_scale), ADR-0065), so
205    /// raising it buys a smoother figure rather than a brighter one. Changing it
206    /// changes shot noise and cost; it does not change exposure.
207    pub attractor_particles: u32,
208
209    /// The largest budget [`attractor_budget`] may resolve for a scene drawing
210    /// into a **live surface** — a window, or the plugin's host surface.
211    ///
212    /// Frame-time bound, and it is the number that keeps the law from spending a
213    /// display's whole budget on sample count. It is also the **allocation**: the
214    /// particle buffer is sized here at construction and never resized, so a
215    /// resize changes the active count and rebuilds no GPU resource. That costs
216    /// `ceiling * 48 B` of GPU storage plus the same again for the CPU seed
217    /// scatter the scene holds for re-upload, in **every** window, whether or not
218    /// the target is large and whether or not an attractor preset is loaded
219    /// (`create_all` builds every scene up front).
220    ///
221    /// # Where these numbers come from
222    ///
223    /// Measured, not chosen — Plan 0128 Phase 1, at 1920x1080 on
224    /// `attractor_leviathan`, counts interleaved in one process so a throttling
225    /// laptop GPU could not read as signal.
226    ///
227    /// **`Rich`: 600 000**, four times the anchor. On the midrange-discrete
228    /// reference NFR §1 calibrates `Rich` against, the rule was *the largest swept
229    /// count whose marginal p99 over today's anchor stays inside 10 % of the
230    /// 16.67 ms budget*: 600 000 reads +1.454 ms (8.7 %) and the next step up,
231    /// 1 200 000, reads +4.703 ms (28.2 %).
232    ///
233    /// **`Floor`: its own anchor**, so the law is a no-op there. On integrated
234    /// hardware — the baseline NFR §1's floor commitment is about — 1080p at
235    /// `Floor` already sits *on* the 16.67 ms budget at today's 50 000 (p99
236    /// 16.854 ms), and the law's own 1080p `Floor` value of 450 000 takes it to
237    /// 31.942 ms. NFR §1 promises `Floor` "values exactly the pre-tier engine's";
238    /// this is what keeps that true at every target size.
239    pub attractor_particles_live_ceiling: u32,
240
241    /// The same ceiling for a **headless render** — `shot --render`, where there
242    /// is no present deadline and no governor, and the only bound is memory.
243    ///
244    /// # Where these numbers come from
245    ///
246    /// The bound is the **device's storage-buffer binding limit**, not process
247    /// memory, because it is reached first: at 48 B a particle, wgpu's default
248    /// `max_storage_buffer_binding_size` of 134 217 728 B holds 2 796 202
249    /// particles, and 5 400 000 — the law's own unclamped 4K value — fails
250    /// outright with `Buffer binding 0 range 259200000 exceeds
251    /// max_*_buffer_binding_size limit 134217728` (Plan 0128 Phase 1).
252    ///
253    /// `Rich` takes **2 700 000**, the largest whole multiple of its anchor under
254    /// that wall — 18x, 129.6 MB, 3.4 % of headroom. `Floor` takes the **same
255    /// multiple** rather than the same number, so a tier still means something
256    /// offline: at one shared ceiling `--tier floor --render` and
257    /// `--tier rich --render` would draw an identical count at 4K.
258    pub attractor_particles_offline_ceiling: u32,
259
260    /// Upper bound on each axis of the attractor's trail accumulation grid.
261    ///
262    /// The floor is the ceiling Plan 0027/0029 chose for a high-DPI display while
263    /// keeping the worst case bounded on the iGPU: every frame pays a decay pass
264    /// plus the full additive instance draw over this grid, so cost scales with
265    /// its area. Rich lifts it to 4K so a 4K or ultrawide display sizes near 1:1
266    /// instead of degrading to a uniform upscale.
267    pub attractor_trail_cap: (u32, u32),
268
269    /// How many particles the swarm simulates.
270    ///
271    /// Plan 0043 left the floor value at an **unmeasured** iGPU cost (+0.5 ms per
272    /// frame of depth math on the dev box, and not fill rate), which is why the
273    /// plans index calls this a live tier candidate rather than a settled
274    /// constant. If the on-device floor check misses, this is the lever — on the
275    /// floor value, and that routes back through `architect`.
276    pub swarm_particles: usize,
277
278    /// How many objects the emitter's pool holds (ADR-0057).
279    ///
280    /// Unlike every other count here this is a **ceiling on a varying
281    /// population**, not the population: the emitter spawns and retires, so a
282    /// preset's `spawn_rate * lifetime` decides how many objects are actually
283    /// alive and this decides how many *can* be. Spawns past it are dropped
284    /// rather than queued or allocated for — that is the phase's whole real-time
285    /// hazard — so raising it does not brighten a preset that never reaches it,
286    /// and lowering it below one that does thins the shower rather than changing
287    /// its motion.
288    ///
289    /// Not an accumulating count in the sense the module docs warn about: each
290    /// object is one sprite drawn once per frame, so the light in the frame is
291    /// `population * brightness` and the population is a preset's own arithmetic.
292    /// The tier only says where that arithmetic is cut off.
293    ///
294    /// The floor holds a shipped preset's shower with room to spare (the emitter
295    /// family runs a few hundred objects live); rich triples it for the denser
296    /// looks a discrete GPU can carry. Cheap either way — see NFR §12: the pool
297    /// and its instance buffer are well under a megabyte at both tiers.
298    pub emitter_objects: usize,
299
300    /// Ceiling on the warp mesh's grid, in **cells**, width then height
301    /// (Plan 0100 Phase 1).
302    ///
303    /// A capacity in the strictest sense: the grid is a *resolution* for the
304    /// per-vertex program, not a shape (ADR-0037), so raising it refines the
305    /// warp's spatial detail and changes nothing about what the scene draws. The
306    /// vertex count is `(x + 1) * (y + 1)`, and every one of those vertices costs
307    /// one evaluation of each `[per_vertex]` binding **on the render thread**,
308    /// which is what this bounds.
309    ///
310    /// The upper bound either tier may name is the `.milk` format's own —
311    /// `meshx <= 128`, `meshy <= 96` — so a converted preset's requested grid is
312    /// representable at the top of the range and clamped below it.
313    /// [`warp_mesh::clamp_grid`](super::scenes::warp_mesh::clamp_grid) is the one
314    /// place the clamp happens, shared by the loader and the scene.
315    ///
316    /// # Where these numbers come from
317    ///
318    /// **Measured, not chosen** — Plan 0100 Phase 1's done-when. The rule it set
319    /// was: raise the grid until one frame of per-vertex evaluation costs more
320    /// than **1 ms** — 6 % of the 16.67 ms NFR §1 commits to at 1080p — and cap
321    /// the floor one step below.
322    ///
323    /// `mesh_cost_by_grid` in `scenes/warp_mesh/tests.rs` is the measurement and
324    /// prints the ladder on every run. Taken **2026-08-16 on the development box
325    /// (Windows 10, desktop CPU, `--release`)**, evaluating a four-binding
326    /// `[per_vertex]` program of the shape a real preset writes — two runs,
327    /// agreeing to about 1 %:
328    ///
329    /// ```text
330    ///  grid      vertices    per frame     share of 16.67 ms
331    ///  16x12        221       0.036 ms      0.2 %
332    ///  32x24        825       0.129 ms      0.8 %
333    ///  48x36      1 813       0.280 ms      1.7 %
334    ///  64x48      3 185       0.488 ms      2.9 %   <- Floor
335    ///  72x54      4 015       0.616 ms      3.7 %
336    ///  80x60      4 941       0.755 ms      4.5 %
337    ///  88x66      5 963       0.909 ms      5.5 %   <- Rich
338    ///  96x72      7 081       1.081 ms      6.5 %   <- the bar is crossed here
339    /// 112x84      9 605       1.483 ms      8.9 %
340    /// 128x96     12 513       1.918 ms     11.5 %
341    /// ```
342    ///
343    /// **The bar is crossed between `88x66` and `96x72`**, so `88x66` is the
344    /// largest grid the rule admits and it is what `Rich` takes. The format's own
345    /// maximum is therefore **refused**: at 1.92 ms it is 11.5 % of the frame on
346    /// a desktop CPU, which is not a number any tier should spend on one
347    /// parameter surface. The grid is lowered because it did not measure clean,
348    /// which is the whole of the rule.
349    ///
350    /// **`Floor` sits a step further down than the rule alone would put it, and
351    /// deliberately.** The rig above is a desktop CPU; NFR §1's floor tier
352    /// targets a ~2015 iGPU-class machine whose single-thread performance this
353    /// box does not model, and this is CPU work on the render thread, so a
354    /// slower machine pays proportionally more of a budget it is already
355    /// struggling to hold. `64x48` is 2.9 % here and leaves room for that
356    /// machine to be several times slower before the surface is a problem.
357    ///
358    /// **When the floor tier is next exercised on real target hardware this is
359    /// the constant to re-measure**, and the ladder prints exactly what that
360    /// needs.
361    pub mesh_grid: (u32, u32),
362
363    /// The one capacity value here that a preset can *see*: past it geometry is
364    /// truncated, and ADR-0007 requires that be surfaced rather than silently cut.
365    /// So a preset whose mirror pushes over the floor cap reports an overflow at
366    /// the floor and not at rich — the message is the tier's most visible edge for
367    /// the content lane, which is why shipped presets are authored against the
368    /// floor.
369    pub max_segments: usize,
370
371    /// Cap on how many flat elements a `shape_collage` canvas may hold
372    /// (ADR-0123).
373    ///
374    /// **The one capacity here that bounds a per-pixel loop**, which is what
375    /// makes it load-bearing rather than a memory number. Every other count in
376    /// this struct bounds work paid once per particle, per vertex or per
377    /// segment; this one bounds work every *fragment* pays, so a frame costs
378    /// `elements x pixels` and ADR-0123 prices the bounding-box reject alone —
379    /// before anything is drawn — at roughly `6N` operations per pixel. The
380    /// buffer is irrelevant at any value either tier would take: 64 bytes an
381    /// element, so 128 elements is 8 KB.
382    ///
383    /// # Where the floor value comes from
384    ///
385    /// **Measured, then decided by a human** — Plan 0113 Phases 2 and 3.
386    /// `core/tests/collage_cost.rs` sweeps the count on hardware, prints
387    /// the ladder on every run, and its module docs own the readings and
388    /// the trap in quoting them. **Two tables are not interchangeable**:
389    /// the pre-roster ladder is what the Phase 3 gate read, and the
390    /// post-roster table is what a canvas costs today — the eight-kind
391    /// roster made the loop cheaper, because rings, sectors and checker
392    /// patches shade far less of their own bounding box than a quad
393    /// does.
394    ///
395    /// **40 is the reference set's own top, not a budget line.** The
396    /// gate was a look judgement and the cost was not the binding
397    /// constraint: the user's working density is **8 to 14 elements**,
398    /// which on the system as shipped costs **8.2 % of a 60 Hz frame at
399    /// eight and 10.7 % at sixteen**, and denser canvases were rejected
400    /// on sight long before they were rejected on cost. The ceiling is
401    /// the densest canvas in ADR-0123's roster, Kandinsky's *On White
402    /// II*, counted at just above 40 once its lines and arcs are
403    /// included — so this value sits **exactly on** that canvas, and a
404    /// `collage_onwhite` needing a forty-first element moves this
405    /// number rather than being quietly truncated.
406    ///
407    /// `Rich` is provisional in the sense every [`RICH`](TierConfig::RICH) value
408    /// is — see that constant's own note.
409    ///
410    /// # It clamps, and it does not yet say so
411    ///
412    /// `shape_collage::applied_count` holds a bound `count` to this value
413    /// **silently**, unlike [`max_segments`](Self::max_segments), which
414    /// ADR-0007 requires surface an overflow. That was harmless while the cap
415    /// sat far above any authored canvas and is not harmless now that it sits on
416    /// one. Recorded as a followup on Plan 0113 rather than fixed there: the
417    /// surfaced channel is [`CapOverflow`](super::scenes::lines::CapOverflow),
418    /// whose context enum is shared with the line scenes, so widening it is an
419    /// architect call.
420    pub collage_elements: usize,
421}
422
423impl TierConfig {
424    /// The iGPU floor: the pre-tier engine's constants, unchanged.
425    pub const FLOOR: Self = Self {
426        tier: Tier::Floor,
427        post_cap: (1920, 1080),
428        bloom_levels: 4,
429        attractor_particles: 50_000,
430        attractor_particles_live_ceiling: 50_000,
431        attractor_particles_offline_ceiling: 900_000,
432        attractor_trail_cap: (2560, 1440),
433        swarm_particles: 10_000,
434        emitter_objects: 2_000,
435        mesh_grid: (64, 48),
436        max_segments: 20_000,
437        collage_elements: 40,
438    };
439
440    /// The midrange-discrete tier.
441    ///
442    /// **These are provisional multipliers, not measurements.** Plan 0044 Phase 4
443    /// runs the standalone pinned here on the target GPU at native fullscreen
444    /// across the heaviest preset of each family and records the frame times; the
445    /// values that ship are the ones that hold the display rate. A number that
446    /// misses gets lowered, and no number here is invented upward to look good.
447    /// Until that phase closes, treat every field below as a starting point.
448    pub const RICH: Self = Self {
449        tier: Tier::Rich,
450        post_cap: (2560, 1440),
451        bloom_levels: 6,
452        attractor_particles: 150_000,
453        attractor_particles_live_ceiling: 600_000,
454        attractor_particles_offline_ceiling: 2_700_000,
455        attractor_trail_cap: (3840, 2160),
456        swarm_particles: 30_000,
457        emitter_objects: 6_000,
458        mesh_grid: (88, 66),
459        max_segments: 60_000,
460        collage_elements: 96,
461    };
462
463    /// The config for `tier`.
464    pub const fn for_tier(tier: Tier) -> Self {
465        match tier {
466            Tier::Floor => Self::FLOOR,
467            Tier::Rich => Self::RICH,
468        }
469    }
470}
471
472impl Default for TierConfig {
473    fn default() -> Self {
474        Self::FLOOR
475    }
476}
477
478// ---------------------------------------------------------------------------
479// The attractor's sample budget (ADR-0140)
480// ---------------------------------------------------------------------------
481
482/// The render target the attractor's sample density is anchored to: 640x360,
483/// 230 400 pixels.
484///
485/// The attractor's trail grid is surface-sized, so its deposit spreads over
486/// whatever the target holds and a flat count therefore *falls* in density as the
487/// target grows — 0.651 particles per pixel per frame here, 0.072 at 1080p, which
488/// is the whole of the "it just looks like Leviathan upscaled" verdict. This is
489/// the size whose density is already accepted, so it is where
490/// [`attractor_budget`] resolves to exactly the tier's own anchor.
491///
492/// **The denominator is target pixels, not grid texels**, and the two differ by
493/// the grid's 256-px quantization — 1.71x here, 1.07x at 720p, 1.26x at 1080p,
494/// 1.00x at 4K. Bounded, and named because the deposit lands per texel.
495pub const REFERENCE_PX: u32 = 230_400;
496
497/// The attractor's drawn sample budget for a target of `target_px` pixels, before
498/// a preset's `[particles] density` narrows it further (ADR-0140).
499///
500/// `clamp(round(anchor * target_px / REFERENCE_PX), anchor, ceiling)`.
501///
502/// **The lower clamp is load-bearing.** The law can only ever *add* samples above
503/// [`REFERENCE_PX`], never remove them below it, so every existing capture — the
504/// 128x128 golden suite, the 96x96 sanity suite, every small `shot` still —
505/// resolves to exactly the count it resolved before this function existed and
506/// stays byte-identical. That is assertable on the value rather than inferred
507/// from pixels, which is the same shape of argument ADR-0065 used for
508/// `deposit_scale` being exactly `1.0` at `Floor`.
509///
510/// `f64` throughout: `anchor * target_px` reaches 41 bits at a 4K target, past
511/// `f32`'s 24-bit mantissa, so the product would be rounded before the divide.
512///
513/// A `ceiling` below `anchor` is raised to it rather than inverting the clamp —
514/// `u32::clamp` panics when `min > max`, and this runs on the resize path.
515pub fn attractor_budget(anchor: u32, target_px: u32, ceiling: u32) -> u32 {
516    let scaled = (f64::from(anchor) * f64::from(target_px) / f64::from(REFERENCE_PX)).round();
517    let scaled = if scaled >= f64::from(u32::MAX) {
518        u32::MAX
519    } else {
520        scaled as u32
521    };
522    scaled.clamp(anchor, ceiling.max(anchor))
523}
524
525impl TierConfig {
526    /// [`attractor_budget`] against this tier's **live** ceiling — what a window
527    /// and the plugin's host surface resolve.
528    pub fn attractor_budget_live(&self, target_px: u32) -> u32 {
529        attractor_budget(
530            self.attractor_particles,
531            target_px,
532            self.attractor_particles_live_ceiling,
533        )
534    }
535
536    /// [`attractor_budget`] against this tier's **offline** ceiling — what a
537    /// headless render resolves, where the bound is memory rather than frame time.
538    pub fn attractor_budget_offline(&self, target_px: u32) -> u32 {
539        attractor_budget(
540            self.attractor_particles,
541            target_px,
542            self.attractor_particles_offline_ceiling,
543        )
544    }
545}
546
547// ---------------------------------------------------------------------------
548// The frame-time governor (Plan 0044 Phase 2)
549// ---------------------------------------------------------------------------
550
551/// The display budget assumed when the frontend has not named a refresh rate —
552/// 60 Hz, the rate NFR §1's floor is quoted at.
553pub const DEFAULT_DISPLAY_HZ: f32 = 60.0;
554
555/// How far past the display budget a single frame must run to count as a miss.
556///
557/// Not 1.0. A frame landing a hair over the budget is the ordinary condition of a
558/// vsynced renderer — the measured interval *is* the refresh interval, plus
559/// scheduling noise — so a bare comparison would read a perfectly healthy 60 fps
560/// run as missing on half its frames. 1.25 is "missing the budget by a quarter",
561/// which at 60 Hz is 20.8 ms: past it the run is visibly not holding the rate.
562pub const MISS_FACTOR: f32 = 1.25;
563
564/// What fraction of the observed frames must be misses before the governor
565/// demotes. Three quarters: high enough that an intermittently-heavy passage
566/// rides through, low enough that a genuine overload does not have to be
567/// unanimous (a demotion is triggered by *sustained* pressure, and a real
568/// overload still has fast frames in it — a cheap preset in the rotation, a
569/// dissolve that ended).
570pub const MISS_FRACTION: f32 = 0.75;
571
572/// Frames of history required before the governor will demote at all.
573///
574/// This is the hysteresis, and it is a *count* rather than a smoothing constant
575/// on purpose: it makes "a single spike must not demote" true by arithmetic
576/// instead of by tuning. With 180 frames required and 75 % of them needing to
577/// miss, no run of fewer than 135 consecutive bad frames can demote — so a window
578/// drag, a driver hiccup, or a shader compile cannot, whatever their magnitude.
579/// At 60 Hz it is also a 3 s warm-up, which keeps the pathological first frames
580/// of a session (pipeline creation, first-use resource builds) out of the verdict.
581///
582/// **This number is only satisfiable because of a constant in another module.**
583/// The only series the renderer ever hands [`sustained_miss`] is
584/// [`FrameStats::samples`](crate::diag::FrameStats::samples), which yields at
585/// most `crate::diag::RING` items — so a `MIN_SAMPLES` above the ring's
586/// capacity makes the governor a permanent no-op. See the assertion below.
587pub const MIN_SAMPLES: usize = 180;
588
589/// The governor must be able to *reach* its own threshold from its real input.
590///
591/// A build failure rather than a test, because the failure it guards is silent:
592/// nothing observable happens when the governor stops demoting — a machine that
593/// cannot hold the rich budget simply stutters for the rest of the session, which
594/// is the exact NFR §1 outcome ADR-0045 built the governor to prevent. A runtime
595/// check could not fire (there is nothing to fire *on*), and a unit test over an
596/// injected series cannot see this at all: the series in the tests below are
597/// `Vec`s of any length we like, so they would keep passing while the real
598/// producer had gone too short to ever trigger a demotion.
599const _: () = assert!(
600    MIN_SAMPLES <= crate::diag::RING,
601    "the frame-time ring is shorter than the governor's minimum sample count, \
602     so the governor can never demote"
603);
604
605/// Whether a frame-time series shows a **sustained** miss of the display budget,
606/// which is the one condition that demotes [`Tier::Rich`] to [`Tier::Floor`]
607/// (ADR-0045).
608///
609/// Pure and total: a function of the series and the budget with no clock, no
610/// state and no allocation, so the policy is unit-testable against injected
611/// series (which is how the spike-versus-overload distinction above is checked
612/// rather than asserted). `frame_secs` is the rolling history in **seconds**, the
613/// unit [`FrameStats::samples`](crate::diag::FrameStats::samples) yields; order
614/// does not matter, only the counts.
615///
616/// Says `false` for a non-positive or non-finite budget, and for a series shorter
617/// than [`MIN_SAMPLES`] — the safe direction, since a wrong `true` costs the user
618/// the rich tier for the rest of the session and a wrong `false` costs nothing but
619/// another second of measurement.
620pub fn sustained_miss(frame_secs: impl Iterator<Item = f32>, budget_secs: f32) -> bool {
621    if !budget_secs.is_finite() || budget_secs <= 0.0 {
622        return false;
623    }
624    let threshold = budget_secs * MISS_FACTOR;
625    let mut total = 0usize;
626    let mut missed = 0usize;
627    for dt in frame_secs {
628        total += 1;
629        if dt.is_finite() && dt > threshold {
630            missed += 1;
631        }
632    }
633    total >= MIN_SAMPLES && missed as f32 >= total as f32 * MISS_FRACTION
634}
635
636/// **The governor's whole decision**: whether to demote `tier` right now.
637///
638/// Pure — every input is a value, so the three properties ADR-0045 asks of the
639/// governor are unit-testable together rather than one being a fact about
640/// `Renderer`'s field layout: a pin never demotes, an already-demoted session
641/// never demotes again (the one-way latch), and only a sustained miss demotes.
642///
643/// The caller owns the latch: it sets its "demoted" flag and rebuilds when this
644/// says `true`, and passes that flag back in on every later frame. Keeping the
645/// flag out here is what makes "exactly once" a property of the decision instead
646/// of a property of the call site.
647pub fn should_demote(
648    tier: Tier,
649    pinned: bool,
650    already_demoted: bool,
651    frame_secs: impl Iterator<Item = f32>,
652    budget_secs: f32,
653) -> bool {
654    // Ordered cheapest-first: three flag reads settle the steady state, and the
655    // series is only walked on a governed rich session that has not yet demoted.
656    if pinned || already_demoted || tier == Tier::Floor {
657        return false;
658    }
659    sustained_miss(frame_secs, budget_secs)
660}
661
662/// **Whether a runtime tier change is allowed at all** (ADR-0054): only on a
663/// context that has a surface.
664///
665/// A surface-less context is exactly the headless capture path, and ADR-0045's
666/// guarantee is that a capture is `Tier::Floor` **by construction** —
667/// `Renderer::new_headless` takes no tier argument, so no baseline can be blessed
668/// at another tier by forgetting a field. `Renderer::set_tier` is a public
669/// mutator on the very type the golden suite renders through, so it is the one
670/// hole that guarantee was shaped to exclude, and this predicate is what keeps it
671/// closed.
672///
673/// Pure, and separate from `set_tier`, deliberately. A `Renderer` **with** a
674/// surface cannot be constructed in CI — there is no window — so a test that only
675/// observed the headless no-op would pass equally well against a `set_tier` that
676/// did nothing at all. Expressed as a value-in/value-out function, both
677/// directions are assertable.
678pub fn tier_change_permitted(has_surface: bool) -> bool {
679    has_surface
680}
681
682/// The frame budget for a display running at `hz`, in seconds. Falls back to
683/// [`DEFAULT_DISPLAY_HZ`] for a value that is not a usable rate, so a frontend
684/// that cannot read its monitor still gets a governed session rather than an
685/// ungoverned one.
686pub fn budget_secs(hz: f32) -> f32 {
687    let hz = if hz.is_finite() && hz > 0.0 {
688        hz
689    } else {
690        DEFAULT_DISPLAY_HZ
691    };
692    1.0 / hz
693}
694
695#[cfg(test)]
696mod tests;