epiphany/crates/epiphany-layout-ir/src/quality.rs

349 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The Quality Metric Catalog's normative constants (companion specification
//! *Epiphany — Quality Metric Catalog*, v0.3.0): the per-axis normalization
//! anchors, the per-tier metric threshold table, the profile→threshold-column
//! mapping, and the `QualityFloorApproached` warning fraction.
//!
//! **Neither minor revision so far has moved a number here.** v0.2.0 narrowed
//! `spacing_distortion`'s *measurement domain* to rhythmic columns; v0.3.0
//! narrowed `vertical_density_penalty`'s *contributing units*, counting one per
//! realization of a gap band rather than one per band. Both changed what a
//! solver measures over, not what it normalizes against, so this transcription
//! of the numeric constants is unchanged across both.
//!
//! This module is a **transcription**, not an invention: every number here is
//! pinned by the catalog and cited to its chapter. Solvers that compute real
//! metrics (e.g. `epiphany-engrave`) normalize raw measurements through
//! [`normalize`] with the [`anchors`] of catalog Chapter 3 ("The Nine Normative
//! Metrics"), and reference the threshold tables of catalog Chapter 5
//! ("Per-Tier Metric Thresholds") — as does the reference-suite harness in
//! `epiphany-testkit`. The in-crate [`StubSolver`](crate::StubSolver) computes
//! no metrics and touches none of this (it stays on the all-worst
//! [`QualityMetricVector::unmeasured`] placeholder, the honest "no claim"
//! vector the catalog's vacuous-geometry requirement reserves for a solver
//! that computes no metrics at all).
use crate::solver::{
NormalizedMetric, QualityMetricKind, QualityMetricVector, SolverProfile, SolverTier,
};
/// The nine normative metric axes in their catalog order (catalog §"The
/// Normative Metric Set and `QualityMetricKind`", Table "kind-mapping").
pub const QUALITY_METRIC_KINDS: [QualityMetricKind; 9] = [
QualityMetricKind::Collision,
QualityMetricKind::Spacing,
QualityMetricKind::SlurShape,
QualityMetricKind::BeamSlope,
QualityMetricKind::VerticalDensity,
QualityMetricKind::SystemBreak,
QualityMetricKind::PageFill,
QualityMetricKind::CastingOff,
QualityMetricKind::SymbolDensity,
];
/// The per-axis normalization anchors `R_worst` (catalog Chapter 3): each
/// normative metric defines a dimensionless raw measurement `raw >= 0` and a
/// pinned anchor, and normalizes by the clamped-linear map
/// `n = min(1, raw / R_worst)` (catalog §"Normalization Form",
/// `req:qmc:normalization-form`). Implementations MUST use these anchors;
/// arbitrary normalization is non-conforming.
pub mod anchors {
/// `collision_penalty` (catalog §`collision_penalty`): colliding
/// cross-column pairs per glyph; one collision per twenty glyphs is
/// worst-tolerable.
pub const COLLISION_R_WORST: f64 = 0.05;
/// `spacing_distortion` (catalog §`spacing_distortion`): mean per-system
/// CV of column advances; CV 1.0 is spacing with no discernible
/// regularity.
pub const SPACING_R_WORST: f64 = 1.0;
/// `slur_shape_penalty` (catalog §`slur_shape_penalty`): mean deviation of
/// the arc ratio from the ideal band `[0.08, 0.25]`; a semicircular slur
/// (deviation 0.25) is worst-tolerable.
pub const SLUR_SHAPE_R_WORST: f64 = 0.25;
/// `beam_slope_penalty` (catalog §`beam_slope_penalty`): mean slope excess
/// over 0.25; slope 0.5 (deviation 0.25) is worst-tolerable.
pub const BEAM_SLOPE_R_WORST: f64 = 0.25;
/// `vertical_density_penalty` (catalog §`vertical_density_penalty`): mean
/// relative gap deviation `|r - p| / p`; a gap off by its own preferred
/// size is worst-tolerable.
pub const VERTICAL_DENSITY_R_WORST: f64 = 1.0;
/// `system_break_penalty` (catalog §`system_break_penalty`): mean
/// `|W - w_s| / W` over non-final systems; half-empty (or half-overflowing)
/// non-final systems are worst-tolerable.
pub const SYSTEM_BREAK_R_WORST: f64 = 0.5;
/// `page_fill_efficiency` (catalog §`page_fill_efficiency`): mean unfilled
/// fraction of non-final pages; three-quarters empty is worst-tolerable.
pub const PAGE_FILL_R_WORST: f64 = 0.75;
/// `casting_off_quality` (catalog §`casting_off_quality`): mean per-region
/// CV of system widths (final system included); CV 0.5 is worst-tolerable.
pub const CASTING_OFF_R_WORST: f64 = 0.5;
/// `symbol_density_uniformity` (catalog §`symbol_density_uniformity`):
/// mean per-region CV of glyphs-per-width densities; CV 0.5 is
/// worst-tolerable.
pub const SYMBOL_DENSITY_R_WORST: f64 = 0.5;
}
/// The pinned anchor `R_worst` for a normative axis (catalog Chapter 3; see
/// [`anchors`]).
pub fn r_worst(kind: QualityMetricKind) -> f64 {
match kind {
QualityMetricKind::Collision => anchors::COLLISION_R_WORST,
QualityMetricKind::Spacing => anchors::SPACING_R_WORST,
QualityMetricKind::SlurShape => anchors::SLUR_SHAPE_R_WORST,
QualityMetricKind::BeamSlope => anchors::BEAM_SLOPE_R_WORST,
QualityMetricKind::VerticalDensity => anchors::VERTICAL_DENSITY_R_WORST,
QualityMetricKind::SystemBreak => anchors::SYSTEM_BREAK_R_WORST,
QualityMetricKind::PageFill => anchors::PAGE_FILL_R_WORST,
QualityMetricKind::CastingOff => anchors::CASTING_OFF_R_WORST,
QualityMetricKind::SymbolDensity => anchors::SYMBOL_DENSITY_R_WORST,
}
}
/// The catalog's clamped-linear normalization (catalog §"Normalization Form",
/// `req:qmc:normalization-form`): `n = min(1, raw / R_worst)`, so `raw = 0`
/// (the ideal) normalizes to `0.0` and `raw >= R_worst` (the worst-tolerable
/// anchor and beyond) normalizes to `1.0`.
///
/// `raw` must be a finite, non-negative measurement and `r_worst` a positive
/// anchor, per the catalog; the result is a valid [`NormalizedMetric`] by
/// construction.
pub fn normalize(raw: f64, r_worst: f64) -> NormalizedMetric {
assert!(
raw.is_finite() && raw >= 0.0,
"a raw quality measurement must be finite and non-negative (got {raw})"
);
assert!(
r_worst > 0.0,
"a normalization anchor must be positive (got {r_worst})"
);
NormalizedMetric::new((raw / r_worst).min(1.0))
}
/// One column of the catalog's per-tier threshold table (catalog Chapter 5,
/// Table "tier-thresholds"): the maximum permitted [`NormalizedMetric`] value
/// per axis for a reference-suite entry evaluated at that tier.
#[derive(Copy, Clone, PartialEq, Debug)]
pub struct MetricThresholds {
pub collision_penalty: f64,
pub spacing_distortion: f64,
pub slur_shape_penalty: f64,
pub beam_slope_penalty: f64,
pub vertical_density_penalty: f64,
pub system_break_penalty: f64,
pub page_fill_efficiency: f64,
pub casting_off_quality: f64,
pub symbol_density_uniformity: f64,
}
impl MetricThresholds {
/// The column's threshold for one axis.
pub fn axis(&self, kind: QualityMetricKind) -> f64 {
match kind {
QualityMetricKind::Collision => self.collision_penalty,
QualityMetricKind::Spacing => self.spacing_distortion,
QualityMetricKind::SlurShape => self.slur_shape_penalty,
QualityMetricKind::BeamSlope => self.beam_slope_penalty,
QualityMetricKind::VerticalDensity => self.vertical_density_penalty,
QualityMetricKind::SystemBreak => self.system_break_penalty,
QualityMetricKind::PageFill => self.page_fill_efficiency,
QualityMetricKind::CastingOff => self.casting_off_quality,
QualityMetricKind::SymbolDensity => self.symbol_density_uniformity,
}
}
}
/// The **Minimal** threshold column (catalog Chapter 5, Table
/// "tier-thresholds"): uniformly `0.90` — relaxed but non-vacuous, excluding
/// layouts at an axis's worst-tolerable anchor and the all-worst unmeasured
/// placeholder ("measuring is part of the Minimal claim").
pub const MINIMAL_THRESHOLDS: MetricThresholds = MetricThresholds {
collision_penalty: 0.90,
spacing_distortion: 0.90,
slur_shape_penalty: 0.90,
beam_slope_penalty: 0.90,
vertical_density_penalty: 0.90,
system_break_penalty: 0.90,
page_fill_efficiency: 0.90,
casting_off_quality: 0.90,
symbol_density_uniformity: 0.90,
};
/// The **Standard** threshold column (catalog Chapter 5, Table
/// "tier-thresholds"): professional engraving quality — collisions bounded
/// tightest (`0.25`), the break family at `0.35`, distribution/vertical proxies
/// at `0.40`, slur/beam shape at `0.30`.
pub const STANDARD_THRESHOLDS: MetricThresholds = MetricThresholds {
collision_penalty: 0.25,
spacing_distortion: 0.40,
slur_shape_penalty: 0.30,
beam_slope_penalty: 0.30,
vertical_density_penalty: 0.40,
system_break_penalty: 0.35,
page_fill_efficiency: 0.40,
casting_off_quality: 0.35,
symbol_density_uniformity: 0.40,
};
/// The `QualityFloorApproached` warning fraction (catalog §"The
/// `QualityFloorApproached` Warning", `req:qmc:floor-warning`): a solver SHOULD
/// warn for axis `k` when `k`'s computed value exceeds **0.8×** the applicable
/// threshold — the one selected by the solve's [`SolverProfile`]
/// ([`profile_thresholds`]). The warning is diagnostic: emitting it does not
/// change the solve's status.
pub const QUALITY_FLOOR_FRACTION: f64 = 0.8;
/// The threshold column a **conformance tier** is evaluated against on the
/// reference suite (catalog Chapter 5): `Minimal` has its own relaxed column;
/// `Standard` the professional column; `Advanced` imposes the Standard column
/// on the nine normative axes (plus per-extension thresholds,
/// `req:qmc:advanced`, which this table does not model). `Stub` is below every
/// conformance tier and is evaluated against nothing — it computes no metrics
/// and passes no suite.
pub fn tier_thresholds(tier: SolverTier) -> Option<&'static MetricThresholds> {
match tier {
SolverTier::Stub => None,
SolverTier::Minimal => Some(&MINIMAL_THRESHOLDS),
SolverTier::Standard | SolverTier::Advanced => Some(&STANDARD_THRESHOLDS),
}
}
/// The threshold column a **registered profile** selects (catalog Chapter 6,
/// `req:qmc:profiles`): `Draft` → the Minimal column (few warnings, fast
/// iteration); `Standard` and `Publication` → the Standard column (no column
/// tighter than Standard is ratified as of v0.2). This is the column the solver's
/// own `QualityFloorApproached` diagnostics reference during ordinary solves;
/// suite evaluation at a claimed tier always uses that *tier's* column
/// ([`tier_thresholds`]).
pub fn profile_thresholds(profile: SolverProfile) -> &'static MetricThresholds {
match profile {
SolverProfile::Draft => &MINIMAL_THRESHOLDS,
SolverProfile::Standard | SolverProfile::Publication => &STANDARD_THRESHOLDS,
}
}
impl QualityMetricVector {
/// The vector's value for one normative axis, by its
/// [`QualityMetricKind`] (catalog Table "kind-mapping": each kind names
/// exactly one vector field).
pub fn axis(&self, kind: QualityMetricKind) -> NormalizedMetric {
match kind {
QualityMetricKind::Collision => self.collision_penalty,
QualityMetricKind::Spacing => self.spacing_distortion,
QualityMetricKind::SlurShape => self.slur_shape_penalty,
QualityMetricKind::BeamSlope => self.beam_slope_penalty,
QualityMetricKind::VerticalDensity => self.vertical_density_penalty,
QualityMetricKind::SystemBreak => self.system_break_penalty,
QualityMetricKind::PageFill => self.page_fill_efficiency,
QualityMetricKind::CastingOff => self.casting_off_quality,
QualityMetricKind::SymbolDensity => self.symbol_density_uniformity,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::solver::TieBreakingWeights;
#[test]
fn normalization_is_the_catalogs_clamped_linear_map() {
assert_eq!(normalize(0.0, 0.5).0, 0.0);
assert_eq!(normalize(0.25, 0.5).0, 0.5);
assert_eq!(normalize(0.5, 0.5).0, 1.0);
// At and beyond the anchor clamps to the worst-tolerable 1.0.
assert_eq!(normalize(3.0, 0.5).0, 1.0);
}
#[test]
#[should_panic(expected = "finite and non-negative")]
fn normalization_rejects_a_negative_raw() {
let _ = normalize(-0.1, 0.5);
}
#[test]
fn minimal_is_uniformly_more_permissive_than_standard() {
// Catalog Table "tier-thresholds": "Minimal is uniformly more
// permissive than Standard on every axis."
for kind in QUALITY_METRIC_KINDS {
assert!(
MINIMAL_THRESHOLDS.axis(kind) > STANDARD_THRESHOLDS.axis(kind),
"{kind:?}"
);
// Both columns are valid NormalizedMetric bounds.
assert!((0.0..=1.0).contains(&MINIMAL_THRESHOLDS.axis(kind)));
assert!((0.0..=1.0).contains(&STANDARD_THRESHOLDS.axis(kind)));
}
}
#[test]
fn the_minimal_column_excludes_the_unmeasured_placeholder() {
// Catalog Chapter 5 rationale: "a solver reporting the unmeasured 1.0
// placeholder cannot pass the Minimal suite" — measuring is part of
// the Minimal claim.
let unmeasured = QualityMetricVector::unmeasured();
assert!(QUALITY_METRIC_KINDS
.iter()
.any(|&k| unmeasured.axis(k).0 > MINIMAL_THRESHOLDS.axis(k)));
}
#[test]
fn tier_and_profile_columns_map_per_the_catalog() {
// Tiers (catalog ch5): Minimal has its own column; Standard and
// Advanced share the Standard column; Stub is evaluated against nothing.
assert_eq!(tier_thresholds(SolverTier::Stub), None);
assert_eq!(
tier_thresholds(SolverTier::Minimal),
Some(&MINIMAL_THRESHOLDS)
);
assert_eq!(
tier_thresholds(SolverTier::Standard),
Some(&STANDARD_THRESHOLDS)
);
assert_eq!(
tier_thresholds(SolverTier::Advanced),
Some(&STANDARD_THRESHOLDS)
);
// Profiles (catalog ch6): Draft → Minimal column; Standard and
// Publication → Standard column; Standard is the default profile.
assert_eq!(
profile_thresholds(SolverProfile::Draft),
&MINIMAL_THRESHOLDS
);
assert_eq!(
profile_thresholds(SolverProfile::Standard),
&STANDARD_THRESHOLDS
);
assert_eq!(
profile_thresholds(SolverProfile::Publication),
&STANDARD_THRESHOLDS
);
assert_eq!(SolverProfile::default(), SolverProfile::Standard);
}
#[test]
fn default_tie_breaking_weights_are_the_catalogs_normative_defaults() {
// Catalog Chapter 4 (`req:qmc:weights`): every one of the nine weights
// defaults to 1.0 — blessing the implementation's existing `Default`.
let w = TieBreakingWeights::default();
for value in [
w.collision,
w.spacing,
w.slur_shape,
w.beam_slope,
w.vertical_density,
w.system_break,
w.page_fill,
w.casting_off,
w.symbol_density,
] {
assert_eq!(value, 1.0);
}
}
#[test]
fn every_axis_has_a_positive_anchor() {
for kind in QUALITY_METRIC_KINDS {
assert!(r_worst(kind) > 0.0, "{kind:?}");
}
}
}