epiphany/spikes/editor-toolkit/round2-textkit/src/quantize.rs

233 lines
9.7 KiB
Rust

//! The one place this crate quantizes an `f64` staff-space value onto the
//! `1/1024` grid (W3 §5 invariant 5). Every position this crate constructs
//! — glyph offsets, caret stops, the run's `origin`, `bounds`, and
//! `reserved_box` — goes through this function, so there is exactly one
//! rounding rule to audit rather than one per call site.
//!
//! ## It is the *canonical* rounding rule, not a second one
//!
//! W3 §5 is explicit that this must not be a separate convention:
//!
//! > Positions are staff-space y-up, quantized on the same 1/1024 grid as
//! > glyph positions (`resolved.rs:11-13`), **so text quantization is not a
//! > second convention.**
//!
//! Revision 2 of this module implemented the grid arithmetic locally as
//! `(v * 1024.0).round() / 1024.0` and *named* the divergence in a doc comment
//! — `f64::round` is round-half-**away-from-zero**, while
//! `epiphany_determinism::QuantizedCoord::from_staff_spaces` is round-half-to-
//! **even** (Appendix D) — with the reasoning that this spike's values never
//! land on a tie in practice. That reasoning was wrong twice over. It is not
//! checkable (nothing asserted that no value ever ties, and a font metric or a
//! padding constant could land on one at any time), and more importantly W3's
//! requirement is about the *convention*, not about whether the two conventions
//! happen to agree on today's inputs. Naming a divergence is not the same as
//! being allowed to take it.
//!
//! So [`quantize_component`] now routes through `QuantizedCoord` itself. There
//! is one quantizer in this project and this module calls it; the tie-break is
//! whatever Appendix D says it is, today and after any future change to it.
use epiphany_determinism::QuantizedCoord;
/// Rounds `v` onto the canonical `1/1024` staff-space grid, using the
/// project's own quantizer — **round-to-nearest, ties-to-even**, per
/// Appendix D.
///
/// # Panics
///
/// If `v` is NaN, infinite, or so large that its scaled unit count leaves
/// `i64` range. `QuantizedCoord::from_staff_spaces` returns `None` for those
/// rather than saturating, and this crate deliberately does not paper over it
/// with a default: a position that cannot be placed on the canonical grid is a
/// bug in whatever computed it, and the fixture generator is the right place
/// for it to stop. No such value occurs in this recipe — every input is a font
/// metric or a stated constant — so this panic is a guard, not a code path.
pub fn quantize_component(v: f64) -> f64 {
QuantizedCoord::from_staff_spaces(v)
.unwrap_or_else(|| {
panic!(
"{v} cannot be placed on the canonical 1/1024 staff-space grid (NaN, infinite, or \
outside i64 range) — see epiphany_determinism::QuantizedCoord::from_staff_spaces"
)
})
.to_staff_spaces()
}
/// Whether `v` already sits **exactly** on the grid.
///
/// Revision 2 tested `(scaled - scaled.round()).abs() < 1e-6`, which claimed
/// exactness in its own doc comment while accepting anything within a
/// tolerance — the precise defect it was written to prevent, since "within
/// some tolerance of the grid" is what a fixed grid exists to rule out.
///
/// The test is now a round-trip through the canonical quantizer: `v` is on the
/// grid iff quantizing it changes nothing. That is exact by construction (the
/// multiply and divide by `1024` are exact for every value this crate handles,
/// `1024` being a power of two), and it rejects NaN, infinity and
/// out-of-range values as off-grid rather than passing them to a subtraction
/// that would produce `NaN < 1e-6 == false` by accident.
pub fn is_on_grid(v: f64) -> bool {
QuantizedCoord::from_staff_spaces(v).map(|q| q.to_staff_spaces()) == Some(v)
}
#[cfg(test)]
mod tests {
use super::*;
/// The recipe's original nominal origin. Kept as a permanent record of the
/// case even though §3 now states the quantized value: the property that
/// matters is that an unrepresentable literal is *moved*, not that this
/// particular one has since been fixed.
#[test]
fn quantizing_the_recipes_nominal_origin_lands_on_grid() {
let q = quantize_component(1.6);
assert!(is_on_grid(q));
assert_eq!(q, 1638.0 / 1024.0);
}
/// The constant the recipe now states must already be on the grid, so
/// that quantizing it changes nothing. If someone re-edits
/// `RUN_ORIGIN_STAFF` back to an unrepresentable literal, this fails
/// rather than being repaired in silence.
#[test]
fn the_declared_run_origin_is_already_on_grid() {
let (ox, oy) = crate::RUN_ORIGIN_STAFF;
assert!(
is_on_grid(ox as f64),
"origin x {ox} is off the 1/1024 grid"
);
assert!(
is_on_grid(oy as f64),
"origin y {oy} is off the 1/1024 grid"
);
assert_eq!(quantize_component(ox as f64), ox as f64);
assert_eq!(quantize_component(oy as f64), oy as f64);
}
#[test]
fn the_unquantized_literal_is_not_on_grid() {
// This is exactly the discrepancy the module doc comment names —
// proven here so it cannot silently stop being true.
assert!(!is_on_grid(1.6));
}
#[test]
fn is_on_grid_kills_a_value_one_unit_off() {
let q = quantize_component(1.6);
assert!(!is_on_grid(q + 1.0 / 2048.0));
}
// ---- ties-to-even, the convention W3 requires ----
//
// Each case below is a half-grid value where ties-to-even and
// ties-away-from-zero DISAGREE, so reverting `quantize_component` to
// `(v * 1024.0).round() / 1024.0` fails every one of them. A tie that both
// rules resolve the same way (scaled = 1.5, where both give 2) would prove
// nothing, and is checked separately below so this distinction is on the
// record rather than implied.
/// `0.5` grid units, positive: ties-to-even rounds **down to 0**;
/// ties-away-from-zero would round up to 1 unit.
#[test]
fn positive_half_grid_ties_to_even_down() {
let v = 1.0 / 2048.0; // scaled = 0.5
assert_eq!(quantize_component(v), 0.0);
assert_ne!(quantize_component(v), 1.0 / 1024.0);
}
/// `2.5` grid units, positive: ties-to-even rounds **down to 2**;
/// ties-away-from-zero would round up to 3.
#[test]
fn positive_half_grid_ties_to_even_at_two_and_a_half() {
let v = 5.0 / 2048.0; // scaled = 2.5
assert_eq!(quantize_component(v), 2.0 / 1024.0);
assert_ne!(quantize_component(v), 3.0 / 1024.0);
}
/// `-0.5` grid units: ties-to-even rounds **towards zero**;
/// ties-away-from-zero would round to -1 unit. The sign matters because
/// `f64::round`'s bias is away from zero in *both* directions, so a
/// positive-only test would miss half of the divergence.
#[test]
fn negative_half_grid_ties_to_even_towards_zero() {
let v = -1.0 / 2048.0; // scaled = -0.5
assert_eq!(quantize_component(v), 0.0);
assert_ne!(quantize_component(v), -1.0 / 1024.0);
}
/// `-2.5` grid units: ties-to-even rounds **to -2**;
/// ties-away-from-zero would round to -3.
#[test]
fn negative_half_grid_ties_to_even_at_minus_two_and_a_half() {
let v = -5.0 / 2048.0; // scaled = -2.5
assert_eq!(quantize_component(v), -2.0 / 1024.0);
assert_ne!(quantize_component(v), -3.0 / 1024.0);
}
/// A tie the two rules agree on, stated so the four tests above are
/// understood as testing the *disagreement* and not merely "ties round
/// somewhere".
#[test]
fn a_tie_the_two_conventions_agree_on_is_not_evidence() {
let v = 3.0 / 2048.0; // scaled = 1.5; ties-even -> 2, ties-away -> 2
assert_eq!(quantize_component(v), 2.0 / 1024.0);
assert_eq!((v * 1024.0).round() / 1024.0, 2.0 / 1024.0);
}
/// This module must not have its own arithmetic at all: quantizing agrees
/// with the canonical type exactly, for ties and non-ties alike.
#[test]
fn quantizing_is_the_canonical_quantizer() {
for v in [
0.0,
1.6,
-1.6,
1638.0 / 1024.0,
1.0 / 2048.0,
-1.0 / 2048.0,
5.0 / 2048.0,
-5.0 / 2048.0,
17.151_367_187_5,
-0.362_304_687_5,
] {
let canonical = QuantizedCoord::from_staff_spaces(v)
.unwrap()
.to_staff_spaces();
assert_eq!(quantize_component(v), canonical, "disagreement at {v}");
}
}
/// `is_on_grid` is exact, not tolerant. A value one part in `2^40` off the
/// grid is off the grid; revision 2's `1e-6` tolerance accepted it.
#[test]
fn is_on_grid_is_exact_not_tolerant() {
let on = 1638.0 / 1024.0;
assert!(is_on_grid(on));
let barely_off = f64::from_bits(on.to_bits() + 1);
assert_ne!(
barely_off, on,
"anchor: the perturbation must change the value"
);
assert!(
(barely_off * 1024.0 - (barely_off * 1024.0).round()).abs() < 1e-6,
"anchor: revision 2's tolerant test would have ACCEPTED this value"
);
assert!(
!is_on_grid(barely_off),
"one ULP off the grid is off the grid"
);
}
/// Non-finite and out-of-range values are off-grid, not accidentally
/// on-grid. Revision 2's subtraction produced `NaN`, and `NaN < 1e-6` is
/// `false`, so NaN came out off-grid by luck rather than by rule.
#[test]
fn non_finite_values_are_off_grid_by_rule() {
assert!(!is_on_grid(f64::NAN));
assert!(!is_on_grid(f64::INFINITY));
assert!(!is_on_grid(f64::NEG_INFINITY));
assert!(!is_on_grid(1e30)); // scaled well past i64 range
}
}