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

932 lines
36 KiB
Rust

//! The constraint-solver interface (Chapter 9 "Constraint-Solver Interface")
//! and the v0 stub solver.
//!
//! Chapter 9 specifies the *interface* and its contracts, not an algorithm.
//! This module implements the interface surface in full shape — the
//! [`ConstraintSolver`] trait (`solve`/`solve_incremental`/`tier`/`version`,
//! `Send + Sync`), [`SolverConfig`] (profile, budget, tie-breaking weights),
//! [`SolverState`], the [`InvalidationSet`] (slots/bands/constraints/glyphs), and
//! a [`SolveReport`] with its full diagnostic surface (unsatisfied constraints,
//! warnings, a [`QualityMetricVector`], budget used, state) — and a
//! [`StubSolver`] that, per the QUICKSTART, "returns `SolveStatus::Solved` with
//! the input geometry verbatim" — for a constraint-free problem; with
//! constraints declared it stays a renderable passthrough but claims no
//! satisfaction (see [`StubSolver`]).
//!
//! **The stub computes no quality metrics** (QUICKSTART: "only the interface —
//! don't implement quality metrics"): the
//! [`QualityMetricVector`]/[`NormalizedMetric`] *types* and the
//! [`TieBreakingWeights`] exist (the interface requires them), and the Quality
//! Metric Catalog's normative anchors and threshold tables are transcribed in
//! [`crate::quality`] for solvers that do measure (`epiphany-engrave`). The
//! `StubSolver` is not a conformant solver and passes no reference suite, so it
//! reports the [`SolverTier::Stub`] tier (the honest non-conformance rung, below
//! `Minimal`) and an all-worst metric vector. Those values are deliberately
//! conservative placeholders, not computed quality measurements; the real solver
//! replaces them.
use epiphany_core::TypedObjectId;
use crate::constrained::{ConstrainedLayoutIR, GlyphObjectId};
use crate::glyph::{all_available, BravuraCatalog, GlyphCatalog};
use crate::resolved::{
PrimitiveIndices, ResolvedGlyph, ResolvedLayoutIR, ResolvedPage, ResolvedSystem,
};
use crate::spatial::{Margins, Rect, Size2D};
use crate::vertical_band::VerticalBandId;
/// The solver status (Chapter 9 §"The Solver Report"). Variants and their
/// authority rules are quoted from the spec.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum SolveStatus {
/// All hard constraints satisfied, target quality reached.
Solved,
/// All hard constraints satisfied, but warnings were generated.
SolvedWithWarnings,
/// Deterministic budget exhausted before reaching target quality. The
/// returned layout still satisfies all hard constraints.
PartialBudgetExhausted,
/// Hard constraints cannot be simultaneously satisfied; the layout is
/// diagnostic-only.
Unsatisfiable,
/// Solver bug or unexpected error; the layout is diagnostic-only.
InternalError,
}
impl SolveStatus {
/// Whether a layout under this status may be rendered as authoritative
/// (Chapter 9 §"The Solver Report").
pub fn is_renderable(self) -> bool {
matches!(
self,
SolveStatus::Solved
| SolveStatus::SolvedWithWarnings
| SolveStatus::PartialBudgetExhausted
)
}
}
/// The conformance tier a solver claims (Chapter 9 §"Conformance Tiers").
///
/// `Stub` is below the spec's three conformance tiers: it is *not* a conformance
/// claim but its honest absence — an interface-only solver that evaluates no
/// constraints and computes no quality metrics reports `Stub`, never `Minimal`,
/// so a caller cannot mistake the passthrough for the lowest conformant tier.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum SolverTier {
/// Not a conformance tier: an interface-only / passthrough solver that
/// evaluates no constraints and computes no quality metrics (e.g.
/// [`StubSolver`]). Ordered below every conformant tier.
Stub,
/// Minimal Layout Solver.
Minimal,
/// Standard Engraving Solver.
Standard,
/// Advanced / Extension-Aware Solver.
Advanced,
}
/// A solver's implementation version (Chapter 9: within a fixed version,
/// identical input produces identical output).
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct SolverVersion(pub u32);
/// The conformance profile under which to solve (Chapter 9 §"The Solver
/// Interface": `SolverConfig.profile` — selects metric thresholds and the active
/// constraint/extension set). The registered profile catalog and each profile's
/// threshold column are the Quality Metric Catalog's Chapter 6, transcribed as
/// [`crate::quality::profile_thresholds`].
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Default)]
pub enum SolverProfile {
/// Fast, low-quality (draft) profile.
Draft,
/// The reference engraving-quality profile.
#[default]
Standard,
/// The highest-quality (publication) profile.
Publication,
}
/// Tie-breaking weights among layouts of equivalent quality (Chapter 9
/// §"Quality Metrics": `TieBreakingWeights`). The normative defaults are the
/// Quality Metric Catalog's Chapter 4: every weight `1.0` — exactly this
/// type's [`Default`].
#[derive(Copy, Clone, PartialEq, Debug)]
pub struct TieBreakingWeights {
pub collision: f64,
pub spacing: f64,
pub slur_shape: f64,
pub beam_slope: f64,
pub vertical_density: f64,
pub system_break: f64,
pub page_fill: f64,
pub casting_off: f64,
pub symbol_density: f64,
}
impl Default for TieBreakingWeights {
fn default() -> Self {
TieBreakingWeights {
collision: 1.0,
spacing: 1.0,
slur_shape: 1.0,
beam_slope: 1.0,
vertical_density: 1.0,
system_break: 1.0,
page_fill: 1.0,
casting_off: 1.0,
symbol_density: 1.0,
}
}
}
/// The deterministic budget (Chapter 9 §"The Solver Interface": `SolverBudget`).
/// Wall-clock time is advisory only; the canonical layout depends on the
/// deterministic counters.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct SolverBudget {
pub max_iterations: u64,
pub max_nodes: u64,
pub max_constraint_evaluations: u64,
pub advisory_wall_time_ms: Option<u64>,
}
impl Default for SolverBudget {
fn default() -> Self {
SolverBudget {
max_iterations: u64::MAX,
max_nodes: u64::MAX,
max_constraint_evaluations: u64::MAX,
advisory_wall_time_ms: None,
}
}
}
/// The deterministic budget consumed by a solve (Chapter 9: `SolverBudgetUsed`).
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
pub struct SolverBudgetUsed {
pub iterations: u64,
pub nodes: u64,
pub constraint_evaluations: u64,
pub wall_time_ms: u64,
}
/// Solver configuration (Chapter 9 §"The Solver Interface": `SolverConfig`):
/// the conformance profile, the deterministic budget, and the tie-breaking
/// weights.
#[derive(Copy, Clone, PartialEq, Debug, Default)]
pub struct SolverConfig {
pub profile: SolverProfile,
pub budget: SolverBudget,
pub tie_breaking: TieBreakingWeights,
}
/// A quality metric normalized to `[0.0, 1.0]`, lower is better (Chapter 9
/// §"Quality Metrics": `NormalizedMetric`).
#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Default)]
pub struct NormalizedMetric(pub f64);
impl NormalizedMetric {
/// Constructs, panicking if the value is not finite or out of `[0.0, 1.0]`
/// — conforming implementations construct only valid values (Chapter 9).
pub fn new(value: f64) -> Self {
assert!(value.is_finite(), "NormalizedMetric must be finite");
assert!(
(0.0..=1.0).contains(&value),
"NormalizedMetric must lie in [0.0, 1.0]"
);
NormalizedMetric(value)
}
}
/// An extension-contributed quality metric id (Chapter 9: `ExtensionMetricId`).
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct ExtensionMetricId(pub u128);
/// An extension-contributed quality metric (Chapter 9: `ExtensionMetric`).
#[derive(Copy, Clone, PartialEq, Debug)]
pub struct ExtensionMetric {
pub metric_id: ExtensionMetricId,
pub value: NormalizedMetric,
}
/// The quality metric vector for a layout (Chapter 9 §"Quality Metrics":
/// `QualityMetricVector`). An interface-only solver that computes no metrics
/// reports the conservative all-worst placeholder
/// ([`QualityMetricVector::unmeasured`], every metric `1.0`), never a measured
/// value, so a caller cannot mistake an unmeasured layout for a good one. (The
/// derived [`Default`] is all-`0.0`/nominal-best and is *not* what the stub
/// reports.) A measuring solver computes each axis per the Quality Metric
/// Catalog's formulas, normalized through [`crate::quality::normalize`] with
/// the catalog's pinned anchors ([`crate::quality::anchors`]).
#[derive(Clone, PartialEq, Debug, Default)]
pub struct QualityMetricVector {
pub collision_penalty: NormalizedMetric,
pub spacing_distortion: NormalizedMetric,
pub slur_shape_penalty: NormalizedMetric,
pub beam_slope_penalty: NormalizedMetric,
pub vertical_density_penalty: NormalizedMetric,
pub system_break_penalty: NormalizedMetric,
pub page_fill_efficiency: NormalizedMetric,
pub casting_off_quality: NormalizedMetric,
pub symbol_density_uniformity: NormalizedMetric,
pub extension_metrics: Vec<ExtensionMetric>,
}
impl QualityMetricVector {
/// A conservative placeholder for an interface-only solver that does not
/// compute quality metrics. Every built-in metric is worst-valued.
pub fn unmeasured() -> Self {
let worst = NormalizedMetric::new(1.0);
QualityMetricVector {
collision_penalty: worst,
spacing_distortion: worst,
slur_shape_penalty: worst,
beam_slope_penalty: worst,
vertical_density_penalty: worst,
system_break_penalty: worst,
page_fill_efficiency: worst,
casting_off_quality: worst,
symbol_density_uniformity: worst,
extension_metrics: Vec::new(),
}
}
}
/// Opaque solver state threaded into [`ConstraintSolver::solve_incremental`]
/// (Chapter 9 §"The Solver Report": `SolverState`). v0 records the solver
/// version and the resolved-glyph count, enough to drive the observational-
/// equivalence contract for the trivial stub.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
pub struct SolverState {
pub solver_version: Option<SolverVersion>,
pub resolved_glyphs: usize,
}
/// The scope of an incremental invalidation (Chapter 9 §"Incremental Solving":
/// `InvalidationScope`). The solver MAY widen this conservatively; it MUST NOT
/// narrow it.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum InvalidationScope {
ObjectLocal,
MeasureLocal,
SystemLocal,
PageLocal,
RegionLocal,
WholeScore,
}
/// A horizontal spring-slot id (Chapter 7 §"Spring Slots": `SpringSlotId`).
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct SpringSlotId(pub u128);
/// A constraint identifier referenced by [`SolveReport::unsatisfied_constraints`]
/// (Chapter 9: `ConstraintId`). The stub never reports any: it evaluates no
/// constraints, so it neither claims one satisfied nor names one unsatisfied.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct ConstraintId(pub u128);
/// The strength a constraint binds the solver with (Chapter 9 §"Strength
/// Levels": `ConstraintStrength`). Constraints do not carry this in the IR —
/// the spec's [`crate::LayoutConstraint`] enum has no strength field — so it is
/// attached by rule via [`crate::LayoutConstraint::strength`].
#[derive(Copy, Clone, PartialEq, Debug)]
pub enum ConstraintStrength {
/// Hard constraint. The solver MUST satisfy it or return
/// [`SolveStatus::Unsatisfiable`], and MUST NOT treat it as if it were
/// `Preferred` for any reason, including quality optimization (Chapter 9
/// §"Strength Levels").
Required,
/// Soft constraint with an associated weight. The solver minimizes the
/// weighted violation when optimizing; an unhonoured preference is a
/// warning ([`SolverWarningKind::LargeSoftConstraintViolation`]), never an
/// `Unsatisfiable`.
Preferred { weight: f64 },
}
/// A declared invalidation (Chapter 9: `InvalidationSet`) over the invalidated
/// slots, bands, constraints, and glyphs.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct InvalidationSet {
pub scope: InvalidationScope,
pub slots: Vec<SpringSlotId>,
pub bands: Vec<VerticalBandId>,
pub constraints: Vec<ConstraintId>,
pub glyphs: Vec<GlyphObjectId>,
}
/// A normative quality-metric axis (Chapter 9 §"Quality Metrics"), referenced by
/// [`SolverWarningKind::QualityFloorApproached`].
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub enum QualityMetricKind {
Collision,
Spacing,
SlurShape,
BeamSlope,
VerticalDensity,
SystemBreak,
PageFill,
CastingOff,
SymbolDensity,
}
/// An extension-defined solver-warning id (Chapter 9: `ExtensionWarningId`).
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct ExtensionWarningId(pub u128);
/// The kind of a non-fatal solver warning (Chapter 9 §"The Solver Report":
/// `SolverWarningKind`) — every normative variant.
#[derive(Clone, PartialEq, Debug)]
pub enum SolverWarningKind {
LargeSoftConstraintViolation {
constraint: ConstraintId,
magnitude: f64,
},
UnusualLayoutDecision(String),
QualityFloorApproached {
metric: QualityMetricKind,
},
ExtensionWarning(ExtensionWarningId),
}
/// A non-fatal solver warning (Chapter 9 §"The Solver Report": `SolverWarning`).
#[derive(Clone, PartialEq, Debug)]
pub struct SolverWarning {
pub kind: SolverWarningKind,
pub affected_objects: Vec<TypedObjectId>,
pub message: String,
}
/// The solver report (Chapter 9 §"The Solver Report"). The `layout` is always
/// present; under a failure `status` it is diagnostic-only and MUST NOT be used
/// as if valid.
#[derive(Clone, PartialEq, Debug)]
pub struct SolveReport {
pub status: SolveStatus,
/// Whether every hard constraint is satisfied.
pub satisfied_hard_constraints: bool,
pub layout: ResolvedLayoutIR,
/// Unsatisfied hard constraints, if any (empty under `Solved`).
pub unsatisfied_constraints: Vec<ConstraintId>,
/// Non-fatal warnings about the solution.
pub warnings: Vec<SolverWarning>,
/// Quality metric vector for the returned layout.
pub metric_vector: QualityMetricVector,
/// Budget consumed during this solve.
pub budget_used: SolverBudgetUsed,
/// Updated solver state for subsequent incremental calls.
pub state: SolverState,
}
/// The constraint-solver interface (Chapter 9 §"The Solver Interface"). `solve`
/// and `solve_incremental` MUST be pure functions of their inputs within the
/// determinism contract; the trait is `Send + Sync` per the spec.
pub trait ConstraintSolver: Send + Sync {
/// The solver's identifying conformance tier.
fn tier(&self) -> SolverTier;
/// The solver's implementation version.
fn version(&self) -> SolverVersion;
/// Solve from scratch.
fn solve(&self, input: &ConstrainedLayoutIR, config: &SolverConfig) -> SolveReport;
/// Solve incrementally over the declared invalidation scope. Must be
/// observationally equivalent to [`ConstraintSolver::solve`] restricted to
/// that scope (Chapter 9 §"Observational Equivalence").
fn solve_incremental(
&self,
input: &ConstrainedLayoutIR,
prior: &SolverState,
invalidations: &InvalidationSet,
config: &SolverConfig,
) -> SolveReport;
}
/// The v0 stub solver (QUICKSTART, Agent E: "the stub returns
/// `SolveStatus::Solved` with the input geometry verbatim").
///
/// It copies each glyph's baseline anchor into its resolved position unchanged,
/// preserves provenance, carries the engraving decisions and catalog forward,
/// and reports all hard constraints satisfied — provided every glyph's metrics
/// are bundled and the catalog hash actually covers the consulted metrics
/// (Chapter 7 §7.3.2). A glyph whose metrics are not bundled, or a catalog hash
/// that does not match its glyphs, is a well-formedness failure reported as
/// [`SolveStatus::InternalError`] (never a panic).
///
/// **Declared constraints are not evaluated** ([`SolverTier::Stub`]), and the
/// report is honest about it in both directions: the solve stays renderable
/// (geometry passes through; unevaluated constraints are not a defect in the
/// *input*), but `satisfied_hard_constraints` is `false` and a warning names
/// the gap — the stub never claims satisfaction it did not check. Chapter 9
/// has no status for "renderable, constraints unevaluated", so the closest
/// non-claiming renderable status, [`SolveStatus::SolvedWithWarnings`], is
/// used (see DECISIONS.md).
pub struct StubSolver;
impl StubSolver {
fn resolve(&self, input: &ConstrainedLayoutIR) -> SolveReport {
let structural_valid = input.validate().is_ok();
// Short-circuit before catalog identity construction so an unknown glyph
// yields InternalError rather than panicking in the metrics hash.
let names: Vec<&str> = input
.glyphs
.iter()
.map(|glyph| glyph.glyph.as_str())
.collect();
let metrics_available = all_available(names.iter().copied());
let catalog_valid = metrics_available && input.catalog == BravuraCatalog.identity(&names);
let well_formed = structural_valid && catalog_valid;
// This interface-only solver can preserve already-resolved geometry but
// does not evaluate explicit constraints. It must not claim those are
// satisfied merely because the input is structurally well formed.
let unevaluated = input.constraints.len();
let status = if !well_formed {
SolveStatus::InternalError
} else if unevaluated > 0 {
SolveStatus::SolvedWithWarnings
} else {
SolveStatus::Solved
};
let warnings = if well_formed && unevaluated > 0 {
vec![SolverWarning {
kind: SolverWarningKind::UnusualLayoutDecision(format!(
"the interface-only stub solver evaluated none of the {unevaluated} \
declared constraint(s); satisfaction is not claimed"
)),
affected_objects: Vec::new(),
message: "declared constraints were not evaluated".to_owned(),
}]
} else {
Vec::new()
};
let glyphs: Vec<ResolvedGlyph> = if structural_valid {
input
.glyphs
.iter()
.map(|g| ResolvedGlyph {
provenance: g.provenance.clone(),
glyph: g.glyph.clone(),
position: g.baseline,
transform: None,
bounding_box: g.bounding_box,
style: g.style,
layer: g.layer,
})
.collect()
} else {
Vec::new()
};
// Strokes and curves pass through verbatim (the stub resolves no
// geometry), gated on the same structural validity as the glyphs.
let strokes = if structural_valid {
input.strokes.clone()
} else {
Vec::new()
};
let curves = if structural_valid {
input.curves.clone()
} else {
Vec::new()
};
let resolved_glyphs = glyphs.len();
// The stub resolves no per-system geometry (every region becomes one
// degenerate default-rect system, per below), so it has no honest
// per-system attribution to publish: every primitive is unowned
// rather than a fabricated claim (W1 pin 3).
let unowned = PrimitiveIndices {
glyphs: (0..glyphs.len() as u32).collect(),
strokes: (0..strokes.len() as u32).collect(),
curves: (0..curves.len() as u32).collect(),
};
let pages = input
.regions
.first()
.map(|first| ResolvedPage {
provenance: first.provenance.clone(),
number: 1,
size: Size2D::default(),
margins: Margins::default(),
systems: input
.regions
.iter()
.map(|region| ResolvedSystem {
provenance: region.provenance.clone(),
bounding_box: Rect::default(),
staves: Vec::new(),
measures: Vec::new(),
primitives: PrimitiveIndices::default(),
})
.collect(),
free_objects: Vec::new(),
})
.into_iter()
.collect();
SolveReport {
status,
// Honest in both directions: false when the input is malformed *and*
// when constraints were declared but not evaluated.
satisfied_hard_constraints: well_formed && unevaluated == 0,
layout: ResolvedLayoutIR {
source: input.source,
pages,
glyphs,
strokes,
curves,
engraving_decisions: input.engraving_decisions.clone(),
catalog: input.catalog.clone(),
unowned,
},
unsatisfied_constraints: Vec::new(),
warnings,
metric_vector: QualityMetricVector::unmeasured(),
// The stub does no iterative work; its deterministic budget use is zero.
budget_used: SolverBudgetUsed::default(),
state: SolverState {
solver_version: Some(self.version()),
resolved_glyphs,
},
}
}
}
impl ConstraintSolver for StubSolver {
fn tier(&self) -> SolverTier {
// Honest: a passthrough that evaluates no constraints is below Minimal.
SolverTier::Stub
}
fn version(&self) -> SolverVersion {
SolverVersion(0)
}
fn solve(&self, input: &ConstrainedLayoutIR, _config: &SolverConfig) -> SolveReport {
self.resolve(input)
}
fn solve_incremental(
&self,
input: &ConstrainedLayoutIR,
_prior: &SolverState,
_invalidations: &InvalidationSet,
_config: &SolverConfig,
) -> SolveReport {
// The stub resolves geometry verbatim, so a full re-solve is trivially
// observationally equivalent to any scoped incremental solve
// (Chapter 9 §"Observational Equivalence").
self.resolve(input)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constrained::GlyphObject;
use crate::glyph::GlyphCatalogIdentity;
use crate::provenance::{LayoutObjectId, Provenance};
use crate::spatial::Point;
use crate::vertical_band::{VerticalBand, VerticalBandId};
use epiphany_core::{EventId, TypedObjectId, WallClockTime};
fn glyph(name: &'static str) -> GlyphObject {
let source = TypedObjectId::Event(EventId::from_raw(1));
GlyphObject {
provenance: Provenance::projected(source, vec![]),
glyph: crate::GlyphReference::borrowed(name),
horizontal_slot: SpringSlotId(0),
baseline: Point::new(1.0, 0.0),
vertical_band: VerticalBandId(0),
bounding_box: crate::BoundingBox::default(),
anchor: Point::ORIGIN,
layer: 0,
style: crate::GlyphStyle { rgba: 0x0000_00ff },
}
}
fn constrained(mut glyphs: Vec<GlyphObject>) -> ConstrainedLayoutIR {
let band = VerticalBand::margin(
LayoutObjectId(0),
glyphs.iter().map(GlyphObject::id).collect(),
);
for glyph in &mut glyphs {
glyph.vertical_band = band.id;
}
let names: Vec<&str> = glyphs.iter().map(|glyph| glyph.glyph.as_str()).collect();
let catalog = BravuraCatalog.identity(&names);
ConstrainedLayoutIR {
source: crate::ScoreVersion::default(),
regions: vec![],
horizontal_slots: vec![crate::SpringSlot {
id: SpringSlotId(0),
time: crate::TimePoint::WallClock(WallClockTime(0)),
min_width: crate::StaffSpace(1.0),
preferred_width: crate::StaffSpace(1.0),
max_width: None,
stretch_factor: 1.0,
compress_factor: 1.0,
members: glyphs.iter().map(GlyphObject::id).collect(),
}],
glyphs,
strokes: vec![],
curves: vec![],
vertical_bands: vec![band],
constraints: vec![],
break_origins: vec![],
engraving_decisions: vec![],
diagnostics: vec![],
catalog,
}
}
#[test]
fn stub_reports_the_non_conformant_stub_tier_and_worst_metrics() {
// Honest non-conformance: a passthrough reports Stub, never Minimal, and
// Stub orders below every real conformance tier.
assert_eq!(StubSolver.tier(), SolverTier::Stub);
assert!(SolverTier::Stub < SolverTier::Minimal);
assert_eq!(StubSolver.version(), SolverVersion(0));
let input = constrained(vec![glyph("noteheadBlack")]);
assert_eq!(
StubSolver
.solve(&input, &SolverConfig::default())
.metric_vector,
QualityMetricVector::unmeasured()
);
}
#[test]
fn validate_rejects_dangling_constraint_references() {
use crate::constrained::{
BreakKind, ConstrainedValidationError, GlyphObjectId, LayoutConstraint,
};
let mut input = constrained(vec![glyph("noteheadBlack")]);
assert!(input.validate().is_ok());
let real = input.glyphs[0].id();
// A constraint naming a glyph that is not in the set is rejected, not
// silently accepted.
let ghost = GlyphObjectId(real.0 ^ 0xABCD);
input
.constraints
.push(LayoutConstraint::NoCollision { a: real, b: ghost });
assert_eq!(
input.validate(),
Err(ConstrainedValidationError::UnknownConstraintGlyph(ghost))
);
// A break constraint on a non-existent slot is rejected.
input.constraints = vec![LayoutConstraint::SystemBreakAt {
slot: SpringSlotId(999),
kind: BreakKind::Hard,
}];
assert_eq!(
input.validate(),
Err(ConstrainedValidationError::UnknownConstraintSlot(
SpringSlotId(999)
))
);
// A well-formed constraint reference validates — and the stub solver
// still does not *evaluate* it: the solve stays renderable, but it does
// not claim the constraint satisfied.
input.constraints = vec![LayoutConstraint::NoCollision { a: real, b: real }];
assert!(input.validate().is_ok());
let report = StubSolver.solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::SolvedWithWarnings);
assert!(!report.satisfied_hard_constraints);
}
#[test]
fn unknown_glyph_yields_internal_error_not_panic() {
let mut unknown = glyph("noSuchGlyph");
let band = VerticalBand::margin(LayoutObjectId(0), vec![unknown.id()]);
unknown.vertical_band = band.id;
let input = ConstrainedLayoutIR {
source: crate::ScoreVersion::default(),
regions: vec![],
horizontal_slots: vec![crate::SpringSlot {
id: SpringSlotId(0),
time: crate::TimePoint::WallClock(WallClockTime(0)),
min_width: crate::StaffSpace(1.0),
preferred_width: crate::StaffSpace(1.0),
max_width: None,
stretch_factor: 1.0,
compress_factor: 1.0,
members: vec![unknown.id()],
}],
glyphs: vec![unknown],
strokes: vec![],
curves: vec![],
vertical_bands: vec![band],
constraints: vec![],
break_origins: vec![],
engraving_decisions: vec![],
diagnostics: vec![],
catalog: GlyphCatalogIdentity::default(),
};
let report = StubSolver.solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::InternalError);
assert!(!report.satisfied_hard_constraints);
}
#[test]
fn well_formed_input_solves_verbatim() {
let input = constrained(vec![glyph("noteheadBlack")]);
let report = StubSolver.solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved);
assert!(report.satisfied_hard_constraints);
assert_eq!(report.layout.glyphs[0].position, input.glyphs[0].baseline);
assert_eq!(report.state.resolved_glyphs, 1);
assert!(report.unsatisfied_constraints.is_empty());
assert!(report.warnings.is_empty());
}
#[test]
fn the_stub_solver_publishes_every_primitive_unowned() {
// (m2) The stub resolves no per-system geometry (every region becomes
// one degenerate default-rect `ResolvedSystem`, `resolve` above), so
// it must not coerce an unattributed primitive onto system 0 — real,
// non-trivial counts, via the real `to_logical`/`to_constrained`
// pipeline over a rich generated score (RS-2's own construction).
use crate::{to_constrained, to_logical};
use epiphany_core::generators::valid_score_rich;
let score = valid_score_rich(0xF302);
let mut input = to_constrained(&to_logical(&score));
assert_eq!(
input.regions.len(),
3,
"the rich fixture's real region count"
);
// `valid_score_rich` carries no slur, so hand-add a curve (mirrors
// `strokes_survive_the_solve_and_enter_the_canonical_bytes` below) to
// exercise all three flat arrays, not just glyphs and strokes.
input.curves.push(crate::Curve {
provenance: input.glyphs[0].provenance.clone(),
p0: crate::Point::new(0.0, 0.0),
p1: crate::Point::new(1.0, 1.0),
p2: crate::Point::new(2.0, 1.0),
p3: crate::Point::new(3.0, 0.0),
thickness: crate::StaffSpace(0.1),
layer: 0,
style: crate::GlyphStyle::default(),
line: epiphany_core::LineStyle::Solid,
vertical_band: input.glyphs[0].vertical_band,
});
let report = StubSolver.solve(&input, &SolverConfig::default());
let layout = &report.layout;
// Real counts (not `> 0`): the rich fixture's own glyph/stroke tally.
assert_eq!(layout.glyphs.len(), input.glyphs.len());
assert_eq!(layout.strokes.len(), input.strokes.len());
assert_eq!(
layout.glyphs.len(),
11,
"the rich fixture's real glyph count"
);
assert_eq!(
layout.strokes.len(),
38,
"the rich fixture's real stroke count"
);
assert_eq!(layout.curves.len(), 1);
// Every system's own bucket is empty — the stub attributes nothing.
let system_count = layout.systems().count();
assert_eq!(system_count, input.regions.len());
for system in layout.systems() {
assert!(system.primitives.glyphs.is_empty());
assert!(system.primitives.strokes.is_empty());
assert!(system.primitives.curves.is_empty());
}
// Everything unowned: the exact index range, in order.
assert_eq!(
layout.unowned.glyphs,
(0..layout.glyphs.len() as u32).collect::<Vec<_>>()
);
assert_eq!(
layout.unowned.strokes,
(0..layout.strokes.len() as u32).collect::<Vec<_>>()
);
assert_eq!(layout.unowned.curves, vec![0]);
}
#[test]
fn strokes_survive_the_solve_and_enter_the_canonical_bytes() {
let mut input = constrained(vec![glyph("noteheadBlack")]);
let baseline = StubSolver
.solve(&input, &SolverConfig::default())
.layout
.canonical_bytes();
input.strokes.push(crate::Stroke {
provenance: input.glyphs[0].provenance.clone(),
vertical_band: input.glyphs[0].vertical_band,
from: crate::Point::new(0.0, 0.0),
to: crate::Point::new(1.5, 0.0),
thickness: crate::StaffSpace(0.13),
layer: 0,
style: crate::GlyphStyle::default(),
});
let solved = StubSolver.solve(&input, &SolverConfig::default());
assert_eq!(
solved.layout.strokes.len(),
1,
"the stroke survives the solve"
);
assert_ne!(
solved.layout.canonical_bytes(),
baseline,
"a stroke changes the resolved canonical bytes"
);
}
#[test]
fn forged_catalog_metadata_is_rejected() {
let mut input = constrained(vec![glyph("noteheadBlack")]);
input.catalog.font_id = crate::glyph::FontId::owned("Not Bravura");
let report = StubSolver.solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::InternalError);
assert!(!report.satisfied_hard_constraints);
let mut input = constrained(vec![glyph("noteheadBlack")]);
input.catalog.smufl_version.minor_centi += 1;
assert_eq!(
StubSolver.solve(&input, &SolverConfig::default()).status,
SolveStatus::InternalError
);
let mut input = constrained(vec![glyph("noteheadBlack")]);
input.catalog.font_version = None;
assert_eq!(
StubSolver.solve(&input, &SolverConfig::default()).status,
SolveStatus::InternalError
);
}
#[test]
fn dangling_band_and_non_finite_geometry_are_rejected() {
let mut dangling = constrained(vec![glyph("noteheadBlack")]);
dangling.vertical_bands.clear();
assert_eq!(
StubSolver.solve(&dangling, &SolverConfig::default()).status,
SolveStatus::InternalError
);
let mut non_finite = constrained(vec![glyph("noteheadBlack")]);
non_finite.glyphs[0].baseline = Point::new(f32::NAN, 0.0);
let report = StubSolver.solve(&non_finite, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::InternalError);
assert!(report.layout.glyphs.is_empty());
}
#[test]
fn explicit_constraints_are_not_falsely_reported_satisfied() {
let mut input = constrained(vec![glyph("noteheadBlack")]);
let glyph = input.glyphs[0].id();
input
.constraints
.push(crate::LayoutConstraint::NoCollision { a: glyph, b: glyph });
let report = StubSolver.solve(&input, &SolverConfig::default());
// Unevaluated constraints are not a defect in the input, so the solve is
// renderable — but satisfaction is not claimed, and a warning names the gap.
assert_eq!(report.status, SolveStatus::SolvedWithWarnings);
assert!(report.status.is_renderable());
assert!(!report.satisfied_hard_constraints);
assert_eq!(report.warnings.len(), 1);
assert!(report.unsatisfied_constraints.is_empty());
// The geometry still passes through verbatim.
assert_eq!(report.layout.glyphs[0].position, input.glyphs[0].baseline);
}
#[test]
fn incremental_is_observationally_equivalent_to_full() {
let input = constrained(vec![glyph("noteheadBlack"), glyph("gClef")]);
let full = StubSolver.solve(&input, &SolverConfig::default());
let inc = StubSolver.solve_incremental(
&input,
&full.state,
&InvalidationSet {
scope: InvalidationScope::WholeScore,
slots: vec![],
bands: vec![],
constraints: vec![],
glyphs: vec![],
},
&SolverConfig::default(),
);
assert_eq!(full.layout, inc.layout);
}
#[test]
fn normalized_metric_accepts_its_range() {
assert_eq!(NormalizedMetric::new(0.0), NormalizedMetric(0.0));
assert_eq!(NormalizedMetric::new(1.0), NormalizedMetric(1.0));
}
#[test]
#[should_panic(expected = "[0.0, 1.0]")]
fn normalized_metric_rejects_out_of_range() {
let _ = NormalizedMetric::new(1.5);
}
}