Agent I-2: Engraver evaluates hard constraints, earns the Minimal tier

The Engraver now evaluates the IR's declared hard constraints against the
resolved geometry and reports honestly, so it reports SolverTier::Minimal --
"hard constraints satisfied, no claim about optimality" (Chapter 9) -- instead
of the interface-only Stub.

  - evaluate_constraints checks each LayoutConstraint against the resolved glyph
    boxes: NoCollision (boxes do not overlap), Align (equal baseline y for the
    Horizontal axis, equal x for Vertical), PositionWithin (box inside the
    region). A hard SystemBreakAt/PageBreakAt is reported unsatisfied -- a
    single-system, single-page Minimal solve casts off nothing (a soft break
    imposes no obligation); an unverifiable Registered extension constraint is
    conservatively not claimed satisfied.
  - Honest report status: a malformed input (invalid structure or forged/unknown
    catalog) is InternalError; a valid problem whose hard constraints cannot all
    be satisfied is Unsatisfiable, naming the offenders in unsatisfied_constraints;
    all satisfied is Solved. satisfied_hard_constraints and
    budget_used.constraint_evaluations reflect real work (0 when evaluation is
    skipped on a malformed input).
  - Minimal makes no normalized-metric claim, so the metric vector stays the
    conservative all-worst "no claim" placeholder (the Quality Metric Catalog is
    Phase 3 / Standard). Still deferred to a later tier: the vertical spring pass
    (glyph y is the constrained natural staff layout, preserved verbatim) and
    casting-off.

Tests pin the tier, an empty (vacuously satisfied) constraint set, satisfied vs
violated NoCollision, hard-vs-soft breaks, and the skipped-evaluation count.
Full gate green: build, fmt, clippy, 578 tests, conformance scale 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-26 21:11:25 -04:00
parent 91c9f01bb5
commit 1c147534bf
1 changed files with 333 additions and 58 deletions

View File

