Skip to main content

delvewright_dsl/
metrics.rs

1//! The metrics standard (spec-0049 §2) — pipeline stage 0.
2//!
3//! One machine-readable table, engine-owned data, in two halves whose epistemic
4//! status differs and is recorded per entry:
5//!
6//! * **Player metrics** — facts of pinned Minecraft Java 1.21.11. Not chosen,
7//!   so not calibratable: walking a level cannot make a player 0.7 blocks wide.
8//! * **Building metrics** — standards this project fixes. Every one carries
9//!   [`BuildingEntry::calibrated`], `false` until the metrics gym's walk
10//!   (spec-0049 §2.3) rules on it.
11//!
12//! # Why this module is in `delvewright-dsl`
13//!
14//! It is the crate every other one already reaches: `schem`, `grammar`, `admit`,
15//! `compiler` and `render` all resolve it. A metrics module that the navigation
16//! model imports but the render layer cannot would be one authority for some
17//! consumers and a copy for the rest, which is the shape this table exists to
18//! end. It is also the crate that owns [`crate::diagnostic`], and the stage-3
19//! and stage-4 documents whose names resolve into this table are validated here.
20//!
21//! # One authority, structurally
22//!
23//! The player half is not a second table that agrees with the navigation model —
24//! it **is** the navigation model's constants. `compiler::nav` imports
25//! [`MAX_AUTO_STEP_16`], [`MAX_JUMP_RISE_16`] and [`FULL_16`] from here;
26//! `compiler::crosshair`, `compiler::render_plan`, `compiler::view::viewer` and
27//! `compiler::combat` import the body constants they used to declare. There is
28//! one definition of each number in the workspace and the export re-serializes
29//! it, so a player metric cannot drift from the model that proves routes.
30//!
31//! What that replaced is worth recording, because it is the defect this table
32//! was written against rather than a hypothetical: the player eye height was
33//! declared four times (`compiler::render_plan`, `compiler::view::viewer`,
34//! `compiler::creator` in milli-blocks, `render::occupancy` in `f32`) and the
35//! body width twice, each a literal `0.6` or `1.62` with its own doc comment
36//! saying it was vanilla's. Nothing related them, so nothing could have gone red
37//! had one moved.
38//!
39//! # Provenance is recorded per entry
40//!
41//! [`Provenance`] says where a number came from, and the four values are
42//! deliberately not interchangeable: an [`Provenance::EngineConstant`] cannot
43//! drift because there is nothing to drift from, a [`Provenance::VanillaRule`] is
44//! a claim about the game this repository has **not** measured on a running
45//! server, a [`Provenance::Derived`] carries its arithmetic in its note, and a
46//! [`Provenance::Provisional`] is a seed for the gym and is not a standard yet.
47//! Dressing the last as one of the first three is the failure this field exists
48//! to make impossible to commit silently.
49//!
50//! # A provisional value cannot be consumed quietly
51//!
52//! [`BuildingEntry::value`] is the only way to read a building metric's number,
53//! and it takes `&mut `[`Reads`]. So a verdict that rests on an uncalibrated
54//! standard has, by construction, recorded that it did, and [`Metrics::notice`]
55//! turns that ledger into `DW0813`. The obligation lives in the signature, not
56//! in a line of documentation somebody has to remember.
57//!
58//! The residual, named rather than implied: a caller that constructs its own
59//! [`Reads`], reads through it and drops it has bypassed the notice. That is a
60//! deliberate act and not the omission the rule exists to catch — nothing
61//! *forgets* to thread a ledger it had to construct.
62//!
63//! On the campaign path it is closed rather than merely narrow.
64//! [`crate::validate::validate_campaign_with`] constructs **one** ledger and
65//! threads it through the stage-3 and stage-4 checks together, so every building
66//! metric either of them rests a verdict on lands in the ledger the notice
67//! reads. There is no second ledger for a read to disappear into.
68
69use std::collections::{BTreeMap, BTreeSet};
70
71use serde::Serialize;
72
73use crate::diagnostic::{Diagnostic, DwCode, ExitTier};
74
75/// `DW0812`: a document names a metrics entry the table does not define — a
76/// `size_class`, an `opening` or a `pitch` that resolves to nothing.
77pub const DW_METRIC_UNKNOWN: DwCode = DwCode::new("DW0812", ExitTier::Build);
78
79/// `DW0813`: a verdict rests on a standard the gym has not walked.
80///
81/// This rule asks the campaign for nothing at all. It reports a property of the
82/// ENGINE's own table — that some number a check just used is a seed rather than
83/// a standard — so no campaign could adopt its way out of it. It is a warning
84/// (exit 0) for the same reason: a provisional number is still a number,
85/// the check still refuses, and what the line adds is that the green rests on
86/// something nobody has walked.
87pub const DW_METRIC_PROVISIONAL: DwCode = DwCode::new("DW0813", ExitTier::Build).about_the_engine();
88
89// ---------------------------------------------------------------------------
90// The player half — the one definition of each constant in this workspace.
91// ---------------------------------------------------------------------------
92
93/// The table's own revision. Bumped whenever the exported JSON changes by a
94/// single byte **from a version that has merged**, which
95/// `crates/dsl/tests/metrics.rs` enforces against a committed digest: a consumer
96/// that pins a version is pinning values, and values that move under a fixed
97/// version are the drift the pin was bought to prevent.
98///
99/// The qualifier is a scope, not an escape hatch, and the difference is worth
100/// the sentence: the harm exists only where something could already have pinned
101/// the number, so a branch still authoring the version it introduces has nothing
102/// to break. It is also not a claim anybody makes about their own change —
103/// whether a version has merged is a fact of `origin/main`.
104///
105/// No document declares a metrics version and no surface is gated by one. What
106/// it needs is that the number cannot stand still while the table moves, and
107/// that is the digest test.
108pub const METRICS_VERSION: u32 = 2;
109
110/// Player collision-box width in blocks (`0.6 × 0.6 × 1.8` standing).
111pub const PLAYER_WIDTH: f64 = 0.6;
112
113/// Player collision-box height in blocks, standing.
114pub const PLAYER_HEIGHT: f64 = 1.8;
115
116/// Player collision-box height in blocks, crouched.
117pub const PLAYER_CROUCHED_HEIGHT: f64 = 1.5;
118
119/// Player eye height above the floor of the cell the body stands in.
120pub const PLAYER_EYE_HEIGHT: f64 = 1.62;
121
122/// Player maximum health, in half-heart damage points.
123pub const PLAYER_MAX_HEALTH: f64 = 20.0;
124
125/// The largest rise, in sixteenths of a block, a walker crosses **without
126/// jumping** — vanilla's player `maxUpStep` is 0.6 blocks, and 9/16 = 0.5625 is
127/// the largest sixteenth under it (10/16 = 0.625 already needs a jump). A rise
128/// within this budget needs no headroom above the *source* cell: the player walks
129/// straight up onto a slab or a path edge.
130pub const MAX_AUTO_STEP_16: i64 = 9;
131
132/// The largest rise a walker can reach **by jumping**, in sixteenths. A vanilla
133/// player's jump apex is ≈1.2522 blocks, so a surface 20/16 = 1.25 up is
134/// reachable and 21/16 = 1.3125 is not. This is the bound that makes the
135/// **1.5-block** slab-to-full-block step-up the impossible move it is.
136pub const MAX_JUMP_RISE_16: i64 = 20;
137
138/// A full block's height in sixteenths.
139pub const FULL_16: i64 = 16;
140
141// One number, one definition: a full block is 16/16 here and in the collision
142// table this feeds, and this refuses to compile the day the two drift. It is
143// asserted on this side because `blockshape` must compile knowing nothing about
144// the crate around it — the prefab generators are a separate workspace and reach
145// it through `prefab-invariants`, which re-exports it.
146const _: () = assert!(crate::blockshape::FULL_HEIGHT_16 as i64 == FULL_16);
147
148/// What a walker has to do to gain a given rise — the engine's ONE answer, and
149/// the reason it is here rather than in either walk.
150///
151/// Two walks ask this question of two different object classes. The compiler's
152/// navigation model asks it of an assembled world, where a floor has a real
153/// collision top and a body has a footprint; `delvec::schem`'s walk asks it of a
154/// box of cells with a passability answer for each, which is what a grammar
155/// expansion, a structure template read off disk and a reassembled zone all are.
156/// The rule belongs to neither walk: it is a fact about the pinned game's body,
157/// and this crate is where the three numbers it is written in terms of already
158/// live — so the rule lives beside them, and both walks read one answer.
159///
160/// The two callers still measure the rise differently, and that is a difference
161/// of **measurement**, never of rule: a box of cells with no collision heights
162/// can only read a rise as whole cells, which over-states every partial-block
163/// step and therefore only ever refuses. The rise is the input; this is the rule.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum Rise {
166    /// Inside [`MAX_AUTO_STEP_16`]: the body walks straight up, never leaves the
167    /// ground, and needs no room over its head.
168    Walk,
169    /// Past the auto-step budget and inside [`MAX_JUMP_RISE_16`]: the body jumps,
170    /// so the cell its head sweeps through must be clear or it head-bonks.
171    Jump,
172    /// Past the jump apex. No player makes this step.
173    Beyond,
174}
175
176/// Classify a rise between two standing surfaces, in sixteenths. Negative and
177/// zero rises are [`Rise::Walk`] — stepping down and walking level ask nothing
178/// of the ceiling.
179#[must_use]
180pub fn classify_rise_16(rise_16: i64) -> Rise {
181    if rise_16 <= MAX_AUTO_STEP_16 {
182        Rise::Walk
183    } else if rise_16 <= MAX_JUMP_RISE_16 {
184        Rise::Jump
185    } else {
186        Rise::Beyond
187    }
188}
189
190/// Can a body make this step? `head_clear` answers *is the cell the head sweeps
191/// through clear at the source* — it is consulted only for a [`Rise::Jump`], so a
192/// caller may compute it lazily.
193///
194/// This predicate is the whole of the step rule. A walk that answers "can a body
195/// get from here to there" without going through it is a second opinion, and the
196/// two this engine had disagreed by exactly this term: one of them omitted the
197/// head sweep, so it connected a full-block rise under a two-course ceiling that
198/// the other refuses, and the gate that admits a prefab proved its **positive**
199/// reachability claim over the looser of the two.
200#[must_use]
201pub fn step_allowed(rise_16: i64, head_clear: impl FnOnce() -> bool) -> bool {
202    match classify_rise_16(rise_16) {
203        Rise::Walk => true,
204        Rise::Jump => head_clear(),
205        Rise::Beyond => false,
206    }
207}
208
209/// Ticks a jumping player spends off the ground, apex to landing included.
210pub const JUMP_AIRBORNE_TICKS: f64 = 12.0;
211
212/// Player walking speed on the flat, in blocks per second (not sprinting).
213pub const WALK_SPEED_BLOCKS_PER_SECOND: f64 = 4.317;
214
215/// Server ticks per second.
216pub const TICKS_PER_SECOND: f64 = 20.0;
217
218/// The fall distance in blocks below which vanilla deals no fall damage: damage
219/// is `ceil(distance − 3)` points, so a 3-block fall is free and a 4-block fall
220/// costs one.
221pub const FALL_DAMAGE_ONSET_BLOCKS: f64 = 3.0;
222
223/// Ticks a walking player spends crossing one block on the flat.
224///
225/// Derived rather than stored, because both operands are facts and nothing
226/// downstream decides a route on this number — it is the pacing denominator.
227#[must_use]
228pub fn walk_ticks_per_block() -> f64 {
229    TICKS_PER_SECOND / WALK_SPEED_BLOCKS_PER_SECOND
230}
231
232/// The largest fall an unarmoured player at full health survives, in blocks.
233///
234/// `ceil(d − 3) < 20` holds up to `d = 22`, which lands on one half-heart; 23
235/// blocks deals 20 and kills. Derived from [`FALL_DAMAGE_ONSET_BLOCKS`] and
236/// [`PLAYER_MAX_HEALTH`] so that moving either moves this, and it exists so the
237/// designed-drop policy beside it has a physical ceiling to be **tighter than**
238/// rather than a number chosen next to nothing.
239#[must_use]
240pub fn unarmoured_survivable_fall_blocks() -> f64 {
241    (FALL_DAMAGE_ONSET_BLOCKS + PLAYER_MAX_HEALTH - 1.0).floor()
242}
243
244/// Horizontal cells a standing body needs to pass: `ceil(0.6)`.
245#[must_use]
246pub fn passable_width_cells() -> u32 {
247    PLAYER_WIDTH.ceil() as u32
248}
249
250/// Vertical cells a standing body needs to pass: `ceil(1.8)`.
251///
252/// This is a **player** metric and the spec's building half listed it, which is
253/// the correction worth naming: the width and clearance at which a body can pass
254/// at all are functions of the collision box, so no walk can change them and
255/// `calibrated` would mean nothing on them. What the gym calibrates is the
256/// *designed* minimum — a way class's `min_width` and `min_clearance` — which is
257/// a comfort judgement and can never be chosen below this floor —
258/// [`Metrics::self_check`] is what holds it there, over every way class the
259/// table defines rather than over the two entries that used to stand alone.
260#[must_use]
261pub fn passable_clearance_cells() -> u32 {
262    PLAYER_HEIGHT.ceil() as u32
263}
264
265// ---------------------------------------------------------------------------
266// Entry shapes
267// ---------------------------------------------------------------------------
268
269/// Where a number came from. The four are not interchangeable — see the module
270/// docs.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
272#[serde(rename_all = "kebab-case")]
273pub enum Provenance {
274    /// The number **is** a constant this module defines and the rest of the
275    /// workspace imports. One definition, so nothing to drift from.
276    EngineConstant,
277    /// A stated rule of pinned Minecraft Java 1.21.11 that no engine constant
278    /// held before this table, and that this repository has **not** measured on
279    /// a running server. The note names the rule so the claim is checkable.
280    VanillaRule,
281    /// Computed from other entries; the note carries the arithmetic.
282    Derived,
283    /// A seed for the metrics gym's calibration walk. Chosen, not established.
284    Provisional,
285}
286
287/// A named opening in the standard seam set, in cells.
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
289pub struct Opening {
290    /// Clear width, in cells.
291    pub width: u32,
292    /// Clear height, in cells.
293    pub height: u32,
294}
295
296/// A named stair-pitch standard: a rise:run pattern together with the vanilla
297/// blocks that realize it, and the per-step rise the realization actually
298/// presents to a walking body.
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
300pub struct Pitch {
301    /// Blocks of rise per `run` blocks of horizontal travel.
302    pub rise: u32,
303    /// Blocks of horizontal travel per `rise` blocks of rise.
304    pub run: u32,
305    /// The rise in sixteenths that one realized tread presents. A stair block
306    /// offers its lower half first, so a 1:1 stair run steps 8/16 twice per
307    /// block of rise rather than 16/16 once; a slab ramp steps 8/16 as well.
308    /// This is the number [`Metrics::self_check`] holds under the walk-up
309    /// budget, which is what makes "standard pitch" mean *walked*, not merely
310    /// *legal*.
311    pub step_16: i64,
312    /// The vanilla realization, named so the derivation above is checkable
313    /// against blocks rather than asserted.
314    pub realization: &'static str,
315}
316
317/// A rung of the size-class ladder — the vocabulary a layout-graph node declares
318/// and a site-plan box is judged against.
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
320pub struct SizeClass {
321    /// Smallest interior footprint, `[x, z]` in cells, inclusive.
322    pub min_footprint: [u32; 2],
323    /// Largest interior footprint, `[x, z]` in cells, inclusive.
324    pub max_footprint: [u32; 2],
325    /// Least interior clearance, in cells.
326    pub min_clearance: u32,
327    /// Nominal blocks of route a body walks crossing a place of this class —
328    /// the per-leg length the pacing projection sums over a critical path
329    /// (spec-0049 §3.3).
330    ///
331    /// The code that reads it is not named here on purpose: a `DW` number in a
332    /// source comment is a code as far as `tools/check-dw-codes.py` is
333    /// concerned, and one whose check lands two rounds from now has no catalog
334    /// row to match, so naming it early reds the docs job on a rule nothing has
335    /// written yet.
336    pub nominal_traverse_blocks: u32,
337}
338
339/// A **way class** — the vocabulary a layout-graph node declares for a place
340/// whose footprint is bounded in one axis and free in the other: a road, a
341/// causeway, a corridor, a duct (spec-0053 §3).
342///
343/// # Why it is a second kind of classification and not a rung
344///
345/// The size-class ladder classifies a place by both horizontal extents at once,
346/// and that is what it is for. A route has no second extent to classify: a cut
347/// ledge one body wide climbing a whole seaward face is 4 by 90, and for any
348/// rung to admit it that rung would have to span 4..90 on an axis — a class in
349/// which an alcove and an expanse are the same thing has stopped classifying.
350/// The failure is by KIND, not by margin, which is why no calibration of the
351/// ladder reaches it.
352///
353/// # What it bounds, and what it deliberately does not
354///
355/// A way class bounds the **cross-section** — the axis a body feels walking it
356/// — and says nothing whatever about the run. A route's length is per-campaign
357/// geometry, not a standard: a village lane, a canyon rim trail, a ship's
358/// gangway and a mine gallery are the same class of thing at four wildly
359/// different lengths, and a `max_length` here would be this month's map wearing
360/// a standard's clothes. The run is *measured*, into pacing, and is never
361/// compared against anything (spec-0053 §7).
362///
363/// The elongation demand a way-classed box must satisfy is therefore
364/// **structural rather than a constant**: the run must exceed
365/// [`Self::max_width`], which is exactly what a room cannot supply. A square box
366/// can never qualify, because its "run" equals its width and one number cannot
367/// both be `<= max_width` and exceed it. That is what makes "declare it a way to
368/// escape the ladder" refused by the object's own shape rather than by a rule
369/// the author could satisfy by choosing differently.
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
371pub struct WayClass {
372    /// Narrowest cross-section, in cells, inclusive. Never below the physical
373    /// passable width — [`Metrics::self_check`] holds it there.
374    pub min_width: u32,
375    /// Widest cross-section, in cells, inclusive. Doubles as the elongation
376    /// floor: a way-classed box's run must **exceed** this.
377    pub max_width: u32,
378    /// Least interior clearance, in cells.
379    pub min_clearance: u32,
380}
381
382/// The kit grid: the quantum box extents are multiples of, and the datum
383/// convention that fixes what a declared `y` means.
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
385pub struct Grid {
386    /// The footprint quantum `q`, in blocks.
387    pub quantum: u32,
388    /// The axes extents are quantized on. Vertical extent is not quantized:
389    /// storey heights are their own entries and a box's height follows them.
390    pub axes: [&'static str; 2],
391    /// What a box's declared datum `y` names. `floor-surface`: the walk plane is
392    /// at `y`, and whatever stands in the box later puts its own floor there.
393    pub datum: &'static str,
394}
395
396/// One entry's value. `untagged`, so the export reads as the number or object it
397/// is rather than as a wrapper a consumer has to unpick.
398#[derive(Debug, Clone, PartialEq, Serialize)]
399#[serde(untagged)]
400pub enum MetricValue {
401    /// A count of cells, blocks or ticks.
402    Count(u32),
403    /// A measurement that is not a whole number of anything.
404    Number(f64),
405    /// A yes/no fact.
406    Flag(bool),
407    /// A named seam opening.
408    Opening(Opening),
409    /// A named stair pitch.
410    Pitch(Pitch),
411    /// A rung of the size-class ladder.
412    SizeClass(SizeClass),
413    /// A way class — a route's cross-section.
414    WayClass(WayClass),
415    /// The kit grid.
416    Grid(Grid),
417}
418
419/// One player metric: a fact of the pinned game.
420#[derive(Debug, Clone, PartialEq, Serialize)]
421pub struct PlayerEntry {
422    /// The value.
423    pub value: MetricValue,
424    /// What the value counts (`blocks`, `sixteenths`, `ticks`, …), or `none`.
425    pub unit: &'static str,
426    /// Where the number came from.
427    pub provenance: Provenance,
428    /// One sentence: the constant this **is**, the vanilla rule it states, or
429    /// the arithmetic it was derived by.
430    pub note: &'static str,
431}
432
433/// One building metric: a standard this project fixes.
434///
435/// The value is deliberately not a public field. [`BuildingEntry::value`] is the
436/// only way to read it and it takes `&mut `[`Reads`], so nothing can rest a
437/// verdict on an uncalibrated standard without saying that it did.
438#[derive(Debug, Clone, PartialEq, Serialize)]
439pub struct BuildingEntry {
440    /// The entry's own key, carried so a read records itself without the caller
441    /// having to repeat the name it looked up under.
442    key: &'static str,
443    value: MetricValue,
444    /// What the value counts, or `none`.
445    pub unit: &'static str,
446    /// Where the number came from. Orthogonal to `calibrated`: a pitch's
447    /// geometry can be derived from vanilla blocks while the judgement that it
448    /// is *the* standard is still unwalked.
449    pub provenance: Provenance,
450    /// Whether the metrics gym's walk has ruled on this value. `false` on every
451    /// entry at this version.
452    pub calibrated: bool,
453    /// One sentence: what the number is for and what the gym is being asked to
454    /// decide about it.
455    pub note: &'static str,
456}
457
458impl BuildingEntry {
459    /// Read the value, recording the read.
460    ///
461    /// The `&mut `[`Reads`] is the whole mechanism: [`Metrics::notice`] reports
462    /// exactly the uncalibrated entries a run actually consumed, so `DW0813`
463    /// cannot be forgotten by a check that reads one and can never fire over a
464    /// standard nothing looked at.
465    #[must_use]
466    pub fn value(&self, reads: &mut Reads) -> &MetricValue {
467        reads.record(self);
468        &self.value
469    }
470
471    /// The value with no read recorded — for rendering the table, never for
472    /// deciding anything.
473    ///
474    /// Reachable only inside this crate, and used at exactly one site: the
475    /// export, which reports the table rather than resting a verdict on it. A
476    /// serialization is not a verdict, and counting it as one would put every
477    /// entry in every `DW0813` line and make the code mean nothing.
478    pub(crate) fn value_for_display(&self) -> &MetricValue {
479        &self.value
480    }
481
482    /// The entry's key.
483    #[must_use]
484    pub fn key(&self) -> &'static str {
485        self.key
486    }
487}
488
489/// The building metrics a run's verdicts have read.
490///
491/// Deterministic (ADR-0006): a `BTreeSet` of `&'static str`, so the order a
492/// `DW0813` line names them in is the table's own order and not the order the
493/// checks happened to run in.
494#[derive(Debug, Clone, Default, PartialEq, Eq)]
495pub struct Reads {
496    read: BTreeSet<&'static str>,
497    provisional: BTreeSet<&'static str>,
498}
499
500impl Reads {
501    /// A ledger nothing has read through yet.
502    #[must_use]
503    pub fn new() -> Self {
504        Self::default()
505    }
506
507    fn record(&mut self, entry: &BuildingEntry) {
508        self.read.insert(entry.key);
509        if !entry.calibrated {
510            self.provisional.insert(entry.key);
511        }
512    }
513
514    /// How many building metrics this run's verdicts read, and how many of those
515    /// the gym has not walked.
516    #[must_use]
517    pub fn binding(&self) -> ReadBinding {
518        ReadBinding {
519            read: self.read.len(),
520            provisional: self.provisional.len(),
521        }
522    }
523
524    /// The uncalibrated entries read, in table order.
525    #[must_use]
526    pub fn provisional(&self) -> Vec<&'static str> {
527        self.provisional.iter().copied().collect()
528    }
529
530    /// Every entry read, in table order.
531    ///
532    /// The metrics gym's coverage numerator: an entry the gym's construction
533    /// never read is an entry no bay was built from, so a walk of the gym cannot
534    /// rule on it. Taking it from the same ledger the reads are recorded in is
535    /// what stops the gym's own coverage claim being a list somebody maintains
536    /// beside the generator.
537    #[must_use]
538    pub fn read(&self) -> BTreeSet<&'static str> {
539        self.read.clone()
540    }
541}
542
543/// What a run's building-metric reads bound to.
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
545pub struct ReadBinding {
546    /// Building metrics read.
547    pub read: usize,
548    /// Of those, entries whose `calibrated` is false.
549    pub provisional: usize,
550}
551
552/// A name a document wrote that this table does not define — `DW0812`'s payload.
553///
554/// It carries the defined set as well as the bad name, because the author's next
555/// action is choosing a real one and a refusal that only says *no* sends them to
556/// read the compiler.
557#[derive(Debug, Clone, PartialEq, Eq)]
558pub struct UnknownMetric {
559    /// The kind of entry the document was naming (`size class`, `opening`, …).
560    pub kind: &'static str,
561    /// The same kind, plural — carried rather than derived, because three of the
562    /// six nouns end in a sibilant and `{kind}s` is wrong for them.
563    pub kind_plural: &'static str,
564    /// The name it wrote.
565    pub named: String,
566    /// Every name this table does define of that kind, in table order.
567    pub defined: Vec<&'static str>,
568}
569
570impl UnknownMetric {
571    /// The `DW0812` refusal, located by the caller.
572    #[must_use]
573    pub fn diagnostic(&self, stage: &str, path: &str) -> Diagnostic {
574        let defined = if self.defined.is_empty() {
575            "nothing".to_string()
576        } else {
577            self.defined.join(", ")
578        };
579        Diagnostic::error(
580            DW_METRIC_UNKNOWN,
581            stage,
582            path,
583            format!(
584                "the metrics table defines no {kind} called `{named}`. The table is \
585                 the single authority for this vocabulary, so a name it does not \
586                 define cannot compile and no check downstream has to cope with one. \
587                 Defined {plural}: {defined}. Run `delvec metrics` for the whole \
588                 table, including what each entry is for.",
589                kind = self.kind,
590                plural = self.kind_plural,
591                named = self.named,
592            ),
593        )
594    }
595}
596
597// ---------------------------------------------------------------------------
598// The table
599// ---------------------------------------------------------------------------
600
601/// The metrics standard, as exported and as read.
602#[derive(Debug, Clone, PartialEq, Serialize)]
603pub struct Metrics {
604    /// The table's revision.
605    pub metrics_version: u32,
606    /// The Minecraft version the player half states facts about.
607    pub mc_version: &'static str,
608    /// Facts of the pinned game, in key order.
609    pub player: BTreeMap<&'static str, PlayerEntry>,
610    /// Standards this project fixes, in key order.
611    pub building: BTreeMap<&'static str, BuildingEntry>,
612}
613
614/// Kinds of building entry a document can name, for [`Metrics::resolve`].
615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616pub enum MetricKind {
617    /// A seam opening (`opening.<name>`).
618    Opening,
619    /// A stair pitch (`pitch.<name>`).
620    Pitch,
621    /// A rung of the size-class ladder (`size-class.<name>`).
622    SizeClass,
623    /// A way class (`way-class.<name>`) — the second kind of place
624    /// classification (spec-0053 §3). A [`MetricKind`] rather than a lookup of
625    /// its own for the reason [`Metrics::resolve`] gives: this is a name a
626    /// DOCUMENT writes, so it goes through the one path from a name to an entry
627    /// and a name the table does not define is `DW0812` here exactly as it is
628    /// for a size class.
629    WayClass,
630    /// A storey height (`storey.<name>`).
631    Storey,
632    /// A pacing coefficient (`pacing.<name>`) — blocks of route per minute of
633    /// play. Named like the rest of the vocabulary rather than reached by a
634    /// second lookup, because [`Metrics::resolve`] is the ONE path from a name
635    /// to an entry and a coefficient read any other way would be a second
636    /// authority (spec-0049 §2.2). No campaign document names one; the pacing
637    /// projection does.
638    Pacing,
639}
640
641impl MetricKind {
642    /// The key prefix entries of this kind carry.
643    #[must_use]
644    pub fn prefix(self) -> &'static str {
645        match self {
646            MetricKind::Opening => "opening.",
647            MetricKind::Pitch => "pitch.",
648            MetricKind::SizeClass => "size-class.",
649            MetricKind::WayClass => "way-class.",
650            MetricKind::Storey => "storey.",
651            MetricKind::Pacing => "pacing.",
652        }
653    }
654
655    /// What the kind is called in a refusal, **plural**.
656    ///
657    /// A fact about the kind rather than an `s` appended where a message needed
658    /// one: three of the six nouns end in a sibilant, so `{noun}s` reads
659    /// `size classs`, `stair pitchs` and `way classs`. Written out here, a kind
660    /// added later cannot inherit that by default — it has to answer.
661    #[must_use]
662    pub fn plural(self) -> &'static str {
663        match self {
664            MetricKind::Opening => "seam openings",
665            MetricKind::Pitch => "stair pitches",
666            MetricKind::SizeClass => "size classes",
667            MetricKind::WayClass => "way classes",
668            MetricKind::Storey => "storey heights",
669            MetricKind::Pacing => "pacing coefficients",
670        }
671    }
672
673    /// What the kind is called in a refusal.
674    #[must_use]
675    pub fn noun(self) -> &'static str {
676        match self {
677            MetricKind::Opening => "seam opening",
678            MetricKind::Pitch => "stair pitch",
679            MetricKind::SizeClass => "size class",
680            MetricKind::WayClass => "way class",
681            MetricKind::Storey => "storey height",
682            MetricKind::Pacing => "pacing coefficient",
683        }
684    }
685}
686
687fn player(
688    value: MetricValue,
689    unit: &'static str,
690    provenance: Provenance,
691    note: &'static str,
692) -> PlayerEntry {
693    PlayerEntry {
694        value,
695        unit,
696        provenance,
697        note,
698    }
699}
700
701fn building(
702    key: &'static str,
703    value: MetricValue,
704    unit: &'static str,
705    provenance: Provenance,
706    note: &'static str,
707) -> (&'static str, BuildingEntry) {
708    (
709        key,
710        BuildingEntry {
711            key,
712            value,
713            unit,
714            provenance,
715            // Every entry lands uncalibrated. The gym walk flips it, one entry
716            // at a time, and that walk is the only thing that may.
717            calibrated: false,
718            note,
719        },
720    )
721}
722
723impl Metrics {
724    /// The table.
725    ///
726    /// Rebuilt per call rather than held in a `static`, because the values are a
727    /// handful of maps and a `static` would need interior mutability the moment
728    /// the gym's walk starts flipping `calibrated`. Deterministic by
729    /// construction: `BTreeMap`, no clock, no environment.
730    #[must_use]
731    pub fn table() -> Self {
732        let player_entries: Vec<(&'static str, PlayerEntry)> = vec![
733            (
734                "body.width",
735                player(
736                    MetricValue::Number(PLAYER_WIDTH),
737                    "blocks",
738                    Provenance::EngineConstant,
739                    "The standing collision box is 0.6 wide; `dsl::metrics::PLAYER_WIDTH` \
740                     is the definition `compiler::nav`'s entity dims table and \
741                     `compiler::crosshair` both import.",
742                ),
743            ),
744            (
745                "body.height",
746                player(
747                    MetricValue::Number(PLAYER_HEIGHT),
748                    "blocks",
749                    Provenance::EngineConstant,
750                    "The standing collision box is 1.8 tall; the same definition the \
751                     entity dims table reads for `minecraft:player`.",
752                ),
753            ),
754            (
755                "body.crouched-height",
756                player(
757                    MetricValue::Number(PLAYER_CROUCHED_HEIGHT),
758                    "blocks",
759                    Provenance::VanillaRule,
760                    "A sneaking player's collision box is 1.5 tall, which is what lets a \
761                     body pass under a cell a standing one cannot. No engine constant \
762                     held this before the table, and nothing in this workspace has \
763                     measured it on a running server.",
764                ),
765            ),
766            (
767                "body.eye-height",
768                player(
769                    MetricValue::Number(PLAYER_EYE_HEIGHT),
770                    "blocks",
771                    Provenance::EngineConstant,
772                    "The eye sits 1.62 above the floor of the cell the body stands in. \
773                     This is the definition the render plan, the viewer page and the \
774                     creator overlay all import, having each declared their own before.",
775                ),
776            ),
777            (
778                "body.max-health",
779                player(
780                    MetricValue::Number(PLAYER_MAX_HEALTH),
781                    "half-hearts",
782                    Provenance::EngineConstant,
783                    "Twenty points of health, the definition `compiler::combat` imports \
784                     for its winnability arithmetic.",
785                ),
786            ),
787            (
788                "step.walk-up",
789                player(
790                    MetricValue::Count(MAX_AUTO_STEP_16 as u32),
791                    "sixteenths",
792                    Provenance::EngineConstant,
793                    "The largest rise a walker crosses without jumping: vanilla's player \
794                     `maxUpStep` is 0.6 blocks and 9/16 is the largest sixteenth under \
795                     it. `compiler::nav` imports this as its step rule's walk-up budget.",
796                ),
797            ),
798            (
799                "step.jump-rise",
800                player(
801                    MetricValue::Count(MAX_JUMP_RISE_16 as u32),
802                    "sixteenths",
803                    Provenance::EngineConstant,
804                    "The largest rise a walker reaches by jumping: the apex is ≈1.2522 \
805                     blocks, so 20/16 is reachable and 21/16 is not. `compiler::nav` \
806                     imports this, and it is why a slab-to-full-block step-up of 1.5 is \
807                     the impossible move it is.",
808                ),
809            ),
810            (
811                "step.block",
812                player(
813                    MetricValue::Count(FULL_16 as u32),
814                    "sixteenths",
815                    Provenance::EngineConstant,
816                    "A full block in the sixteenths the step rule is denominated in, so \
817                     that no comparison in the navigation model is a float.",
818                ),
819            ),
820            (
821                "jump.airborne",
822                player(
823                    MetricValue::Number(JUMP_AIRBORNE_TICKS),
824                    "ticks",
825                    Provenance::VanillaRule,
826                    "A jump is airborne about twelve ticks, apex to landing. This was \
827                     prose in the navigation model's elevation-weight derivation and \
828                     nothing could read it; the table is where it becomes data.",
829                ),
830            ),
831            (
832                "walk.speed",
833                player(
834                    MetricValue::Number(WALK_SPEED_BLOCKS_PER_SECOND),
835                    "blocks/second",
836                    Provenance::VanillaRule,
837                    "A walking player covers 4.317 blocks a second on the flat; \
838                     sprinting is faster and is not the pacing basis, because a route \
839                     nobody has learnt is walked.",
840                ),
841            ),
842            (
843                "walk.ticks-per-block",
844                player(
845                    MetricValue::Number(walk_ticks_per_block()),
846                    "ticks",
847                    Provenance::Derived,
848                    "Twenty ticks a second over 4.317 blocks a second. Against the \
849                     twelve airborne ticks of a jump this is what makes a block of \
850                     climb cost about two and a half blocks of walking, which is where \
851                     the navigation model's elevation weight of two comes from.",
852                ),
853            ),
854            (
855                "fluid.passable",
856                player(
857                    MetricValue::Flag(false),
858                    "none",
859                    Provenance::VanillaRule,
860                    "Water and lava are impassable to every proof in this engine and are \
861                     never floor either: a body cannot stand on a fluid surface, so the \
862                     two sets are disjoint and both gate standability. Marked as a rule \
863                     rather than an engine constant deliberately — the navigation model \
864                     encodes this as the SHAPE of its occupancy sets and not as a shared \
865                     `const`, so there is no single definition for this row to be, and \
866                     claiming otherwise would be the overclaim the provenance field \
867                     exists to prevent.",
868                ),
869            ),
870            (
871                "fall.damage-onset",
872                player(
873                    MetricValue::Number(FALL_DAMAGE_ONSET_BLOCKS),
874                    "blocks",
875                    Provenance::VanillaRule,
876                    "Fall damage is `ceil(distance − 3)` points, so a three-block fall is \
877                     free and a four-block fall costs one. Stated from the vanilla \
878                     damage rule; this repository has not measured it on a running \
879                     server.",
880                ),
881            ),
882            (
883                "fall.unarmoured-survivable",
884                player(
885                    MetricValue::Number(unarmoured_survivable_fall_blocks()),
886                    "blocks",
887                    Provenance::Derived,
888                    "`ceil(distance − 3) < 20` holds up to 22 blocks, which lands a \
889                     full-health unarmoured body on one half-heart; 23 deals twenty and \
890                     kills. The survivable ceiling is a function of health and armour, \
891                     and this is its unarmoured, full-health case — the physical bound \
892                     the designed-drop policy is deliberately tighter than.",
893                ),
894            ),
895            (
896                "passable.width",
897                player(
898                    MetricValue::Count(passable_width_cells()),
899                    "cells",
900                    Provenance::Derived,
901                    "`ceil(0.6)`: one cell is the narrowest a standing body fits \
902                     through. This is a fact, not a standard — no walk can change it, \
903                     and it is the floor the designed corridor minimum may never be \
904                     chosen below.",
905                ),
906            ),
907            (
908                "passable.clearance",
909                player(
910                    MetricValue::Count(passable_clearance_cells()),
911                    "cells",
912                    Provenance::Derived,
913                    "`ceil(1.8)`: two cells is the lowest a standing body passes under. \
914                     Like the width beside it this is a fact rather than a standard, \
915                     which is why neither carries a calibration flag.",
916                ),
917            ),
918        ];
919
920        let building_entries: Vec<(&'static str, BuildingEntry)> = vec![
921            building(
922                "grid",
923                MetricValue::Grid(Grid {
924                    quantum: 4,
925                    axes: ["x", "z"],
926                    datum: "floor-surface",
927                }),
928                "blocks",
929                Provenance::Provisional,
930                "The footprint quantum every site-plan box's horizontal extents are \
931                 multiples of, and the datum convention: a box's floor SURFACE is at \
932                 its declared y, and whatever stands in the box later puts its walk \
933                 plane there. Four is a seed and nothing in the existing piece library \
934                 argues for it — the cave tileset is odd on every axis and the keep \
935                 tileset is even but not quartered — so what the gym is being asked is \
936                 whether a quantum this fine buys anything a coarser one would not.",
937            ),
938            building(
939                "way-class.corridor",
940                MetricValue::WayClass(WayClass {
941                    min_width: 2,
942                    max_width: 4,
943                    min_clearance: 3,
944                }),
945                "cells",
946                Provenance::Provisional,
947                "The narrow way: a passage, a duct, a gallery cut through rock. Its \
948                 `min_width` and `min_clearance` ARE the two numbers this table used to \
949                 publish as `corridor.min-width` and `corridor.min-clearance` — one \
950                 cell is passable and reads as a crawlspace, two lets two bodies pass, \
951                 and two blocks of clearance puts the ceiling on the walker's head — and \
952                 they are fields here rather than entries of their own so that there is \
953                 one authority for the narrow way rather than a class beside two loose \
954                 numbers nothing could spell. The gym walks widths one, two and three \
955                 and clearances two, three and four. It is also asked a question that \
956                 could not be posed while these numbers were unreachable: the kit \
957                 quantum beside them is 4 and every box extent is a multiple of it, so \
958                 the narrowest way any plan can currently DRAW is four cells, and the \
959                 walk decides whether the floor moves up or the quantum moves down.",
960            ),
961            building(
962                "way-class.road",
963                MetricValue::WayClass(WayClass {
964                    min_width: 4,
965                    max_width: 16,
966                    min_clearance: 6,
967                }),
968                "cells",
969                Provenance::Provisional,
970                "The broad way: a village lane, a causeway, a quay, a ledge cut across a \
971                 cliff face. Wide enough that a party walks it abreast and something can \
972                 come the other way, which is the difference from the corridor beside it \
973                 and is what the walk is being asked to place. The clearance seed is \
974                 higher than the corridor's because a way this wide reads as roofless \
975                 even when it is not, and a low ceiling over a broad floor is the one \
976                 combination that reads as a mistake.",
977            ),
978            building(
979                "opening.door",
980                MetricValue::Opening(Opening {
981                    width: 1,
982                    height: 2,
983                }),
984                "cells",
985                Provenance::Provisional,
986                "The narrow seam: one body at a time, the size of a vanilla door. Seeded \
987                 at the smallest opening a standing body passes, so the gym is deciding \
988                 whether the tightest legal seam is one anybody wants to walk.",
989            ),
990            building(
991                "opening.arch",
992                MetricValue::Opening(Opening {
993                    width: 2,
994                    height: 3,
995                }),
996                "cells",
997                Provenance::Provisional,
998                "The ordinary seam between two interior places: two abreast, headroom \
999                 over both.",
1000            ),
1001            building(
1002                "opening.passage",
1003                MetricValue::Opening(Opening {
1004                    width: 3,
1005                    height: 3,
1006                }),
1007                "cells",
1008                Provenance::Derived,
1009                "The three-by-three doorway the existing jigsaw socket conventions \
1010                 already standardize on — `cave:socket` and `tk:socket` are both this \
1011                 opening, so the prefab library has been built against it for as long \
1012                 as it has existed. Derived from that convention rather than chosen \
1013                 here, and still uncalibrated: what the gym decides is whether the \
1014                 convention is right, not what it is.",
1015            ),
1016            building(
1017                "opening.gateway",
1018                MetricValue::Opening(Opening {
1019                    width: 5,
1020                    height: 5,
1021                }),
1022                "cells",
1023                Provenance::Provisional,
1024                "The broad seam a thing of scenery scale passes: a cart, a barge, a \
1025                 processional. Seeded wide enough to read as an event from inside the \
1026                 place it opens onto, which is the judgement the walk is for.",
1027            ),
1028            building(
1029                "pitch.stair",
1030                MetricValue::Pitch(Pitch {
1031                    rise: 1,
1032                    run: 1,
1033                    step_16: 8,
1034                    realization: "minecraft:*_stairs",
1035                }),
1036                "none",
1037                Provenance::Derived,
1038                "One block of rise per block of run, realized in stair blocks. The \
1039                 geometry is vanilla's: a stair offers its lower half first, so the \
1040                 body walks two eight-sixteenth steps per block of rise and never \
1041                 jumps. What is uncalibrated is the comfort judgement — whether a climb \
1042                 this steep is one a player wants to make repeatedly.",
1043            ),
1044            building(
1045                "pitch.ramp",
1046                MetricValue::Pitch(Pitch {
1047                    rise: 1,
1048                    run: 2,
1049                    step_16: 8,
1050                    realization: "minecraft:*_slab + full block",
1051                }),
1052                "none",
1053                Provenance::Derived,
1054                "One block of rise per two of run, realized as a bottom slab then a full \
1055                 block. Same eight-sixteenth tread as the stair and half the pitch, so \
1056                 it is the gentle standard; the run it costs is what the gym weighs it \
1057                 on.",
1058            ),
1059            building(
1060                "storey.low",
1061                MetricValue::Count(5),
1062                "blocks",
1063                Provenance::Derived,
1064                "Floor course, three cells of interior clearance, ceiling course. Taken \
1065                 from the existing cave tileset, every passage and room of which is five \
1066                 blocks tall, so this is the storey the shipped library already has \
1067                 rather than a number invented here.",
1068            ),
1069            building(
1070                "storey.standard",
1071                MetricValue::Count(8),
1072                "blocks",
1073                Provenance::Provisional,
1074                "The storey an interior room of consequence gets: six cells of clearance \
1075                 between courses. A seed, and the walk is what says whether a room this \
1076                 tall reads as generous or merely as far away.",
1077            ),
1078            building(
1079                "storey.hall",
1080                MetricValue::Count(14),
1081                "blocks",
1082                Provenance::Provisional,
1083                "The storey a hall gets, where the height itself is the effect. The seed \
1084                 is deliberately at the point where volume starts costing walking time \
1085                 for nothing, because that is the trade the walk has to judge.",
1086            ),
1087            building(
1088                "size-class.alcove",
1089                MetricValue::SizeClass(SizeClass {
1090                    min_footprint: [4, 4],
1091                    max_footprint: [8, 8],
1092                    min_clearance: 3,
1093                    nominal_traverse_blocks: 6,
1094                }),
1095                "cells",
1096                Provenance::Provisional,
1097                "A place a body stands in rather than crosses: a shrine, a landing, a \
1098                 cell. The smallest rung of the ladder, and the whole ladder's bounds \
1099                 are seeds — what the walk fixes is where one class stops feeling like \
1100                 the next.",
1101            ),
1102            building(
1103                "size-class.room",
1104                MetricValue::SizeClass(SizeClass {
1105                    min_footprint: [8, 8],
1106                    max_footprint: [16, 16],
1107                    min_clearance: 4,
1108                    nominal_traverse_blocks: 12,
1109                }),
1110                "cells",
1111                Provenance::Provisional,
1112                "A place with a purpose and something in it: a guardroom, a chapel, a \
1113                 workshop.",
1114            ),
1115            building(
1116                "size-class.hall",
1117                MetricValue::SizeClass(SizeClass {
1118                    min_footprint: [16, 16],
1119                    max_footprint: [32, 32],
1120                    min_clearance: 8,
1121                    nominal_traverse_blocks: 24,
1122                }),
1123                "cells",
1124                Provenance::Provisional,
1125                "A place a fight or a crowd fits in, and the smallest rung whose height \
1126                 is doing work of its own.",
1127            ),
1128            building(
1129                "size-class.arena",
1130                MetricValue::SizeClass(SizeClass {
1131                    min_footprint: [32, 32],
1132                    max_footprint: [64, 64],
1133                    min_clearance: 12,
1134                    nominal_traverse_blocks: 48,
1135                }),
1136                "cells",
1137                Provenance::Provisional,
1138                "A place built around one encounter, with room to retreat and re-approach.",
1139            ),
1140            building(
1141                "size-class.expanse",
1142                MetricValue::SizeClass(SizeClass {
1143                    min_footprint: [64, 64],
1144                    max_footprint: [128, 128],
1145                    min_clearance: 16,
1146                    nominal_traverse_blocks: 96,
1147                }),
1148                "cells",
1149                Provenance::Provisional,
1150                "A shore, a valley floor, a cavern — a place whose job is that crossing \
1151                 it takes time. The rung most at risk of being a big empty room, which \
1152                 is what the walk is watching for.",
1153            ),
1154            building(
1155                "drop.max-designed-rise",
1156                MetricValue::Count(5),
1157                "blocks",
1158                Provenance::Provisional,
1159                "The deepest fall a designed one-way drop edge may declare. A policy \
1160                 cap, not a physical one: the unarmoured survivable fall beside it in \
1161                 the player half is 22 blocks, and this is far tighter on purpose, \
1162                 because a drop is a topology decision and should not also be a health \
1163                 decision. Five costs two of twenty at full health, which is the seed \
1164                 the walk argues with.",
1165            ),
1166            building(
1167                "pacing.route-blocks-per-minute",
1168                MetricValue::Count(60),
1169                "blocks/minute",
1170                Provenance::Provisional,
1171                "Blocks of route a party gets through per minute of play, once looking, \
1172                 fighting and backtracking are in it. Carried with NO THRESHOLD \
1173                 anywhere until the first walked blockout and the first full playtest \
1174                 calibrate it: a threshold on a number this uncertain would be defending \
1175                 nothing. Its upper bound is the pure-walk figure beside it, which no \
1176                 party achieves.",
1177            ),
1178            building(
1179                "pacing.walk-only-blocks-per-minute",
1180                MetricValue::Count((WALK_SPEED_BLOCKS_PER_SECOND * 60.0) as u32),
1181                "blocks/minute",
1182                Provenance::Derived,
1183                "Walking speed times sixty: what a body covers doing nothing but \
1184                 walking in a straight line. It exists so the route coefficient above \
1185                 has a ceiling that is a fact rather than another guess, and so the \
1186                 ratio between them is the thing the playtest actually measures.",
1187            ),
1188        ];
1189
1190        Metrics {
1191            metrics_version: METRICS_VERSION,
1192            mc_version: crate::blocks::MC_VERSION,
1193            player: player_entries.into_iter().collect(),
1194            building: building_entries.into_iter().collect(),
1195        }
1196    }
1197
1198    /// Resolve a name a **document** wrote to its building entry.
1199    ///
1200    /// This is the **only** path from an authored name to an entry, and it is
1201    /// what makes the table the single authority rather than a suggestion: a
1202    /// name it does not define cannot be resolved, so it cannot compile, so no
1203    /// check downstream ever meets one.
1204    ///
1205    /// The other half of that guarantee is that no key string is spelled outside
1206    /// this module. The entries no document names — the kit grid, the designed-
1207    /// drop cap — are reached through the accessors below rather than by looking
1208    /// the key up in [`Metrics::building`], which is public so that a *reporter*
1209    /// can walk the whole table (`delvec metrics` counts it; the tests iterate
1210    /// it). Reporting is not resolution: a caller that walks every entry cannot
1211    /// name a wrong one, and a caller that wants ONE entry has an accessor and
1212    /// therefore no reason to type a key.
1213    ///
1214    /// # Errors
1215    ///
1216    /// [`UnknownMetric`], which the caller turns into `DW0812` with its own
1217    /// stage and path.
1218    pub fn resolve(&self, kind: MetricKind, named: &str) -> Result<&BuildingEntry, UnknownMetric> {
1219        let key = format!("{}{}", kind.prefix(), named);
1220        self.building
1221            .get(key.as_str())
1222            .ok_or_else(|| UnknownMetric {
1223                kind: kind.noun(),
1224                kind_plural: kind.plural(),
1225                named: named.to_string(),
1226                defined: self.names_of(kind),
1227            })
1228    }
1229
1230    /// The kit grid — the quantum a site-plan box's footprint is a multiple of,
1231    /// and the datum convention that fixes what a declared floor `y` means.
1232    ///
1233    /// One of the entries **no document names**: an author writes a number, not
1234    /// the word `grid`, so it has no place in [`Metrics::resolve`]'s naming
1235    /// vocabulary and would need a [`MetricKind`] whose prefix is the empty
1236    /// string — which would make `names_of` return the whole table. An accessor
1237    /// instead, so the key string still lives here and nowhere else.
1238    ///
1239    /// `None` only if the table stopped defining it, which
1240    /// [`Metrics::self_check`] reports as an internal error.
1241    #[must_use]
1242    pub fn grid(&self, reads: &mut Reads) -> Option<Grid> {
1243        match self.building.get("grid")?.value(reads) {
1244            MetricValue::Grid(g) => Some(*g),
1245            _ => None,
1246        }
1247    }
1248
1249    /// The deepest fall a **designed** one-way drop may declare, in blocks — a
1250    /// policy cap, deliberately tighter than the survivability fact in the
1251    /// player half. See [`Metrics::grid`] for why this is an accessor.
1252    #[must_use]
1253    pub fn max_designed_drop_blocks(&self, reads: &mut Reads) -> Option<u32> {
1254        match self.building.get("drop.max-designed-rise")?.value(reads) {
1255            MetricValue::Count(n) => Some(*n),
1256            _ => None,
1257        }
1258    }
1259
1260    /// The **widest** standard opening in the table, in cells — the floor a
1261    /// contact seam's span must exceed (spec-0053 §4).
1262    ///
1263    /// Derived from the table rather than seeded, and that is the whole of why
1264    /// the floor is honest: anything at or under this width **could have been a
1265    /// portal**, so a doorway declared a contact to dodge the standard set is
1266    /// refused by its own width. A seeded floor would be a number an author
1267    /// could argue with; this one is a consequence of the standard set, and it
1268    /// moves when the standard set moves.
1269    ///
1270    /// It walks every opening rather than naming one, so a broader standard
1271    /// landing tomorrow raises the floor with no edit here — the failure mode a
1272    /// hand-named `opening.gateway` would have is that the floor silently stops
1273    /// being the broadest the day a broader one is added.
1274    ///
1275    /// `None` only if the table defines no opening at all, which
1276    /// [`Metrics::self_check`] reports as an internal error.
1277    #[must_use]
1278    pub fn broadest_opening_width(&self, reads: &mut Reads) -> Option<u32> {
1279        let mut widest: Option<u32> = None;
1280        for name in self.names_of(MetricKind::Opening) {
1281            let Ok(entry) = self.resolve(MetricKind::Opening, name) else {
1282                continue;
1283            };
1284            if let MetricValue::Opening(o) = entry.value(reads) {
1285                widest = Some(widest.map_or(o.width, |w: u32| w.max(o.width)));
1286            }
1287        }
1288        widest
1289    }
1290
1291    /// Every name defined for a kind, in table order.
1292    #[must_use]
1293    pub fn names_of(&self, kind: MetricKind) -> Vec<&'static str> {
1294        self.building
1295            .keys()
1296            .filter_map(|k| k.strip_prefix(kind.prefix()))
1297            .collect()
1298    }
1299
1300    /// The `DW0813` notice for a run, or `None` when no verdict rested on an
1301    /// unwalked standard.
1302    ///
1303    /// `None` at a zero binding is the calibrated end state and not a vacuity:
1304    /// the line's job is to say that a green rests on a seed, and once the gym
1305    /// has walked every entry a run reads there is nothing left for it to say.
1306    /// The distinguishable failure — a run that read NOTHING — is reported by
1307    /// [`Reads::binding`], which every caller states whether or not this returns
1308    /// a line.
1309    #[must_use]
1310    pub fn notice(&self, reads: &Reads, stage: &str) -> Option<Diagnostic> {
1311        let provisional = reads.provisional();
1312        if provisional.is_empty() {
1313            return None;
1314        }
1315        let binding = reads.binding();
1316        Some(Diagnostic::warning(
1317            DW_METRIC_PROVISIONAL,
1318            stage,
1319            "",
1320            format!(
1321                "{n} of the {read} building metric(s) this run read are provisional — the \
1322                 metrics gym has not walked them: {names}. The checks still ran and still \
1323                 refuse; what is unproven is the number they refused against.",
1324                n = binding.provisional,
1325                read = binding.read,
1326                names = provisional.join(", "),
1327            ),
1328        ))
1329    }
1330
1331    /// Check the table against itself and against the player half.
1332    ///
1333    /// These are verdicts, and they read building metrics through a [`Reads`], so
1334    /// they are also what gives `DW0813` a live binding at this version: `delvec
1335    /// metrics` runs them, and the notice names the seeds the consistency verdict
1336    /// rested on. They are the reason the mechanism is demonstrable now rather
1337    /// than at the round that adds the documents.
1338    ///
1339    /// A violation is an **internal error**, not a diagnostic: the table is
1340    /// engine data, so an inconsistent one is a defect in this file and not in
1341    /// anybody's campaign, and there is no author to address a refusal to.
1342    #[must_use]
1343    pub fn self_check(&self) -> SelfCheck {
1344        let mut reads = Reads::new();
1345        let mut failures: Vec<String> = Vec::new();
1346        let mut checked = 0usize;
1347
1348        let floor_w = u64::from(passable_width_cells());
1349        let floor_h = u64::from(passable_clearance_cells());
1350
1351        // A place that is a route must be spellable at all. Zero way classes is
1352        // the state spec-0053 was written to end — the metrics gym reported
1353        // `corridor.min-width` and `corridor.min-clearance` unreachable because
1354        // no document could name a place that is not a box with a size class —
1355        // so an empty way vocabulary is an internal error rather than a table
1356        // that happens to be short one kind.
1357        checked += 1;
1358        if self.names_of(MetricKind::WayClass).is_empty() {
1359            failures.push(
1360                "the table defines no way class, so no document can state a place that is \
1361                 a route"
1362                    .to_string(),
1363            );
1364        }
1365
1366        // The contact floor is derived from the standard opening set (spec-0053
1367        // §4), so an empty set would make that floor `None` and the refusal it
1368        // is the floor for unable to separate a doorway from a front.
1369        checked += 1;
1370        if self.broadest_opening_width(&mut reads).is_none() {
1371            failures.push(
1372                "the table defines no standard opening, so a contact seam's width floor — \
1373                 the width a front must exceed to be a front rather than a door — cannot \
1374                 be derived"
1375                    .to_string(),
1376            );
1377        }
1378
1379        let quantum = match self.grid(&mut reads) {
1380            Some(g) => {
1381                checked += 1;
1382                if g.quantum == 0 {
1383                    failures.push("the kit grid's quantum is zero".to_string());
1384                }
1385                g.quantum
1386            }
1387            None => {
1388                failures.push("the table defines no kit `grid`".to_string());
1389                1
1390            }
1391        };
1392
1393        for (key, entry) in &self.building {
1394            match entry.value(&mut reads) {
1395                MetricValue::Opening(o) => {
1396                    checked += 1;
1397                    if u64::from(o.width) < floor_w || u64::from(o.height) < floor_h {
1398                        failures.push(format!(
1399                            "`{key}` is {}×{}, which no standing body passes ({floor_w}×{floor_h} \
1400                             is the floor)",
1401                            o.width, o.height
1402                        ));
1403                    }
1404                }
1405                MetricValue::Pitch(p) => {
1406                    checked += 1;
1407                    if p.step_16 > MAX_AUTO_STEP_16 {
1408                        failures.push(format!(
1409                            "`{key}` presents a tread of {}/16, over the {MAX_AUTO_STEP_16}/16 \
1410                             walk-up budget, so it is climbed by jumping and is not a standard \
1411                             pitch",
1412                            p.step_16
1413                        ));
1414                    }
1415                    if p.rise == 0 || p.run == 0 {
1416                        failures.push(format!("`{key}` has a zero rise or run"));
1417                    }
1418                }
1419                MetricValue::SizeClass(c) => {
1420                    checked += 1;
1421                    for (axis, lo, hi) in [
1422                        ("x", c.min_footprint[0], c.max_footprint[0]),
1423                        ("z", c.min_footprint[1], c.max_footprint[1]),
1424                    ] {
1425                        if lo > hi {
1426                            failures
1427                                .push(format!("`{key}` has a {axis} minimum above its maximum"));
1428                        }
1429                        if lo % quantum != 0 || hi % quantum != 0 {
1430                            failures.push(format!(
1431                                "`{key}` bounds its {axis} footprint at {lo}..{hi}, which is not \
1432                                 on the kit grid's quantum of {quantum}"
1433                            ));
1434                        }
1435                    }
1436                    if u64::from(c.min_clearance) < floor_h {
1437                        failures.push(format!(
1438                            "`{key}` allows a clearance of {}, under the passable floor of \
1439                             {floor_h}",
1440                            c.min_clearance
1441                        ));
1442                    }
1443                    if c.nominal_traverse_blocks == 0 {
1444                        failures.push(format!("`{key}` has a nominal traverse of zero"));
1445                    }
1446                }
1447                MetricValue::WayClass(w) => {
1448                    checked += 1;
1449                    // The two floors the freestanding `corridor.min-*` entries
1450                    // used to be checked at, re-asserted here against every way
1451                    // class rather than against the one that inherited them: a
1452                    // designed minimum is a comfort judgement and a standard
1453                    // under the physical passable size would be a standard
1454                    // nothing can use, which is true of a road exactly as it is
1455                    // of a corridor.
1456                    if u64::from(w.min_width) < floor_w {
1457                        failures.push(format!(
1458                            "`{key}` allows a width of {}, under the `passable.width` floor of \
1459                             {floor_w}",
1460                            w.min_width
1461                        ));
1462                    }
1463                    if u64::from(w.min_clearance) < floor_h {
1464                        failures.push(format!(
1465                            "`{key}` allows a clearance of {}, under the `passable.clearance` \
1466                             floor of {floor_h}",
1467                            w.min_clearance
1468                        ));
1469                    }
1470                    if w.min_width > w.max_width {
1471                        failures.push(format!(
1472                            "`{key}` bounds its width at {}..{}, a minimum above its maximum",
1473                            w.min_width, w.max_width
1474                        ));
1475                    }
1476                    // `max_width` is the elongation floor as well as the widest
1477                    // cross-section, and a box's horizontal extents are
1478                    // multiples of the kit quantum (`DW0825`). A `max_width` off
1479                    // the quantum is therefore a bound no plan can draw a way
1480                    // AT, which makes the widest member of the class
1481                    // uninstantiable and the gym unable to rule on it. The
1482                    // narrow bound is deliberately NOT held to the quantum: the
1483                    // corridor's inherited floor of 2 sits under a quantum of 4
1484                    // and which of those two provisional numbers moves is the
1485                    // walk's to decide, not this file's.
1486                    if !w.max_width.is_multiple_of(quantum) {
1487                        failures.push(format!(
1488                            "`{key}` bounds its width at {}, which is not on the kit grid's \
1489                             quantum of {quantum}, so no box can be drawn at the widest member \
1490                             of the class",
1491                            w.max_width
1492                        ));
1493                    }
1494                }
1495                _ => {}
1496            }
1497        }
1498
1499        for (key, floor) in [
1500            ("storey.low", floor_h + 2),
1501            ("storey.standard", floor_h + 2),
1502            ("storey.hall", floor_h + 2),
1503        ] {
1504            let Some(entry) = self.building.get(key) else {
1505                failures.push(format!("the table defines no `{key}`"));
1506                continue;
1507            };
1508            checked += 1;
1509            if let MetricValue::Count(n) = entry.value(&mut reads)
1510                && u64::from(*n) < floor
1511            {
1512                failures.push(format!(
1513                    "`{key}` is {n} blocks, which leaves no passable interior between a \
1514                     floor course and a ceiling course ({floor} is the floor)"
1515                ));
1516            }
1517        }
1518
1519        // The policy cap is deliberately tighter than the physical one. A cap
1520        // that reached the survivability ceiling would not be a policy.
1521        if let Some(n) = self.max_designed_drop_blocks(&mut reads) {
1522            checked += 1;
1523            let physical = unarmoured_survivable_fall_blocks();
1524            if f64::from(n) >= physical {
1525                failures.push(format!(
1526                    "`drop.max-designed-rise` is {n} blocks, at or past the unarmoured \
1527                     survivable fall of {physical}, so it is not a policy cap at all"
1528                ));
1529            }
1530        }
1531
1532        // A party cannot out-pace a body walking in a straight line.
1533        if let (Some(route), Some(walk)) = (
1534            self.building.get("pacing.route-blocks-per-minute"),
1535            self.building.get("pacing.walk-only-blocks-per-minute"),
1536        ) {
1537            checked += 1;
1538            if let (MetricValue::Count(r), MetricValue::Count(w)) =
1539                (route.value(&mut reads), walk.value(&mut reads))
1540                && r > w
1541            {
1542                failures.push(format!(
1543                    "`pacing.route-blocks-per-minute` is {r}, over the pure-walk ceiling of {w}"
1544                ));
1545            }
1546        }
1547
1548        SelfCheck {
1549            binding: SelfCheckBinding {
1550                invariants: checked,
1551                entries: self.building.len(),
1552                reads: reads.binding(),
1553            },
1554            reads,
1555            failures,
1556        }
1557    }
1558}
1559
1560/// What [`Metrics::self_check`] examined and what it found.
1561#[derive(Debug, Clone, PartialEq)]
1562pub struct SelfCheck {
1563    /// The ledger the verdicts read through, for [`Metrics::notice`].
1564    pub reads: Reads,
1565    /// What the run bound to. Stated whether or not anything failed, because a
1566    /// check that examined nothing is a finding and not a pass.
1567    pub binding: SelfCheckBinding,
1568    /// Inconsistencies, each a whole sentence. Non-empty is an internal error.
1569    pub failures: Vec<String>,
1570}
1571
1572/// [`Metrics::self_check`]'s binding count.
1573#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1574pub struct SelfCheckBinding {
1575    /// Invariants evaluated.
1576    pub invariants: usize,
1577    /// Building entries in the table.
1578    pub entries: usize,
1579    /// What those invariants read.
1580    pub reads: ReadBinding,
1581}
1582
1583/// The table rendered for export: every entry with its value, unit, provenance
1584/// and note, plus the halves' own counts.
1585///
1586/// Separate from [`Metrics`]'s own `Serialize` because the building half's value
1587/// is private — an export is the one consumer that reads a number without
1588/// resting a verdict on it.
1589#[must_use]
1590pub fn export(metrics: &Metrics) -> serde_json::Value {
1591    let mut player = serde_json::Map::new();
1592    for (k, e) in &metrics.player {
1593        player.insert((*k).to_string(), serde_json::json!(e));
1594    }
1595    let mut building = serde_json::Map::new();
1596    let mut uncalibrated = 0usize;
1597    for (k, e) in &metrics.building {
1598        if !e.calibrated {
1599            uncalibrated += 1;
1600        }
1601        building.insert(
1602            (*k).to_string(),
1603            serde_json::json!({
1604                "value": e.value_for_display(),
1605                "unit": e.unit,
1606                "provenance": e.provenance,
1607                "calibrated": e.calibrated,
1608                "note": e.note,
1609            }),
1610        );
1611    }
1612    serde_json::json!({
1613        "metrics_version": metrics.metrics_version,
1614        "mc_version": metrics.mc_version,
1615        "counts": {
1616            "player": metrics.player.len(),
1617            "building": metrics.building.len(),
1618            "uncalibrated": uncalibrated,
1619        },
1620        "player": player,
1621        "building": building,
1622    })
1623}
1624
1625#[cfg(test)]
1626mod tests {
1627    use super::*;
1628
1629    #[test]
1630    fn the_table_is_consistent_with_itself_and_with_the_player_half() {
1631        let m = Metrics::table();
1632        let check = m.self_check();
1633        assert!(
1634            check.failures.is_empty(),
1635            "the shipped metrics table is inconsistent: {:?}",
1636            check.failures
1637        );
1638        assert!(
1639            check.binding.invariants > 0,
1640            "a self-check that evaluated nothing is vacuous, not a pass"
1641        );
1642    }
1643
1644    #[test]
1645    fn every_building_entry_lands_uncalibrated() {
1646        let m = Metrics::table();
1647        assert!(!m.building.is_empty(), "the building half is empty");
1648        for (k, e) in &m.building {
1649            assert!(
1650                !e.calibrated,
1651                "`{k}` claims to be calibrated, but the metrics gym has not been walked"
1652            );
1653        }
1654    }
1655
1656    #[test]
1657    fn a_verdict_that_reads_a_seed_raises_dw0813_naming_it() {
1658        let m = Metrics::table();
1659        let check = m.self_check();
1660        assert!(
1661            check.binding.reads.provisional > 0,
1662            "the self-check read no provisional entry, so DW0813 binds to nothing"
1663        );
1664        let d = m
1665            .notice(&check.reads, "metrics")
1666            .expect("a run that read a seed owes the notice");
1667        assert_eq!(d.code, "DW0813");
1668        assert_eq!(d.severity, crate::diagnostic::Severity::Warning);
1669        for name in check.reads.provisional() {
1670            assert!(
1671                d.message.contains(name),
1672                "the notice must name `{name}`, the seed a verdict rested on"
1673            );
1674        }
1675    }
1676
1677    #[test]
1678    fn a_run_that_read_nothing_provisional_gets_no_notice() {
1679        let m = Metrics::table();
1680        let reads = Reads::new();
1681        assert!(m.notice(&reads, "metrics").is_none());
1682        assert_eq!(reads.binding().read, 0);
1683    }
1684
1685    #[test]
1686    fn an_undefined_name_is_dw0812_and_names_what_is_defined() {
1687        let m = Metrics::table();
1688        let err = m
1689            .resolve(MetricKind::SizeClass, "cathedral")
1690            .expect_err("`cathedral` is not a size class");
1691        let d = err.diagnostic("layout-graph", "/nodes/0/size_class");
1692        assert_eq!(d.code, "DW0812");
1693        assert!(d.message.contains("cathedral"));
1694        assert!(d.message.contains("room"), "the defined set is named");
1695        assert_eq!(d.stage, "layout-graph");
1696        assert_eq!(d.path, "/nodes/0/size_class");
1697    }
1698
1699    #[test]
1700    fn every_kind_resolves_at_least_one_defined_name() {
1701        let m = Metrics::table();
1702        for kind in [
1703            MetricKind::Opening,
1704            MetricKind::Pitch,
1705            MetricKind::SizeClass,
1706            MetricKind::Storey,
1707        ] {
1708            let names = m.names_of(kind);
1709            assert!(
1710                !names.is_empty(),
1711                "{} resolves nothing, so DW0812 would refuse every name",
1712                kind.noun()
1713            );
1714            for n in names {
1715                assert!(m.resolve(kind, n).is_ok(), "`{n}` does not resolve");
1716            }
1717        }
1718    }
1719
1720    #[test]
1721    fn the_derived_player_values_are_the_arithmetic_their_notes_claim() {
1722        assert!((walk_ticks_per_block() - 20.0 / 4.317).abs() < f64::EPSILON);
1723        assert!((walk_ticks_per_block() - 4.633).abs() < 0.001);
1724        assert_eq!(unarmoured_survivable_fall_blocks(), 22.0);
1725        assert_eq!(passable_width_cells(), 1);
1726        assert_eq!(passable_clearance_cells(), 2);
1727    }
1728}