349 lines
15 KiB
Rust
349 lines
15 KiB
Rust
//! 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:?}");
|
||
}
|
||
}
|
||
}
|