@ -9,27 +9,26 @@
//! crates so the core/product boundary stays sharp (`spec/PHASE2_QUICKSTART.md`,
//! crate topology).
//!
//! ## Phase status — honest scaffold, not yet `Minimal`
//! ## Phase status — `Minimal` tier
//!
//! Per the QUICKSTART's recommended development pattern, Agent I develops the
//! renderer ([`epiphany-render-svg`]) against the **stub solver** first, then
//! grows this crate into the real two-pass spring solver. This is the first
//! increment: [`Engraver`] runs a genuine deterministic **horizontal spacing
//! pass** (see [`spacing`]) — the first axis of the planned two-pass spring
//! layout — placing each glyph-bearing slot left-to-right by a collision-aware
//! advance (its preferred width floored by the real glyph bearings) instead of
//! returning the input columns verbatim.
//! [`Engraver`] runs a genuine deterministic **horizontal spacing pass** (see
//! [`spacing`]) — placing each glyph-bearing slot left-to-right by a
//! collision-aware advance (its preferred width floored by the real glyph
//! bearings) — and **evaluates the IR's declared hard constraints** against the
//! resolved geometry (no-collision, alignment, position-within; a hard break or
//! unverifiable extension constraint it cannot honour is reported unsatisfied).
//! A solve is [`SolveStatus::Solved`] only when every hard constraint is
//! satisfied; otherwise it is a diagnostic layout naming the ones it could not.
//!
//! It does **not yet** run the vertical spring pass, the soft-constraint
//! stretch/compress solve, or evaluate the IR's declared hard constraints. By the
//! same honesty rule `layout-ir`'s [`StubSolver`] follows, a solver that does not
//! evaluate the declared constraints and computes no quality metrics MUST report
//! [`SolverTier::Stub`], never [`SolverTier::Minimal`] (Chapter 9 §"Conformance
//! Tiers"). So [`Engraver::tier`] reports `Stub` today; it is promoted to
//! `Minimal` in the same change that lands real constraint satisfaction. The
//! quality-metric vector is the conservative all-worst placeholder
//! ([`QualityMetricVector::unmeasured`]) until the Quality Metric Catalog lands
//! (Phase 3).
//! Having earned it, [`Engraver::tier`] reports [`SolverTier::Minimal`] — which
//! (Chapter 9 §"Conformance Tiers" / QUICKSTART) means *hard constraints
//! satisfied, no claim about optimality*. It therefore makes **no
//! normalized-metric claim**: the quality-metric vector stays the conservative
//! all-worst "no claim" placeholder ([`QualityMetricVector::unmeasured`]) until
//! the Quality Metric Catalog lands (Phase 3 / `Standard`). Still deferred to a
//! later tier: the **vertical spring pass** (glyph `y` is the constrained natural
//! staff layout, preserved verbatim) and **casting-off** (a single system, so it
//! cannot honour a forced system/page break).
//!
//! ## Architecture decision (see `DECISIONS.md`)
//!
@ -42,16 +41,19 @@
mod spacing;
use std::collections::BTreeMap;
use epiphany_layout_ir::{
all_available, BravuraCatalog, ConstrainedLayoutIR, ConstraintSolver, GlyphCatalog,
InvalidationSet, Margins, Point, QualityMetricVector, Rect, ResolvedGlyph, ResolvedLayoutIR,
ResolvedPage, ResolvedSystem, Size2D, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig,
SolverState, SolverTier, SolverVersion, SolverWarning, SolverWarningKind, Stroke,
all_available, Axis, BravuraCatalog, BreakKind, ConstrainedLayoutIR, ConstraintId,
ConstraintSolver, GlyphCatalog, GlyphObjectId, InvalidationSet, LayoutConstraint, Margins,
Point, QualityMetricVector, Rect, ResolvedGlyph, ResolvedLayoutIR, ResolvedPage,
ResolvedSystem, Size2D, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig, SolverState,
SolverTier, SolverVersion, SolverWarning, SolverWarningKind, Stroke,
};
/// The Epiphany engraving solver (Chapter 9). See the crate docs for the phase
/// status: this is the horizontal-spacing scaffold of the planned two-pass
/// spring layout, reporting [`SolverTier::Stub`] until it earns `Minimal`.
/// The Epiphany engraving solver (Chapter 9). A `Minimal`-tier solver: it spaces
/// glyphs horizontally and satisfies the IR's declared hard constraints. See the
/// crate docs for what each tier claims and what remains deferred.
#[derive(Copy, Clone, Debug, Default)]
pub struct Engraver;
@ -61,11 +63,12 @@ pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(1);
impl Engraver {
/// Resolves geometry: a deterministic horizontal spacing pass over the spring
/// slots, copying each glyph to its slot's `x` with its baseline `y`
/// preserved. Well-formedness is gated exactly as the stub's is — an unknown
/// glyph, a forged catalog identity, malformed structure, or an explicit
/// constraint this scaffold cannot yet evaluate yields
/// [`SolveStatus::InternalError`] (a diagnostic-only layout), never a panic.
/// slots (each glyph to its slot's `x`, baseline `y` preserved), then
/// evaluation of the declared hard constraints. A malformed input — an unknown
/// glyph, a forged catalog identity, or invalid structure — yields
/// [`SolveStatus::InternalError`]; a valid problem whose hard constraints
/// cannot all be satisfied yields [`SolveStatus::Unsatisfiable`] (naming the
/// unsatisfied constraints). Both are diagnostic-only; neither panics.
fn resolve(&self, input: &ConstrainedLayoutIR) -> SolveReport {
let structural_valid = input.validate().is_ok();
@ -75,11 +78,6 @@ impl Engraver {
let metrics_available = all_available(names.iter().copied());
let catalog_valid = metrics_available && input.catalog == BravuraCatalog.identity(&names);
// This scaffold does not yet evaluate explicit hard constraints, so — like
// the stub — it must not claim them satisfied. An input carrying explicit
// constraints is reported as not-yet-solvable rather than falsely Solved.
let well_formed = structural_valid && catalog_valid && input.constraints.is_empty();
// The horizontal spacing pass re-places each glyph by its spring slot.
// The strokes that track those glyphs (stems, staff lines, barlines) must
// ride the *same* horizontal map, or a re-spaced notehead would leave its
@ -94,16 +92,41 @@ impl Engraver {
};
let resolved_glyphs = glyphs.len();
// Evaluate every declared hard constraint against the *resolved* geometry.
// A Minimal solve is `Solved` only when all are satisfied. A structurally
// invalid or bad-catalog input is not evaluated — there is no trustworthy
// geometry — so it reports no evaluation work.
let (constraints_satisfied, unsatisfied_constraints, constraints_evaluated) =
if structural_valid && catalog_valid {
let (satisfied, unsatisfied) = evaluate_constraints(&input.constraints, &glyphs);
(satisfied, unsatisfied, input.constraints.len() as u64)
} else {
(false, Vec::new(), 0)
};
let well_formed = structural_valid && catalog_valid && constraints_satisfied;
// Distinguish a *malformed/unusable* input (InternalError — a structural or
// catalog defect the solver cannot proceed past) from a *valid problem
// whose declared hard constraints cannot all be satisfied* (Unsatisfiable),
// per the solver-report contract (Chapter 9 §"The Solver Report").
let status = if !structural_valid || !catalog_valid {
SolveStatus::InternalError
} else if constraints_satisfied {
SolveStatus::Solved
} else {
SolveStatus::Unsatisfiable
};
let mut warnings = Vec::new();
if structural_valid && catalog_valid && !input.constraints.is_empty() {
if structural_valid && catalog_valid && !unsatisfied_constraints.is_empty() {
warnings.push(SolverWarning {
kind: SolverWarningKind::UnusualLayoutDecision(
"explicit hard-constraint evaluation is not implemented in the \
horizontal-spacing scaffold; it lands with the Minimal tier"
"one or more declared hard constraints are not satisfiable by this \
single-system Minimal solve (e.g. a forced break, or an unverifiable \
extension constraint); see unsatisfied_constraints"
.to_owned(),
),
affected_objects: Vec::new(),
message: "engrave scaffold cannot yet evaluate declared constraints".to_owned(),
message: "declared hard constraints unsatisfied".to_owned(),
});
}
@ -131,11 +154,7 @@ impl Engraver {
.collect();
SolveReport {
status: if well_formed {
SolveStatus::Solved
} else {
SolveStatus::InternalError
},
status,
satisfied_hard_constraints: well_formed,
layout: ResolvedLayoutIR {
source: input.source,
@ -145,15 +164,18 @@ impl Engraver {
engraving_decisions: input.engraving_decisions.clone(),
catalog: input.catalog.clone(),
},
unsatisfied_constraints: Vec::new(),
unsatisfied_constraints,
warnings,
// No normalized quality metrics computed yet (Stub tier, Phase 3 work).
// Minimal makes no normalized-metric claim (Chapter 9 / QUICKSTART:
// "satisfies hard constraints but makes no normalized-metric claims";
// the Quality Metric Catalog is Phase 3), so the vector is the
// conservative all-worst "no claim" placeholder, like the stub's.
metric_vector: QualityMetricVector::unmeasured(),
budget_used: SolverBudgetUsed {
// The horizontal pass touches each slot once; report that honestly.
iterations: input.horizontal_slots.len() as u64,
nodes: resolved_glyphs as u64,
constraint_evaluations: 0,
constraint_evaluations: constraints_evaluated,
wall_time_ms: 0,
},
state: SolverState {
@ -252,12 +274,98 @@ fn interp((s0, t0): (f32, f32), (s1, t1): (f32, f32), x: f32) -> f32 {
}
}
/// Evaluates the IR's declared hard constraints against the *resolved* geometry,
/// returning whether all are satisfied and the ids of those that are not — a
/// constraint's id is its index in the IR's constraint list.
///
/// Geometric constraints (no-collision, alignment, position-within) are checked
/// against the resolved glyph boxes. A *hard* break is reported unsatisfied — a
/// single-system, single-page Minimal solve casts off nothing, so it cannot force
/// a break (a *soft* break imposes no obligation). An extension `Registered`
/// constraint this solver cannot interpret is likewise not claimed satisfied
/// (Chapter 7 §"Behavior Under Unknown Extensions": conservative).
fn evaluate_constraints(
constraints: &[LayoutConstraint],
glyphs: &[ResolvedGlyph],
) -> (bool, Vec<ConstraintId>) {
let by_id: BTreeMap<GlyphObjectId, &ResolvedGlyph> = glyphs
.iter()
.map(|g| (GlyphObjectId(g.provenance.stable_id.0), g))
.collect();
let mut unsatisfied = Vec::new();
for (index, constraint) in constraints.iter().enumerate() {
let satisfied = match constraint {
LayoutConstraint::NoCollision { a, b } => match (by_id.get(a), by_id.get(b)) {
(Some(a), Some(b)) => !overlaps(a, b),
// A referenced glyph was dropped (a diagnostic layout): not claimed.
_ => false,
},
LayoutConstraint::Align { a, b, axis } => match (by_id.get(a), by_id.get(b)) {
(Some(a), Some(b)) => aligned(a, b, *axis),
_ => false,
},
LayoutConstraint::PositionWithin { glyph, region } => match by_id.get(glyph) {
Some(g) => within(g, region),
None => false,
},
LayoutConstraint::SystemBreakAt { kind, .. }
| LayoutConstraint::PageBreakAt { kind, .. } => matches!(kind, BreakKind::Soft),
LayoutConstraint::Registered(_, _) => false,
};
if !satisfied {
unsatisfied.push(ConstraintId(index as u128));
}
}
(unsatisfied.is_empty(), unsatisfied)
}
/// A resolved glyph's absolute bounding box `[left, bottom, right, top]`.
fn abs_box(g: &ResolvedGlyph) -> [f32; 4] {
[
g.position.x.0 + g.bounding_box.left.0,
g.position.y.0 + g.bounding_box.bottom.0,
g.position.x.0 + g.bounding_box.right.0,
g.position.y.0 + g.bounding_box.top.0,
]
}
/// Whether two glyphs' boxes overlap (touching edges do not count).
fn overlaps(a: &ResolvedGlyph, b: &ResolvedGlyph) -> bool {
let [al, ab, ar, at] = abs_box(a);
let [bl, bb, br, bt] = abs_box(b);
ar > bl && br > al && at > bb && bt > ab
}
/// Whether two glyphs are aligned along `axis`: a common horizontal line (equal
/// baseline y) for `Horizontal`, a common vertical line (equal x) for `Vertical`.
/// (The spec leaves the axis sense to the solver; this is the chosen convention.)
fn aligned(a: &ResolvedGlyph, b: &ResolvedGlyph, axis: Axis) -> bool {
const EPS: f32 = 1e-3;
match axis {
Axis::Horizontal => (a.position.y.0 - b.position.y.0).abs() < EPS,
Axis::Vertical => (a.position.x.0 - b.position.x.0).abs() < EPS,
}
}
/// Whether a glyph's box lies within a region rectangle (inclusive, with a small
/// tolerance for quantization).
fn within(g: &ResolvedGlyph, region: &Rect) -> bool {
const EPS: f32 = 1e-3;
let [l, b, r, t] = abs_box(g);
let rl = region.origin.x.0;
let rb = region.origin.y.0;
let rr = rl + region.size.width.0;
let rt = rb + region.size.height.0;
l >= rl - EPS && b >= rb - EPS && r <= rr + EPS && t <= rt + EPS
}
impl ConstraintSolver for Engraver {
fn tier(&self) -> SolverTier {
// Honest: a solver that does not evaluate the declared hard constraints
// and computes no quality metrics is below Minimal (Chapter 9). Promoted
// to `Minimal` in the change that lands real constraint satisfaction.
SolverTier::Stub
// Minimal (Chapter 9): it evaluates and satisfies the IR's declared hard
// constraints, reporting honestly which (if any) it cannot. It makes no
// normalized-metric claim — `Minimal` means hard constraints satisfied,
// not optimal quality (the Quality Metric Catalog is Phase 3 / `Standard`).
SolverTier::Minimal
}
fn version(&self) -> SolverVersion {
@ -294,16 +402,173 @@ mod tests {
}
#[test]
fn reports_the_honest_stub_tier_until_it_earns_minimal() {
// The crate exists to *become* the Minimal solver, but until it evaluates
// the declared constraints it reports Stub — never a tier it has not
// earned. This guards the honesty invariant.
assert_eq!(Engraver.tier(), SolverTier::Stub);
assert!(Engraver.tier() < SolverTier::Minimal);
fn reports_the_minimal_tier_it_has_earned() {
// It evaluates the declared hard constraints, so it reports Minimal — above
// the interface-only stub, below the metric-claiming Standard tier.
assert_eq!(Engraver.tier(), SolverTier::Minimal);
assert!(Engraver.tier() > StubSolver.tier());
assert!(Engraver.tier() < SolverTier::Standard);
// Minimal makes no normalized-metric claim (the catalog is Phase 3).
let report = Engraver.solve(&fixture(), &SolverConfig::default());
assert_eq!(report.metric_vector, QualityMetricVector::unmeasured());
assert_eq!(Engraver.version(), ENGRAVER_VERSION);
assert_ne!(Engraver.version(), StubSolver.version());
}
/// Builds a tiny valid constrained IR — a clef and a single note — and lets
/// the caller add constraints over its glyphs.
fn with_constraints(
constraints: impl FnOnce(&ConstrainedLayoutIR) -> Vec<epiphany_layout_ir::LayoutConstraint>,
) -> ConstrainedLayoutIR {
use epiphany_core::{
CmnNominal, EventId, MusicalPosition, PitchId, PitchSpelling, RegionId, StaffId,
StaffInstanceId, TypedObjectId,
};
use epiphany_layout_ir::{
LayoutContent, LayoutObject, LayoutRegion, LocalCoordinateSystem, LogicalLayoutIR,
MetricTimeAxis, NoteContent, NotePitch, Provenance, ScoreVersion, StaffContent,
TimeAxisModel, TimePoint, VerticalExtent,
};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let pitch = PitchId::from_raw(100);
let manifested = |src, content| {
LayoutObject::from_projection_with_content(
Provenance::manifested(src, region, vec![]),
Some(staff),
content,
)
};
let logical = LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
coordinate_system: LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: VerticalExtent {
staves: vec![staff],
},
objects: vec![
manifested(
TypedObjectId::StaffInstance(StaffInstanceId::from_raw(1)),
LayoutContent::Staff(StaffContent {
clefs: vec![],
keys: vec![],
}),
),
manifested(
TypedObjectId::Event(EventId::from_raw(1)),
LayoutContent::Note(NoteContent {
position: TimePoint::Musical(MusicalPosition::origin()),
components: vec![],
pitches: vec![NotePitch {
pitch,
spelling: Some(PitchSpelling::cmn(CmnNominal::C, 5)),
}],
}),
),
manifested(TypedObjectId::Pitch(pitch), LayoutContent::Structural),
],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let mut c = to_constrained(&logical);
c.constraints = constraints(&c);
c
}
#[test]
fn an_empty_constraint_set_is_vacuously_satisfied() {
let report = Engraver.solve(&fixture(), &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved);
assert!(report.satisfied_hard_constraints);
assert!(report.unsatisfied_constraints.is_empty());
assert_eq!(
report.budget_used.constraint_evaluations,
fixture().constraints.len() as u64
);
}
#[test]
fn a_satisfied_no_collision_constraint_solves() {
use epiphany_layout_ir::LayoutConstraint;
// The clef and the notehead are in different columns, so they do not
// collide; a NoCollision over them is satisfied.
let input = with_constraints(|c| {
let clef = c
.glyphs
.iter()
.find(|g| g.glyph.as_str() == "gClef")
.unwrap()
.id();
let head = c
.glyphs
.iter()
.find(|g| g.glyph.as_str().starts_with("notehead"))
.unwrap()
.id();
vec![LayoutConstraint::NoCollision { a: clef, b: head }]
});
let report = Engraver.solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings);
assert!(report.satisfied_hard_constraints);
assert!(report.unsatisfied_constraints.is_empty());
assert_eq!(report.budget_used.constraint_evaluations, 1);
}
#[test]
fn a_violated_no_collision_is_reported_not_falsely_solved() {
use epiphany_layout_ir::LayoutConstraint;
// A glyph trivially collides with itself; NoCollision(g, g) is unsatisfiable
// and must be reported, not silently accepted.
let input = with_constraints(|c| {
let g = c
.glyphs
.iter()
.find(|g| g.glyph.as_str().starts_with("notehead"))
.unwrap()
.id();
vec![LayoutConstraint::NoCollision { a: g, b: g }]
});
let report = Engraver.solve(&input, &SolverConfig::default());
// A valid problem whose hard constraint cannot be met is Unsatisfiable,
// not an InternalError (which is reserved for solver/structure failures).
assert_eq!(report.status, SolveStatus::Unsatisfiable);
assert!(!report.satisfied_hard_constraints);
assert_eq!(report.unsatisfied_constraints.len(), 1);
assert_eq!(report.budget_used.constraint_evaluations, 1);
}
#[test]
fn a_hard_break_cannot_be_honoured_by_single_system_minimal() {
use epiphany_layout_ir::{BreakKind, LayoutConstraint};
let input = with_constraints(|c| {
let slot = c.horizontal_slots[0].id;
vec![LayoutConstraint::SystemBreakAt {
slot,
kind: BreakKind::Hard,
}]
});
let report = Engraver.solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Unsatisfiable);
assert_eq!(report.unsatisfied_constraints.len(), 1);
// …but a *soft* break imposes no obligation, so it solves.
let soft = with_constraints(|c| {
let slot = c.horizontal_slots[0].id;
vec![LayoutConstraint::SystemBreakAt {
slot,
kind: BreakKind::Soft,
}]
});
assert_eq!(
Engraver.solve(&soft, &SolverConfig::default()).status,
SolveStatus::Solved
);
}
#[test]
fn solves_the_stub_pipeline_and_preserves_provenance() {
let input = fixture();
@ -336,6 +601,12 @@ mod tests {
layer: 0,
style: epiphany_layout_ir::GlyphStyle::default(),
});
// Declare a constraint too: a malformed input is *not* evaluated, so the
// report must claim zero constraint evaluations (not work it never did).
let g = input.glyphs[0].id();
input
.constraints
.push(epiphany_layout_ir::LayoutConstraint::NoCollision { a: g, b: g });
assert!(
input.validate().is_err(),
"the out-of-range stroke is invalid"
@ -347,6 +618,10 @@ mod tests {
report.layout.strokes.is_empty(),
"a structurally invalid input emits no strokes (gated like glyphs)"
);
assert_eq!(
report.budget_used.constraint_evaluations, 0,
"no constraints were evaluated on a malformed input"
);
}
#[test]