Land epiphany-layout-ir (Agent E): layout IR + solver interface
Implements Agent E per spec/QUICKSTART.md — the layout intermediate
representation (Chapter 7) and the constraint-solver interface (Chapter 9):
* Four IR stages: LogicalLayoutIR -> ConstrainedLayoutIR ->
ResolvedLayoutIR -> RenderIR (interface only), with the composite-object
taxonomy, spring slots/constraints, vertical-band model, pages/systems,
engraving decisions + overrides, and the incremental dependency/cache model.
* TimeAxisModel tagged enum (Metric/Proportional/Aleatoric/Registered).
* Provenance back-references with manifestation- and synthesis-aware ids
((source, region) and (source, kind, ordinal)), so multiply-manifested and
synthesized objects never collide.
* In-tree Bravura GlyphCatalog (Send+Sync, metrics + render-data interface),
MUSCFNTM-tagged metrics hash with anchors hashed as a name-keyed map.
* Edit-barrier types keyed on OperationKindTag, with precise EditContext /
EditOracle scope/condition evaluation.
* StubSolver: returns SolveStatus::Solved with the input geometry verbatim;
spec-compliant SolveReport, Minimal tier + all-worst (unmeasured) metric
vector (no false conformance claim); rejects ill-formed input.
* f32 staff-space IR coordinates, quantized to the 1/1024 grid only at
canonical ResolvedLayoutIR serialization (Appendix D); non-finite geometry
is rejected, not normalized. Canonical encoding is injective in glyph
identity, provenance, engraving decisions, and catalog identity.
Re-points Agent F's testkit layout harness from its in-tree stub to the real
crate (v0 acceptance criterion 6) and expands its generators to E's public
surface. Expands Agent C's OperationKindTag to the full normative variant set
so edit barriers can prohibit every operation class.
Workspace gates green: fmt, clippy -D warnings, 377 tests, doc tests, rustdoc
-D warnings, and the conformance suite at scale 1. Decisions and Pass 11
candidates recorded in crates/epiphany-layout-ir/DECISIONS.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a2e9ec32f6
commit
3d1c55d73e
|
|
@ -93,6 +93,15 @@ dependencies = [
|
|||
"blake3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "epiphany-layout-ir"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"epiphany-core",
|
||||
"epiphany-determinism",
|
||||
"epiphany-ops",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "epiphany-ops"
|
||||
version = "0.0.0"
|
||||
|
|
@ -109,6 +118,7 @@ dependencies = [
|
|||
"epiphany-bundle",
|
||||
"epiphany-core",
|
||||
"epiphany-determinism",
|
||||
"epiphany-layout-ir",
|
||||
"epiphany-ops",
|
||||
]
|
||||
|
||||
|
|
|
|||
23
Cargo.toml
23
Cargo.toml
|
|
@ -5,17 +5,19 @@ members = [
|
|||
"crates/epiphany-core",
|
||||
"crates/epiphany-bundle",
|
||||
"crates/epiphany-ops",
|
||||
"crates/epiphany-layout-ir",
|
||||
"crates/epiphany-testkit",
|
||||
]
|
||||
|
||||
# Each remaining crate (epiphany-layout-ir) is added to `members` by its owning
|
||||
# agent when it lands. Agent A shipped epiphany-determinism first; Agent B's
|
||||
# epiphany-core and Agent D's epiphany-bundle build on it (in parallel — the
|
||||
# bundle depends on A only, not on B or C). Agent C's epiphany-ops builds on A
|
||||
# and B. Agent F's epiphany-testkit is cross-cutting: it drives the real
|
||||
# shipped A/B/C/D and carries a faithful in-tree stub only for the not-yet-landed
|
||||
# E (epiphany-layout-ir), which its layout-round-trip harness re-points at when
|
||||
# that crate lands. See spec/QUICKSTART.md for the dependency order.
|
||||
# Agent A shipped epiphany-determinism first; Agent B's epiphany-core and Agent
|
||||
# D's epiphany-bundle build on it (in parallel — the bundle depends on A only,
|
||||
# not on B or C). Agent C's epiphany-ops builds on A and B. Agent E's
|
||||
# epiphany-layout-ir (Chapter 7 + the Chapter 9 solver interface) lands last
|
||||
# among the implementation crates, on A and B (plus C's `OperationKindTag` for
|
||||
# the edit-barrier types). Agent F's epiphany-testkit is cross-cutting: it
|
||||
# drives the real shipped A/B/C/D/E — its layout-round-trip harness, formerly a
|
||||
# faithful in-tree stub, now re-points at the real epiphany-layout-ir. See
|
||||
# spec/QUICKSTART.md for the dependency order.
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
|
|
@ -38,6 +40,11 @@ epiphany-bundle = { path = "crates/epiphany-bundle" }
|
|||
# testkit drives its convergence, reduction-determinism, and equivocation gates,
|
||||
# so its path is declared here alongside the other intra-workspace crates.
|
||||
epiphany-ops = { path = "crates/epiphany-ops" }
|
||||
# Agent E's epiphany-layout-ir is the layout IR + constraint-solver interface
|
||||
# (Chapters 7 & 9). Agent F's testkit drives its layout round-trip gate
|
||||
# (v0 acceptance criterion 6), so its path is declared here alongside the other
|
||||
# intra-workspace crates.
|
||||
epiphany-layout-ir = { path = "crates/epiphany-layout-ir" }
|
||||
# Event-arena storage backend (QUICKSTART decision 2): slotmap gives
|
||||
# generation-checked stale-handle detection, matching the spec's
|
||||
# identifier-stability requirement (Chapter 5).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "epiphany-layout-ir"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
description = "The Epiphany layout intermediate representation (spec Chapter 7) and constraint-solver interface (Chapter 9, interface only): the four IR stages, the TimeAxisModel tagged enum, provenance back-references, engraving-decision records, the vertical-band model, the in-tree Bravura glyph catalog identity, edit barriers keyed on OperationKindTag, and the v0 stub solver that returns the input geometry verbatim."
|
||||
|
||||
[dependencies]
|
||||
epiphany-determinism.workspace = true
|
||||
epiphany-core.workspace = true
|
||||
# Agent E depends on A and B for the IR pipeline (QUICKSTART). The edit-barrier
|
||||
# types (Chapter 8 §"Forward Compatibility and Edit Barriers") store
|
||||
# `prohibited_operation_kinds: Vec<OperationKindTag>`, and `OperationKindTag` is
|
||||
# Agent C's canonical discriminator type — reproducing it here would let it
|
||||
# drift from the real one, so the barrier types reference C's type directly.
|
||||
# This is the only use of epiphany-ops; see DECISIONS.md.
|
||||
epiphany-ops.workspace = true
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
# epiphany-layout-ir — decisions and Pass 11 candidates
|
||||
|
||||
This file records (a) the implementation decisions the QUICKSTART asked each
|
||||
agent to make once and document, and (b) the ambiguities discovered while
|
||||
building `epiphany-layout-ir`, batched as **Pass 11 candidates** for the spec
|
||||
rather than improvised in code (QUICKSTART, Process notes: *"Ambiguities go into
|
||||
a batch, not into code … Don't open Pass 11 until you have at least three such
|
||||
items batched."*).
|
||||
|
||||
## Scope
|
||||
|
||||
The crate implements the Chapter 7 interface surface: all four stages, the
|
||||
logical composite taxonomy, overrides and cross-region objects, time-axis
|
||||
payloads and trait, spring slots and constraints, vertical bands, resolved
|
||||
pages/systems, render configuration, glyph-catalog identity, and incremental
|
||||
cache/dependency types. Per the QUICKSTART's prototype framing, the algorithms
|
||||
behind those interfaces remain simple; the constraint solver still returns
|
||||
validated input geometry verbatim and performs no production engraving,
|
||||
casting-off, quality optimization, or rendering.
|
||||
|
||||
The round-trip's strict stage-equality assertion (the full `Provenance` of every
|
||||
object is preserved object-for-object) reflects this prototype's **1:1**
|
||||
projection — one layout object per laid-out score-graph object. A later stage
|
||||
that flattens a composite object into multiple glyphs would relax that assertion
|
||||
to source-coverage (every glyph's source is a laid-out object, every laid-out
|
||||
object is covered); the provenance-preservation contract itself is unchanged.
|
||||
|
||||
## Implementation decisions (QUICKSTART "Decisions you'll need to make")
|
||||
|
||||
1. **Replica ID entropy / 2. event-arena storage / 3. chunk store** — N/A to
|
||||
this crate (Agents B and D).
|
||||
4. **Async or sync — sync only.** No async traits anywhere; `#![forbid(unsafe_code)]`.
|
||||
5. **MSRV — workspace 1.77.** No exotic features.
|
||||
|
||||
### Local decisions
|
||||
|
||||
- **f32 IR coordinates, quantized at serialization.** IR coordinates are
|
||||
single-precision staff spaces (`StaffSpace(f32)`, Chapter 7 §7.2: "Single-
|
||||
precision floating point MUST be used for IR coordinates"). Quantization to the
|
||||
canonical `1/1024` grid happens **only when serializing** canonical
|
||||
`ResolvedLayoutIR` output — exactly as Appendix D §"Quantized Layout
|
||||
Coordinates" prescribes: "Internal solvers MAY use floating point during
|
||||
computation; canonical serialization rounds to `QuantizedCoord`."
|
||||
[`ResolvedLayoutIR::canonical_bytes`] is that boundary; it round-trips f32
|
||||
jitter below `1/2048` staff space to identical bytes. (An earlier draft of this
|
||||
crate quantized *throughout* the pipeline; that contradicted the explicit
|
||||
Chapter 7 f32 requirement and is corrected here.)
|
||||
|
||||
- **Depend on `epiphany-ops` for `OperationKindTag`.** The QUICKSTART lists
|
||||
Agent E's dependencies as "A and B," but also assigns Agent E the *edit-barrier
|
||||
types with `OperationKindTag`-based `prohibited_operation_kinds`*.
|
||||
`OperationKindTag` is Agent C's canonical discriminator type (Chapter 6);
|
||||
reproducing it here would create a second definition that could drift. We
|
||||
therefore take a single, narrow dependency on `epiphany-ops` for that one type.
|
||||
This is sound: `epiphany-ops` does not depend on this crate (no cycle), and
|
||||
Agent E lands after Agent C. See Pass 11 candidate 1.
|
||||
|
||||
- **`ObjectKind` for edit barriers is the `TypedObjectId` discriminant.** The
|
||||
spec's `EditBarrier.affected_object_kinds: Vec<ObjectKind>` needs a
|
||||
*score-graph object class* key. The `ObjectKind` in `epiphany-ops` is a narrow
|
||||
*system-counter-collision* kind (Voice/Pitch/Registered), semantically
|
||||
unrelated, so we define a local `ObjectKind(pub u16)` over the `TypedObjectId`
|
||||
discriminant — the natural object-class key in this codebase.
|
||||
|
||||
- **Edit-barrier scopes and conditions are evaluated precisely.** A
|
||||
`Region`/`StaffInstance`/`AnalysisLayer`/`PitchSpace` barrier prohibits only
|
||||
objects within that scope; the editor (which holds the score) supplies the
|
||||
candidate object's structural location via `EditContext`. The *known*
|
||||
conditions `ObjectExists` and `ObjectHasExtensionData` are evaluated via an
|
||||
`EditOracle` the editor implements, **not** hardcoded to `true`. Only genuinely
|
||||
unknown narrowing — a `Registered` scope or an unknown `Registered` condition —
|
||||
is treated conservatively (as matching), per Chapter 8 §"Behavior Under Unknown
|
||||
Extensions". This avoids over-prohibiting edits to objects demonstrably outside
|
||||
a known scope or to objects a known condition excludes.
|
||||
|
||||
- **`stable_layout_id` and the engraving-decision id borrow a domain tag.** A
|
||||
layout object's stable id is `trunc128(BLAKE3(source.canonical_bytes()))` — a
|
||||
pure function of its source, so it is invariant under insertion/removal/
|
||||
reordering of other objects (Chapter 7 §"Provenance"). It is not domain-
|
||||
separated, and the engraving-decision id borrows the `MUSCCONF` tag with a
|
||||
literal `engraving-decision` type prefix, because the frozen determinism crate
|
||||
(Agent A) defines no layout-object domain tag. See Pass 11 candidate 3.
|
||||
|
||||
- **Repeated manifestations get per-`(source, region)` ids.** A score-graph
|
||||
object manifested within a region is laid out **per manifestation**: its stable
|
||||
id derives from `(source, region)` via `manifestation_layout_id` /
|
||||
`Provenance::manifested`. A staff manifested in two time-disjoint regions
|
||||
(Chapter 5 §"Region Overlap and Concurrency") therefore yields *two* distinct
|
||||
layout objects — both visual staves are preserved, neither dropped — and the
|
||||
ids do not collide. The id is still independent of traversal *position* (it
|
||||
depends on region *identity*, not order), so it stays stable across relayouts.
|
||||
Score-level cross-cutting objects, which have a single manifestation, keep a
|
||||
source-only id (`Provenance::projected`).
|
||||
|
||||
- **`GlyphObjectId`/`VerticalBandId` reuse the provenance hash.** A glyph's
|
||||
`GlyphObjectId` is its provenance `stable_id` (already manifestation-aware); a
|
||||
staff band's `VerticalBandId` is the staff *layout object's* manifestation id
|
||||
(`VerticalBand::staff_manifestation`), so two manifestations of a staff get two
|
||||
distinct bands. Both are stable across relayouts.
|
||||
|
||||
- **Bundled Bravura metrics are a representative slice.** `BRAVURA_METRICS` holds
|
||||
~two dozen real-Bravura glyphs (noteheads, clefs, accidentals, rests, flags,
|
||||
time signatures, barlines, dynamics) with advance, bounding box, and named
|
||||
anchors, in `1/1024`-staff-space units, tracking the `BRAVURA_VERSION` release.
|
||||
Enough to exercise the `MUSCFNTM` catalog identity and the `GlyphCatalog`
|
||||
metric-lookup interface end to end without shipping a font file; render-data
|
||||
lookup and a full catalog are out-of-core concerns (Chapter 7 §"Glyph Catalog
|
||||
Interface").
|
||||
|
||||
- **Glyph identity flows to the resolved/render stages.** `ResolvedGlyph` and
|
||||
`RenderPrimitive` carry an owned-or-borrowed `GlyphReference`, so
|
||||
the renderer knows *what symbol to draw* and the canonical encoding is
|
||||
**injective in glyph identity** — swapping two glyphs' names (even with the
|
||||
consulted-name *set*, and so the metrics hash, unchanged) changes the bytes.
|
||||
|
||||
- **Comprehensive, rejecting canonical encoding for `ResolvedLayoutIR`.** The
|
||||
canonical output (`ResolvedLayoutIR::canonical_bytes`, via `CanonicalEncode`)
|
||||
covers the *full* resolved layout — every glyph's provenance (source, stable
|
||||
id, synthesis kind, sorted/deduped dependencies), **glyph name**, and quantized
|
||||
position, every engraving decision, and the complete catalog identity — so any
|
||||
change that distinguishes two layouts (a swapped glyph, an altered engraving
|
||||
decision, a different manifestation id, a different font version) changes the
|
||||
bytes. A non-finite or out-of-range coordinate is **rejected** with a panic
|
||||
(faulting in every build), never normalized to the origin (Appendix D: invalid
|
||||
geometry is rejected).
|
||||
|
||||
- **Each glyph is routed to *its own staff's* band.** `GlyphObject.vertical_band`
|
||||
(Chapter 7 §"Glyph-Level Objects") points at the band of the staff the glyph
|
||||
belongs to — `LayoutObject` carries that staff association, so a region
|
||||
manifesting two staves gets a staff band per staff with each glyph in exactly
|
||||
one (no cross-staff contamination). Region-level glyphs (the region object,
|
||||
cross-cutting, free-graphic) go to a margin band; multi-staff regions also carry
|
||||
empty `InterStaffGap` spring bands between staves. Staff-band ids are the staff
|
||||
layout object's manifestation id (distinct per region).
|
||||
|
||||
- **Free-graphic and hybrid graphic objects are projected.** `to_logical` and
|
||||
`laid_out_object_ids` project `region.content.graphic_objects()` (Chapter 5
|
||||
§"Graphic Content"), so free-graphic and hybrid regions are not silently
|
||||
dropped.
|
||||
|
||||
- **Synthesized-object ids include kind and a stable semantic key.**
|
||||
`Provenance::synthesized(source, kind, instance_key, deps)` derives its id
|
||||
from `(source, synthesis_kind, instance_key)`. The key describes the object's
|
||||
role and never traversal order, so insertion or reordering cannot renumber
|
||||
existing synthesized objects.
|
||||
|
||||
- **Glyph-catalog interface: `Send + Sync`, metrics + render data, `SemVer`
|
||||
version, anchors as a map.** `GlyphCatalog` is `Send + Sync` (shareable across
|
||||
parallel re-engraving) with both `metrics` and `render_data`. The in-tree
|
||||
Bravura catalog bundles metrics but **no** outlines/bitmaps, so its
|
||||
`render_data` honestly returns `None` (reporting `Some` would claim data that
|
||||
does not exist). `font_version` is `Option<SemVer>`, set to the latest stable
|
||||
Bravura release (`1.38.0`). Glyph anchors are a *map* keyed by name: the catalog
|
||||
hash sorts them by name and **rejects** a duplicate name (a panic), so the hash
|
||||
never depends on anchor slice order (Appendix D §"Ordered Iteration").
|
||||
every catalog method, including `identity`, is object-safe; owned font,
|
||||
glyph, and anchor names support a runtime-loaded `dyn GlyphCatalog` without
|
||||
leaking strings.
|
||||
|
||||
- **Chapter 9 interface in full shape; no quality-metric computation.** The
|
||||
`ConstraintSolver` interface is implemented as the
|
||||
spec defines it: `Send + Sync`, `solve`/`solve_incremental`, a `SolverConfig`
|
||||
with `profile`/`budget`/`tie_breaking`, a `SolveReport` with
|
||||
`unsatisfied_constraints`/`warnings`/`metric_vector`/`budget_used`/`state` (and
|
||||
the warning kinds, including `QualityFloorApproached`/`ExtensionWarning`), and
|
||||
an `InvalidationSet` with `slots`/`bands`/`constraints`/`glyphs`. The render
|
||||
boundary's `RenderIRProducer::produce(resolved, scale, config)` takes the
|
||||
spec's `ScaleContext`/`RenderConfiguration`. The quality-metric/tie-breaking
|
||||
*types* exist; what the QUICKSTART defers is normalization computation. The
|
||||
exact non-optional interface is preserved: the stub reports `Minimal` and an
|
||||
all-worst `QualityMetricVector`, and rejects explicit constraints it cannot
|
||||
evaluate rather than claiming them satisfied.
|
||||
|
||||
## Pass 11 candidates (ambiguities for the spec, not resolved in code)
|
||||
|
||||
1. **Agent E's stated dependency set vs. the edit-barrier types.** The QUICKSTART
|
||||
says Agent E "depends on A and B," but assigns it the edit-barrier types,
|
||||
which reference Agent C's `OperationKindTag`, and the spec's `EditBarrier`
|
||||
additionally references `ObjectKind` and `ExtensionId` (Chapter 8, the
|
||||
bundle's chapter). The dependency note, the type assignment, and the type's
|
||||
chapter home are in tension; the spec should either bless a layout→ops
|
||||
dependency for the discriminator type or relocate the edit-barrier types.
|
||||
|
||||
2. **Provenance / layout-object id derivation is unspecified.** Chapter 7
|
||||
declares `LayoutObjectId(pub u128)` and requires stability across relayouts but
|
||||
specifies neither the derivation, whether it is domain-separated (Appendix D
|
||||
§"Domain-Separated Preimages" would suggest a dedicated `MUSC*` tag), how a
|
||||
multiply-manifested object (a staff in two regions) is identified — v0 keys it
|
||||
on `(source, region)` — nor how synthesized objects are keyed — v0 uses
|
||||
`(source, synthesis_kind, stable_semantic_instance_key)`. The spec should pin
|
||||
the derivation, manifestation-context key, and synthesized-object key, and
|
||||
register a layout domain tag if separation is required.
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# epiphany-layout-ir
|
||||
|
||||
The Epiphany **layout intermediate representation** and **constraint-solver
|
||||
interface**, implementing the normative requirements of **Chapter 7** ("Layout
|
||||
Intermediate Representation") and the interface of **Chapter 9**
|
||||
("Constraint-Solver Interface") of the core specification
|
||||
(`spec/core_spec.pdf`). This is Agent E's crate per `spec/QUICKSTART.md` — it
|
||||
lands last among the implementation crates, building on Agent A's
|
||||
`epiphany-determinism` and Agent B's `epiphany-core` (and on Agent C's
|
||||
`OperationKindTag` for the edit-barrier types — see `DECISIONS.md`).
|
||||
|
||||
> The IR sits between the score graph and two downstream consumers: the
|
||||
> constraint solver, which resolves spacing and positioning, and the renderer,
|
||||
> which produces final visual output. — Chapter 7
|
||||
|
||||
The score graph is the canonical truth about the music; this crate is a
|
||||
downstream **projection** of it. The transformation is a pipeline of four
|
||||
stages, each with its own type and a deterministic, provenance-preserving
|
||||
contract for the next.
|
||||
|
||||
## What's here
|
||||
|
||||
| Area | Items | Spec |
|
||||
|------|-------|------|
|
||||
| Stage 1 — logical | `LogicalLayoutIR`, `LayoutRegion`, composite `LayoutObject`, overrides/cross-region objects, `to_logical` | Ch. 7 §"LogicalLayoutIR" |
|
||||
| Stage 2 — constrained | `ConstrainedLayoutIR`, `SpringSlot`, `LayoutConstraint`, `GlyphObject`, `to_constrained` | Ch. 7 §"ConstrainedLayoutIR" |
|
||||
| Stage 3 — resolved | `ResolvedLayoutIR`, pages/systems/staves/measures, `ResolvedGlyph` | Ch. 7 §"ResolvedLayoutIR" |
|
||||
| Stage 4 — render (interface only) | `RenderIR`, `RenderPrimitive`, `RenderIRProducer`, `to_render` | Ch. 7 §"RenderIR" |
|
||||
| Time axis | canonical `TimeAxisModel` plus dynamic `TimeAxis`, including registered payload preservation | Ch. 7 §"Layout Regions" |
|
||||
| Provenance | `Provenance`, `LayoutObjectId`, `SynthesisKind`, `stable_layout_id` (a pure function of the source — stable across relayouts) | Ch. 7 §"Provenance" |
|
||||
| Engraving decisions | `EngravingDecision`/`EngravingDecisionId`/`EngravingDecisionKind`, `DecisionSource` | Ch. 7 §"Engraving Decisions" |
|
||||
| Vertical bands | `VerticalBand`/`VerticalBandId`/`VerticalBandKind` | Ch. 7 §"Vertical Bands" |
|
||||
| Incremental cache | `LayoutCache`, `DependencyIndex`, granular stage caches and invalidation | Ch. 7 §"Incremental Layout and Caching" |
|
||||
| Glyph catalog | `GlyphCatalog` (metric-lookup interface) + in-tree `BravuraCatalog`, `GlyphCatalogIdentity`, `SmuflVersion`, `FontId`, `GlyphMetric`/`GlyphAnchor`, `BRAVURA_METRICS`/`BRAVURA_VERSION`, `metrics_hash_for` (`MUSCFNTM`-tagged) | Ch. 7 §"Glyph Catalog Interface" / §7.3.2 |
|
||||
| Edit barriers | `EditBarrier`, `BarrierScope`, `BarrierCondition`, `ObjectKind`, `EditContext` (precise scope evaluation), keyed on Agent C's `OperationKindTag` | Ch. 8 §"Edit Barriers" |
|
||||
| Solver interface | `ConstraintSolver` (`solve`/`solve_incremental`, `Send + Sync`), `SolverConfig`/`SolverBudget`, `SolverState`, `InvalidationSet`, `SolveReport`, `SolveStatus`, `SolverTier`/`SolverVersion`, the v0 `StubSolver` | Ch. 9 |
|
||||
| Round-trip | `round_trip`, `RoundTripReport`, `laid_out_object_ids` | Ch. 7 (v0 acceptance criterion 6) |
|
||||
|
||||
## The stub solver
|
||||
|
||||
Per the QUICKSTART, the v0 constraint solver is a **stub**: `StubSolver` returns
|
||||
`SolveStatus::Solved` with the input geometry **verbatim** (each glyph's resolved
|
||||
position is exactly its constrained baseline), preserves provenance, and reports
|
||||
all hard constraints satisfied. The real solver — Cassowary or otherwise — comes
|
||||
later (Chapter 9 specifies the *interface*, not the algorithm); v0 only needs to
|
||||
round-trip IR through the solver interface to prove the contracts hold. The one
|
||||
stub validates the full Chapter 7 §7.3.2 catalog identity and all slot, band,
|
||||
and geometry cross-references before reporting `Solved`. Explicit constraints
|
||||
are rejected because this interface-only solver cannot honestly evaluate them.
|
||||
|
||||
## The round-trip (v0 acceptance criterion 6)
|
||||
|
||||
`round_trip` runs graph → `LogicalLayoutIR` → `ConstrainedLayoutIR` →
|
||||
stub-solved `ResolvedLayoutIR` → `RenderIR` and asserts the contract every stage
|
||||
must satisfy:
|
||||
|
||||
- the stub solver reports `Solved` with all hard constraints satisfied;
|
||||
- the **complete** `Provenance` of every object — `source`, `synthesis`,
|
||||
`dependencies`, and `stable_id` — survives every stage unchanged;
|
||||
- no two objects ever share a `stable_id`, so manifestation multiplicity is
|
||||
preserved (a source manifested in two regions stays two layout objects);
|
||||
- the stub solver returns the input geometry verbatim;
|
||||
- the *set* of score-graph sources recovered from the `RenderIR` is exactly the
|
||||
set laid out — a surjection onto graph identity (one source may back several
|
||||
manifestations, each with its own stable id).
|
||||
|
||||
Agent F's testkit drives this same entry point (`layout_stub::round_trip`) on the
|
||||
10-measure single-staff hand-off fixture and the rich multi-region generator.
|
||||
|
||||
## Algorithmic scope
|
||||
|
||||
Per the QUICKSTART ("a prototype baseline, not the product"), this crate
|
||||
implements the Chapter 7 IR contracts and interface types, not a production
|
||||
engraving engine. The real spacing and casting-off algorithms, quality-metric
|
||||
computation, constraint solver, and renderer remain later implementations of
|
||||
these interfaces.
|
||||
|
||||
## Determinism
|
||||
|
||||
IR coordinates are single-precision staff spaces (`StaffSpace(f32)`,
|
||||
Chapter 7 §7.2); the **canonical** `ResolvedLayoutIR` output quantizes them to
|
||||
the `1/1024` grid at serialization time
|
||||
(`ResolvedLayoutIR::canonical_bytes`), exactly as Appendix D §"Quantized Layout
|
||||
Coordinates" prescribes — quantization absorbs all f32 variation below `1/2048`
|
||||
staff space, so the canonical output is independent of the floating-point
|
||||
environment. The glyph-catalog identity hashes its consulted metrics (advance,
|
||||
bounding box, named anchors) under the `MUSCFNTM` domain tag, and the
|
||||
edit-barrier types carry a canonical encoding with set-valued fields emitted in
|
||||
canonical byte order (sorted and de-duplicated). See `DECISIONS.md`.
|
||||
|
||||
## Tests
|
||||
|
||||
`cargo test -p epiphany-layout-ir` covers each module plus the round-trip on
|
||||
Agent B's `valid_score` / `valid_score_rich` generators. The end-to-end v0
|
||||
acceptance gate (criterion 6) lives in the testkit.
|
||||
|
|
@ -0,0 +1,574 @@
|
|||
//! Edit barriers (Chapter 8 §"Forward Compatibility and Edit Barriers").
|
||||
//!
|
||||
//! An edit barrier protects an extension's invariants: it prohibits a *class* of
|
||||
//! operations over a scope, so it references the discriminator-only
|
||||
//! [`OperationKindTag`] (Agent C's canonical type — Chapter 6) in
|
||||
//! `prohibited_operation_kinds`, never a concrete payload (Chapter 8: "A barrier
|
||||
//! prohibits an operation class, not one exact payload"). The QUICKSTART assigns
|
||||
//! the barrier *types* to Agent E, keyed on `OperationKindTag`.
|
||||
//!
|
||||
//! An edit matching a barrier's scope, affected object kinds, prohibited
|
||||
//! operation kinds, and condition is prohibited unless the user performs an
|
||||
//! explicit unsafe edit (Chapter 8 §"Behavior Under Unknown Extensions"). v0
|
||||
//! models the barrier *types* and a conservative `prohibits_edit` predicate; the
|
||||
//! unsafe-edit mechanism and registry evaluation are bundle/extension concerns.
|
||||
//!
|
||||
//! Barriers are stored in the bundle in the spec, so the types here carry a
|
||||
//! canonical encoding (Appendix D): set-valued fields are emitted in canonical
|
||||
//! byte order, so two barriers with the same sets in different orders encode
|
||||
//! identically.
|
||||
|
||||
use epiphany_core::{AnalysisLayerId, PitchSpaceId, RegionId, StaffInstanceId, TypedObjectId};
|
||||
use epiphany_determinism::CanonicalEncode;
|
||||
use epiphany_ops::OperationKindTag;
|
||||
|
||||
/// The kind of a score-graph object a barrier protects (Chapter 8:
|
||||
/// `ObjectKind`). v0 represents it by the `TypedObjectId` discriminant — the
|
||||
/// kind of object, independent of which one — which is the natural object-class
|
||||
/// key in this codebase.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct ObjectKind(pub u16);
|
||||
|
||||
impl ObjectKind {
|
||||
/// The object kind of a concrete object.
|
||||
pub fn of(object: &TypedObjectId) -> ObjectKind {
|
||||
ObjectKind(object.discriminant())
|
||||
}
|
||||
}
|
||||
|
||||
impl CanonicalEncode for ObjectKind {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.0.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry id for an extension-defined [`BarrierScope::Registered`].
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct BarrierScopeRegistryId(pub u128);
|
||||
|
||||
/// Registry id for an extension-defined [`BarrierCondition::Registered`].
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct BarrierConditionRegistryId(pub u128);
|
||||
|
||||
/// A reference to the declaring extension of a barrier condition (the spec's
|
||||
/// `ExtensionId`). Opaque in v0 — the extension subsystem lives in the bundle.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct ExtensionRef(pub u128);
|
||||
|
||||
/// The scope of an edit barrier (Chapter 8: `BarrierScope`).
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum BarrierScope {
|
||||
WholeScore,
|
||||
Region(RegionId),
|
||||
StaffInstance(StaffInstanceId),
|
||||
AnalysisLayer(AnalysisLayerId),
|
||||
ObjectSet(Vec<TypedObjectId>),
|
||||
PitchSpace(PitchSpaceId),
|
||||
TuningContext,
|
||||
Registered(BarrierScopeRegistryId),
|
||||
}
|
||||
|
||||
/// A narrowing condition for an edit barrier (Chapter 8: `BarrierCondition`).
|
||||
/// The barrier applies only when its scope, object kinds, operation kinds, and
|
||||
/// this condition all match.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum BarrierCondition {
|
||||
/// Applies unconditionally within its scope.
|
||||
Always,
|
||||
/// Applies only while the named object exists (not tombstoned).
|
||||
ObjectExists(TypedObjectId),
|
||||
/// Applies only when the named object carries data from the named extension.
|
||||
ObjectHasExtensionData {
|
||||
object: TypedObjectId,
|
||||
extension: ExtensionRef,
|
||||
},
|
||||
/// Conjunction: all conditions must match.
|
||||
All(Vec<BarrierCondition>),
|
||||
/// Disjunction: any condition matching is sufficient.
|
||||
Any(Vec<BarrierCondition>),
|
||||
/// Negation: the inner condition must not match.
|
||||
Not(Box<BarrierCondition>),
|
||||
/// Extension-evaluated condition. Core implementations treat it as `Always`
|
||||
/// (conservative — Chapter 8).
|
||||
Registered(BarrierConditionRegistryId),
|
||||
}
|
||||
|
||||
/// Answers the score-state questions a barrier condition asks: whether an object
|
||||
/// is live (not tombstoned) and whether it carries a given extension's data. The
|
||||
/// editor — which holds the score — implements this so *known* conditions are
|
||||
/// evaluated precisely.
|
||||
pub trait EditOracle {
|
||||
/// Whether `object` currently exists (is not tombstoned).
|
||||
fn object_exists(&self, object: &TypedObjectId) -> bool;
|
||||
/// Whether `object` carries data declared by `extension`.
|
||||
fn has_extension_data(&self, object: &TypedObjectId, extension: ExtensionRef) -> bool;
|
||||
}
|
||||
|
||||
/// A trivial oracle: every object exists and carries no extension data. Useful
|
||||
/// as a default when extension data is irrelevant.
|
||||
pub struct AlwaysLiveOracle;
|
||||
|
||||
impl EditOracle for AlwaysLiveOracle {
|
||||
fn object_exists(&self, _object: &TypedObjectId) -> bool {
|
||||
true
|
||||
}
|
||||
fn has_extension_data(&self, _object: &TypedObjectId, _extension: ExtensionRef) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl BarrierCondition {
|
||||
/// Evaluates the condition against `oracle`. **Known** leaf conditions
|
||||
/// (`ObjectExists`, `ObjectHasExtensionData`) are evaluated *precisely* via
|
||||
/// the oracle; only an unknown `Registered` condition is treated
|
||||
/// conservatively (as active), so a core implementation never silently drops
|
||||
/// a barrier it cannot evaluate (Chapter 8 §"Behavior Under Unknown
|
||||
/// Extensions"). The boolean combinators are applied literally.
|
||||
pub fn is_active(&self, oracle: &dyn EditOracle) -> bool {
|
||||
!matches!(self.evaluate(oracle), ConditionEvaluation::Inactive)
|
||||
}
|
||||
|
||||
fn evaluate(&self, oracle: &dyn EditOracle) -> ConditionEvaluation {
|
||||
match self {
|
||||
BarrierCondition::Always => ConditionEvaluation::Active,
|
||||
BarrierCondition::ObjectExists(o) => {
|
||||
ConditionEvaluation::from_bool(oracle.object_exists(o))
|
||||
}
|
||||
BarrierCondition::ObjectHasExtensionData { object, extension } => {
|
||||
ConditionEvaluation::from_bool(oracle.has_extension_data(object, *extension))
|
||||
}
|
||||
BarrierCondition::All(cs) => {
|
||||
let mut unknown = false;
|
||||
for condition in cs {
|
||||
match condition.evaluate(oracle) {
|
||||
ConditionEvaluation::Inactive => return ConditionEvaluation::Inactive,
|
||||
ConditionEvaluation::Unknown => unknown = true,
|
||||
ConditionEvaluation::Active => {}
|
||||
}
|
||||
}
|
||||
if unknown {
|
||||
ConditionEvaluation::Unknown
|
||||
} else {
|
||||
ConditionEvaluation::Active
|
||||
}
|
||||
}
|
||||
BarrierCondition::Any(cs) => {
|
||||
let mut unknown = false;
|
||||
for condition in cs {
|
||||
match condition.evaluate(oracle) {
|
||||
ConditionEvaluation::Active => return ConditionEvaluation::Active,
|
||||
ConditionEvaluation::Unknown => unknown = true,
|
||||
ConditionEvaluation::Inactive => {}
|
||||
}
|
||||
}
|
||||
if unknown {
|
||||
ConditionEvaluation::Unknown
|
||||
} else {
|
||||
ConditionEvaluation::Inactive
|
||||
}
|
||||
}
|
||||
BarrierCondition::Not(condition) => match condition.evaluate(oracle) {
|
||||
ConditionEvaluation::Active => ConditionEvaluation::Inactive,
|
||||
ConditionEvaluation::Inactive => ConditionEvaluation::Active,
|
||||
ConditionEvaluation::Unknown => ConditionEvaluation::Unknown,
|
||||
},
|
||||
BarrierCondition::Registered(_) => ConditionEvaluation::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
enum ConditionEvaluation {
|
||||
Active,
|
||||
Inactive,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ConditionEvaluation {
|
||||
fn from_bool(value: bool) -> Self {
|
||||
if value {
|
||||
ConditionEvaluation::Active
|
||||
} else {
|
||||
ConditionEvaluation::Inactive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An edit barrier (Chapter 8: `EditBarrier`).
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct EditBarrier {
|
||||
pub scope: BarrierScope,
|
||||
/// Object kinds protected by this barrier.
|
||||
pub affected_object_kinds: Vec<ObjectKind>,
|
||||
/// Operation classes this barrier prohibits (Chapter 8: keyed on
|
||||
/// `OperationKindTag`, not `OperationKind`).
|
||||
pub prohibited_operation_kinds: Vec<OperationKindTag>,
|
||||
/// Additional narrowing condition.
|
||||
pub condition: BarrierCondition,
|
||||
}
|
||||
|
||||
/// The structural location of a candidate edit's object, supplied by the
|
||||
/// editor (which has the score) so that *known* barrier scopes
|
||||
/// (`Region`/`StaffInstance`/`AnalysisLayer`/`PitchSpace`) are evaluated
|
||||
/// **precisely** rather than conservatively. Each field is the object's
|
||||
/// containing entity, or `None` if it has none of that kind.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct EditContext {
|
||||
pub region: Option<RegionId>,
|
||||
pub staff_instance: Option<StaffInstanceId>,
|
||||
pub analysis_layer: Option<AnalysisLayerId>,
|
||||
pub pitch_space: Option<PitchSpaceId>,
|
||||
}
|
||||
|
||||
impl EditBarrier {
|
||||
/// Whether this barrier prohibits applying an operation of class `op` to
|
||||
/// `object`, given the object's structural location `ctx`.
|
||||
///
|
||||
/// Known scopes are evaluated **precisely** against `ctx`: a `Region`
|
||||
/// barrier prohibits only objects in that region, etc. Only genuinely
|
||||
/// unknown narrowing — a `Registered` scope, an unknown `Registered`
|
||||
/// condition — is treated conservatively (as matching), so a core
|
||||
/// implementation never silently drops a barrier it cannot fully evaluate
|
||||
/// (Chapter 8 §"Behavior Under Unknown Extensions"). An empty
|
||||
/// `affected_object_kinds` matches any kind.
|
||||
pub fn prohibits_edit(
|
||||
&self,
|
||||
op: OperationKindTag,
|
||||
object: &TypedObjectId,
|
||||
ctx: &EditContext,
|
||||
oracle: &dyn EditOracle,
|
||||
) -> bool {
|
||||
self.prohibited_operation_kinds.contains(&op)
|
||||
&& (self.affected_object_kinds.is_empty()
|
||||
|| self.affected_object_kinds.contains(&ObjectKind::of(object)))
|
||||
&& self.scope_admits(object, ctx)
|
||||
&& self.condition.is_active(oracle)
|
||||
}
|
||||
|
||||
/// Whether the barrier's scope admits `object`. `WholeScore`/`TuningContext`
|
||||
/// are score-wide; `ObjectSet` and the structural scopes are checked
|
||||
/// precisely against `ctx`; only a `Registered` (unknown-extension) scope is
|
||||
/// treated conservatively as admitting.
|
||||
fn scope_admits(&self, object: &TypedObjectId, ctx: &EditContext) -> bool {
|
||||
match &self.scope {
|
||||
BarrierScope::WholeScore | BarrierScope::TuningContext => true,
|
||||
BarrierScope::ObjectSet(objs) => objs.contains(object),
|
||||
BarrierScope::Region(r) => ctx.region.as_ref() == Some(r),
|
||||
BarrierScope::StaffInstance(si) => ctx.staff_instance.as_ref() == Some(si),
|
||||
BarrierScope::AnalysisLayer(a) => ctx.analysis_layer.as_ref() == Some(a),
|
||||
BarrierScope::PitchSpace(ps) => ctx.pitch_space.as_ref() == Some(ps),
|
||||
BarrierScope::Registered(_) => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Canonical encoding (Appendix D): set-valued fields in canonical order ---
|
||||
|
||||
fn push_u64(out: &mut Vec<u8>, v: u64) {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Length-prefixes one element's canonical bytes (self-delimiting).
|
||||
fn push_elem<T: CanonicalEncode>(out: &mut Vec<u8>, item: &T) {
|
||||
let bytes = item.to_canonical_bytes();
|
||||
push_u64(out, bytes.len() as u64);
|
||||
out.extend_from_slice(&bytes);
|
||||
}
|
||||
|
||||
/// Emits a set-valued field in canonical byte order with **duplicates removed**,
|
||||
/// so element order and repetition do not affect the encoding — `[A]` and
|
||||
/// `[A, A]` serialize identically (Appendix D §"Ordered Iteration over Sets and
|
||||
/// Maps").
|
||||
fn push_set<T: CanonicalEncode>(out: &mut Vec<u8>, items: &[T]) {
|
||||
let mut encoded: Vec<Vec<u8>> = items.iter().map(|i| i.to_canonical_bytes()).collect();
|
||||
encoded.sort();
|
||||
encoded.dedup();
|
||||
push_u64(out, encoded.len() as u64);
|
||||
for bytes in encoded {
|
||||
push_u64(out, bytes.len() as u64);
|
||||
out.extend_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits an order-significant list (the structure of a condition tree).
|
||||
fn push_list<T: CanonicalEncode>(out: &mut Vec<u8>, items: &[T]) {
|
||||
push_u64(out, items.len() as u64);
|
||||
for item in items {
|
||||
push_elem(out, item);
|
||||
}
|
||||
}
|
||||
|
||||
impl CanonicalEncode for BarrierScopeRegistryId {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.0.to_le_bytes());
|
||||
}
|
||||
}
|
||||
impl CanonicalEncode for BarrierConditionRegistryId {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.0.to_le_bytes());
|
||||
}
|
||||
}
|
||||
impl CanonicalEncode for ExtensionRef {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.0.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
impl CanonicalEncode for BarrierScope {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
match self {
|
||||
BarrierScope::WholeScore => out.push(0),
|
||||
BarrierScope::Region(id) => {
|
||||
out.push(1);
|
||||
id.encode_canonical(out);
|
||||
}
|
||||
BarrierScope::StaffInstance(id) => {
|
||||
out.push(2);
|
||||
id.encode_canonical(out);
|
||||
}
|
||||
BarrierScope::AnalysisLayer(id) => {
|
||||
out.push(3);
|
||||
id.encode_canonical(out);
|
||||
}
|
||||
BarrierScope::ObjectSet(objs) => {
|
||||
out.push(4);
|
||||
push_set(out, objs);
|
||||
}
|
||||
BarrierScope::PitchSpace(id) => {
|
||||
out.push(5);
|
||||
// `PitchSpaceId` is a text catalog id (NFC-normalized at
|
||||
// construction, Appendix D §"Text and Unicode"); encode its
|
||||
// normalized string, length-prefixed.
|
||||
let s = id.to_string();
|
||||
push_u64(out, s.len() as u64);
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
}
|
||||
BarrierScope::TuningContext => out.push(6),
|
||||
BarrierScope::Registered(id) => {
|
||||
out.push(7);
|
||||
id.encode_canonical(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanonicalEncode for BarrierCondition {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
match self {
|
||||
BarrierCondition::Always => out.push(0),
|
||||
BarrierCondition::ObjectExists(obj) => {
|
||||
out.push(1);
|
||||
obj.encode_canonical(out);
|
||||
}
|
||||
BarrierCondition::ObjectHasExtensionData { object, extension } => {
|
||||
out.push(2);
|
||||
object.encode_canonical(out);
|
||||
extension.encode_canonical(out);
|
||||
}
|
||||
BarrierCondition::All(cs) => {
|
||||
out.push(3);
|
||||
push_list(out, cs);
|
||||
}
|
||||
BarrierCondition::Any(cs) => {
|
||||
out.push(4);
|
||||
push_list(out, cs);
|
||||
}
|
||||
BarrierCondition::Not(c) => {
|
||||
out.push(5);
|
||||
push_elem(out, c.as_ref());
|
||||
}
|
||||
BarrierCondition::Registered(id) => {
|
||||
out.push(6);
|
||||
id.encode_canonical(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanonicalEncode for EditBarrier {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
self.scope.encode_canonical(out);
|
||||
push_set(out, &self.affected_object_kinds);
|
||||
push_set(out, &self.prohibited_operation_kinds);
|
||||
self.condition.encode_canonical(out);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use epiphany_core::{EventId, RegionId};
|
||||
|
||||
fn ev(raw: u128) -> TypedObjectId {
|
||||
TypedObjectId::Event(EventId::from_raw(raw))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prohibits_matching_op_within_object_set() {
|
||||
let target = ev(1);
|
||||
let ctx = EditContext::default();
|
||||
let barrier = EditBarrier {
|
||||
scope: BarrierScope::ObjectSet(vec![target]),
|
||||
affected_object_kinds: vec![ObjectKind::of(&target)],
|
||||
prohibited_operation_kinds: vec![OperationKindTag::DeleteEvent],
|
||||
condition: BarrierCondition::Always,
|
||||
};
|
||||
let oracle = AlwaysLiveOracle;
|
||||
// Matching op + object inside the scope set is prohibited.
|
||||
assert!(barrier.prohibits_edit(OperationKindTag::DeleteEvent, &target, &ctx, &oracle));
|
||||
// A non-prohibited op is allowed.
|
||||
assert!(!barrier.prohibits_edit(OperationKindTag::InsertEvent, &target, &ctx, &oracle));
|
||||
// An object outside the scope set is allowed.
|
||||
assert!(!barrier.prohibits_edit(OperationKindTag::DeleteEvent, &ev(2), &ctx, &oracle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_exists_condition_is_evaluated_via_the_oracle() {
|
||||
let target = ev(5);
|
||||
let barrier = EditBarrier {
|
||||
scope: BarrierScope::WholeScore,
|
||||
affected_object_kinds: vec![],
|
||||
prohibited_operation_kinds: vec![OperationKindTag::DeleteEvent],
|
||||
condition: BarrierCondition::ObjectExists(target),
|
||||
};
|
||||
let ctx = EditContext::default();
|
||||
// When the oracle says the object is live, the barrier is active.
|
||||
assert!(barrier.prohibits_edit(
|
||||
OperationKindTag::DeleteEvent,
|
||||
&target,
|
||||
&ctx,
|
||||
&AlwaysLiveOracle
|
||||
));
|
||||
// When it says the object is gone, the barrier does not fire.
|
||||
struct Dead;
|
||||
impl EditOracle for Dead {
|
||||
fn object_exists(&self, _o: &TypedObjectId) -> bool {
|
||||
false
|
||||
}
|
||||
fn has_extension_data(&self, _o: &TypedObjectId, _e: ExtensionRef) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
assert!(!barrier.prohibits_edit(OperationKindTag::DeleteEvent, &target, &ctx, &Dead));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_scope_is_evaluated_precisely() {
|
||||
let target = ev(1);
|
||||
let region = RegionId::from_raw(7);
|
||||
let barrier = EditBarrier {
|
||||
scope: BarrierScope::Region(region),
|
||||
affected_object_kinds: vec![],
|
||||
prohibited_operation_kinds: vec![OperationKindTag::DeleteEvent],
|
||||
condition: BarrierCondition::Always,
|
||||
};
|
||||
// An object inside the barrier's region is prohibited...
|
||||
let inside = EditContext {
|
||||
region: Some(region),
|
||||
..EditContext::default()
|
||||
};
|
||||
assert!(barrier.prohibits_edit(
|
||||
OperationKindTag::DeleteEvent,
|
||||
&target,
|
||||
&inside,
|
||||
&AlwaysLiveOracle
|
||||
));
|
||||
// ...but an object in a different region is NOT (no over-prohibition).
|
||||
let outside = EditContext {
|
||||
region: Some(RegionId::from_raw(8)),
|
||||
..EditContext::default()
|
||||
};
|
||||
assert!(!barrier.prohibits_edit(
|
||||
OperationKindTag::DeleteEvent,
|
||||
&target,
|
||||
&outside,
|
||||
&AlwaysLiveOracle
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_object_kinds_matches_any_kind() {
|
||||
let barrier = EditBarrier {
|
||||
scope: BarrierScope::WholeScore,
|
||||
affected_object_kinds: vec![],
|
||||
prohibited_operation_kinds: vec![OperationKindTag::RespellPitch],
|
||||
condition: BarrierCondition::Always,
|
||||
};
|
||||
assert!(barrier.prohibits_edit(
|
||||
OperationKindTag::RespellPitch,
|
||||
&ev(9),
|
||||
&EditContext::default(),
|
||||
&AlwaysLiveOracle
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn condition_not_always_blocks() {
|
||||
let target = ev(3);
|
||||
let barrier = EditBarrier {
|
||||
scope: BarrierScope::WholeScore,
|
||||
affected_object_kinds: vec![],
|
||||
prohibited_operation_kinds: vec![OperationKindTag::DeleteEvent],
|
||||
condition: BarrierCondition::Not(Box::new(BarrierCondition::Always)),
|
||||
};
|
||||
// Not(Always) is inactive, so nothing is prohibited.
|
||||
assert!(!barrier.prohibits_edit(
|
||||
OperationKindTag::DeleteEvent,
|
||||
&target,
|
||||
&EditContext::default(),
|
||||
&AlwaysLiveOracle
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_registered_condition_stays_conservative_through_negation() {
|
||||
let condition = BarrierCondition::Not(Box::new(BarrierCondition::Registered(
|
||||
BarrierConditionRegistryId(7),
|
||||
)));
|
||||
assert!(condition.is_active(&AlwaysLiveOracle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_encoding_dedups_repeated_kinds() {
|
||||
let one = EditBarrier {
|
||||
scope: BarrierScope::WholeScore,
|
||||
affected_object_kinds: vec![ObjectKind(0)],
|
||||
prohibited_operation_kinds: vec![OperationKindTag::DeleteEvent],
|
||||
condition: BarrierCondition::Always,
|
||||
};
|
||||
let repeated = EditBarrier {
|
||||
prohibited_operation_kinds: vec![
|
||||
OperationKindTag::DeleteEvent,
|
||||
OperationKindTag::DeleteEvent,
|
||||
],
|
||||
..one.clone()
|
||||
};
|
||||
assert_eq!(one.to_canonical_bytes(), repeated.to_canonical_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_encoding_is_set_order_independent() {
|
||||
let mk = |kinds: Vec<OperationKindTag>| EditBarrier {
|
||||
scope: BarrierScope::WholeScore,
|
||||
affected_object_kinds: vec![ObjectKind(2), ObjectKind(0), ObjectKind(1)],
|
||||
prohibited_operation_kinds: kinds,
|
||||
condition: BarrierCondition::Always,
|
||||
};
|
||||
let a = mk(vec![
|
||||
OperationKindTag::DeleteEvent,
|
||||
OperationKindTag::InsertEvent,
|
||||
]);
|
||||
let b = mk(vec![
|
||||
OperationKindTag::InsertEvent,
|
||||
OperationKindTag::DeleteEvent,
|
||||
]);
|
||||
assert_eq!(a.to_canonical_bytes(), b.to_canonical_bytes());
|
||||
|
||||
// A different scope must change the encoding.
|
||||
let c = EditBarrier {
|
||||
scope: BarrierScope::TuningContext,
|
||||
..a.clone()
|
||||
};
|
||||
assert_ne!(a.to_canonical_bytes(), c.to_canonical_bytes());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
//! Incremental-layout dependency index and stage cache (Chapter 7).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use epiphany_core::{RegionId, TypedObjectId};
|
||||
|
||||
use crate::{LayoutObjectId, Provenance};
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct SystemId(pub u128);
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct LogicalRegionCache {
|
||||
pub objects: BTreeSet<LayoutObjectId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct ConstrainedRegionCache {
|
||||
pub objects: BTreeSet<LayoutObjectId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct ResolvedSystemCache {
|
||||
pub objects: BTreeSet<LayoutObjectId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct FineLayoutCache {
|
||||
pub objects: BTreeSet<LayoutObjectId>,
|
||||
}
|
||||
|
||||
/// Bidirectional score-object/layout-object dependency index.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct DependencyIndex {
|
||||
pub forward: BTreeMap<TypedObjectId, BTreeSet<LayoutObjectId>>,
|
||||
pub reverse: BTreeMap<LayoutObjectId, BTreeSet<TypedObjectId>>,
|
||||
}
|
||||
|
||||
impl DependencyIndex {
|
||||
pub fn insert(&mut self, provenance: &Provenance) {
|
||||
self.remove(provenance.stable_id);
|
||||
let dependencies: BTreeSet<_> = std::iter::once(provenance.source)
|
||||
.chain(provenance.dependencies.iter().copied())
|
||||
.collect();
|
||||
for dependency in &dependencies {
|
||||
self.forward
|
||||
.entry(*dependency)
|
||||
.or_default()
|
||||
.insert(provenance.stable_id);
|
||||
}
|
||||
self.reverse.insert(provenance.stable_id, dependencies);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, object: LayoutObjectId) {
|
||||
let Some(dependencies) = self.reverse.remove(&object) else {
|
||||
return;
|
||||
};
|
||||
for dependency in dependencies {
|
||||
if let Some(objects) = self.forward.get_mut(&dependency) {
|
||||
objects.remove(&object);
|
||||
if objects.is_empty() {
|
||||
self.forward.remove(&dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn affected_by(
|
||||
&self,
|
||||
changed: impl IntoIterator<Item = TypedObjectId>,
|
||||
) -> BTreeSet<LayoutObjectId> {
|
||||
changed
|
||||
.into_iter()
|
||||
.filter_map(|object| self.forward.get(&object))
|
||||
.flat_map(|objects| objects.iter().copied())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached stage partitions with fine-grained invalidation.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct LayoutCache {
|
||||
pub dependencies: DependencyIndex,
|
||||
pub logical: BTreeMap<RegionId, LogicalRegionCache>,
|
||||
pub constrained: BTreeMap<RegionId, ConstrainedRegionCache>,
|
||||
pub resolved: BTreeMap<SystemId, ResolvedSystemCache>,
|
||||
pub fine_cache: FineLayoutCache,
|
||||
}
|
||||
|
||||
impl LayoutCache {
|
||||
/// Invalidates every indexed layout object depending on `changed` and
|
||||
/// removes it from all stage partitions.
|
||||
pub fn invalidate(
|
||||
&mut self,
|
||||
changed: impl IntoIterator<Item = TypedObjectId>,
|
||||
) -> BTreeSet<LayoutObjectId> {
|
||||
let invalidated = self.dependencies.affected_by(changed);
|
||||
for object in &invalidated {
|
||||
self.dependencies.remove(*object);
|
||||
self.fine_cache.objects.remove(object);
|
||||
for cache in self.logical.values_mut() {
|
||||
cache.objects.remove(object);
|
||||
}
|
||||
for cache in self.constrained.values_mut() {
|
||||
cache.objects.remove(object);
|
||||
}
|
||||
for cache in self.resolved.values_mut() {
|
||||
cache.objects.remove(object);
|
||||
}
|
||||
}
|
||||
invalidated
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use epiphany_core::{EventId, StaffId};
|
||||
|
||||
#[test]
|
||||
fn dependency_index_invalidates_source_and_additional_dependencies() {
|
||||
let source = TypedObjectId::Event(EventId::from_raw(1));
|
||||
let dependency = TypedObjectId::Staff(StaffId::from_raw(2));
|
||||
let provenance = Provenance::projected(source, vec![dependency]);
|
||||
let mut cache = LayoutCache::default();
|
||||
cache.dependencies.insert(&provenance);
|
||||
cache.fine_cache.objects.insert(provenance.stable_id);
|
||||
|
||||
let invalidated = cache.invalidate([dependency]);
|
||||
assert_eq!(invalidated, BTreeSet::from([provenance.stable_id]));
|
||||
assert!(cache.fine_cache.objects.is_empty());
|
||||
assert!(cache.dependencies.reverse.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,601 @@
|
|||
//! Stage 2 — `ConstrainedLayoutIR` (Chapter 7 §"ConstrainedLayoutIR").
|
||||
//!
|
||||
//! The output of the spacing pass: the logical IR with composite objects
|
||||
//! flattened to individual glyphs, each glyph carrying the anchor geometry that
|
||||
//! is the constraint solver's input. v0 lays glyphs out left-to-right on the
|
||||
//! canonical `1/1024` grid, assigns each region's glyphs to a vertical band
|
||||
//! (Chapter 7 §"Vertical Bands"), carries the engraving decisions forward, and
|
||||
//! stamps the catalog with a metrics hash over exactly the glyphs it references
|
||||
//! (Chapter 7 §7.3.2), and emits the spring-slot and constraint interfaces the
|
||||
//! solver consumes. The geometry here is what the stub solver returns verbatim.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use epiphany_core::{StaffId, TypedObjectId, WallClockTime};
|
||||
|
||||
use crate::engraving::EngravingDecision;
|
||||
use crate::glyph::{
|
||||
metrics, BravuraCatalog, GlyphCatalog, GlyphCatalogIdentity, GlyphReference, BRAVURA_METRICS,
|
||||
};
|
||||
use crate::logical::{LogicalLayoutIR, ScoreVersion};
|
||||
use crate::provenance::{manifestation_layout_id, LayoutObjectId, Provenance};
|
||||
use crate::solver::SpringSlotId;
|
||||
use crate::spatial::{BoundingBox, Point, Rect, StaffSpace};
|
||||
use crate::time_axis::TimePoint;
|
||||
use crate::vertical_band::{inter_staff_gap_id, VerticalBand, VerticalBandId};
|
||||
|
||||
/// A stable identifier for a glyph-level object (Chapter 7: `GlyphObjectId`).
|
||||
/// Shares the glyph's provenance `stable_id`, so it is stable across relayouts.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct GlyphObjectId(pub u128);
|
||||
|
||||
/// A glyph with a baseline anchor, the input to the solver (Chapter 7
|
||||
/// §"Glyph-Level Objects"). v0 references the glyph by SMuFL name and queries
|
||||
/// its metrics from the in-tree catalog ([`crate::glyph`]); the `baseline` is
|
||||
/// the staff-space geometry the stub solver returns verbatim.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct GlyphObject {
|
||||
pub provenance: Provenance,
|
||||
/// The SMuFL glyph whose metrics the solver consults.
|
||||
pub glyph: GlyphReference,
|
||||
/// Horizontal spring slot containing this glyph.
|
||||
pub horizontal_slot: SpringSlotId,
|
||||
pub baseline: Point,
|
||||
/// The vertical band this glyph belongs to (Chapter 7 §"Glyph-Level
|
||||
/// Objects": every glyph names exactly one `vertical_band`).
|
||||
pub vertical_band: VerticalBandId,
|
||||
pub bounding_box: BoundingBox,
|
||||
pub anchor: Point,
|
||||
pub layer: i32,
|
||||
pub style: GlyphStyle,
|
||||
}
|
||||
|
||||
impl GlyphObject {
|
||||
/// This glyph's stable id (Chapter 7: `GlyphObjectId`).
|
||||
pub fn id(&self) -> GlyphObjectId {
|
||||
GlyphObjectId(self.provenance.stable_id.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The constrained IR: composite objects flattened to glyphs, with the vertical
|
||||
/// bands and engraving decisions that the solver consumes alongside them
|
||||
/// (Chapter 7 §"Constraints").
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct ConstrainedLayoutIR {
|
||||
pub source: ScoreVersion,
|
||||
pub regions: Vec<ConstrainedLayoutRegion>,
|
||||
pub horizontal_slots: Vec<SpringSlot>,
|
||||
pub glyphs: Vec<GlyphObject>,
|
||||
pub vertical_bands: Vec<VerticalBand>,
|
||||
pub constraints: Vec<LayoutConstraint>,
|
||||
pub engraving_decisions: Vec<EngravingDecision>,
|
||||
pub catalog: GlyphCatalogIdentity,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct GlyphStyle {
|
||||
/// RGBA color in `0xRRGGBBAA` form.
|
||||
pub rgba: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct ConstrainedLayoutRegion {
|
||||
pub provenance: Provenance,
|
||||
pub glyphs: Vec<GlyphObjectId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct SpringSlot {
|
||||
pub id: SpringSlotId,
|
||||
pub time: TimePoint,
|
||||
pub min_width: StaffSpace,
|
||||
pub preferred_width: StaffSpace,
|
||||
pub max_width: Option<StaffSpace>,
|
||||
pub stretch_factor: f32,
|
||||
pub compress_factor: f32,
|
||||
pub members: Vec<GlyphObjectId>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum Axis {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum BreakKind {
|
||||
Hard,
|
||||
Soft,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct ConstraintRegistryId(pub u128);
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct ConstraintParameters(pub Vec<u8>);
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum LayoutConstraint {
|
||||
NoCollision {
|
||||
a: GlyphObjectId,
|
||||
b: GlyphObjectId,
|
||||
},
|
||||
Align {
|
||||
a: GlyphObjectId,
|
||||
b: GlyphObjectId,
|
||||
axis: Axis,
|
||||
},
|
||||
PositionWithin {
|
||||
glyph: GlyphObjectId,
|
||||
region: Rect,
|
||||
},
|
||||
SystemBreakAt {
|
||||
slot: SpringSlotId,
|
||||
kind: BreakKind,
|
||||
},
|
||||
PageBreakAt {
|
||||
slot: SpringSlotId,
|
||||
kind: BreakKind,
|
||||
},
|
||||
Registered(ConstraintRegistryId, ConstraintParameters),
|
||||
}
|
||||
|
||||
/// A structural defect in [`ConstrainedLayoutIR`] that prevents a solver from
|
||||
/// treating the input as a valid constraint problem.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum ConstrainedValidationError {
|
||||
DuplicateGlyphId(GlyphObjectId),
|
||||
DuplicateBandId(VerticalBandId),
|
||||
UnknownBand(VerticalBandId),
|
||||
UnknownBandMember(GlyphObjectId),
|
||||
DuplicateBandMember(GlyphObjectId),
|
||||
BandMismatch(GlyphObjectId),
|
||||
InvalidGeometry(GlyphObjectId),
|
||||
InvalidBandGeometry(VerticalBandId),
|
||||
DuplicateSlotId(SpringSlotId),
|
||||
UnknownSlot(SpringSlotId),
|
||||
UnknownSlotMember(GlyphObjectId),
|
||||
DuplicateSlotMember(GlyphObjectId),
|
||||
SlotMismatch(GlyphObjectId),
|
||||
InvalidSlotGeometry(SpringSlotId),
|
||||
InvalidGlyphBounds(GlyphObjectId),
|
||||
}
|
||||
|
||||
/// A malformed logical-stage value that cannot be transformed without losing
|
||||
/// content.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum LayoutTransformError {
|
||||
RegionSourceIsNotRegion(LayoutObjectId),
|
||||
CrossRegionObjectHasNoRegion(LayoutObjectId),
|
||||
}
|
||||
|
||||
impl ConstrainedLayoutIR {
|
||||
/// Validates the cross-reference and finite-geometry invariants consumed by
|
||||
/// every constraint solver. Invalid public values are rejected before a
|
||||
/// solver can report `Solved` or emit non-canonical geometry.
|
||||
pub fn validate(&self) -> Result<(), ConstrainedValidationError> {
|
||||
let mut glyphs_by_id = BTreeMap::new();
|
||||
for glyph in &self.glyphs {
|
||||
let id = glyph.id();
|
||||
if glyphs_by_id.insert(id, glyph).is_some() {
|
||||
return Err(ConstrainedValidationError::DuplicateGlyphId(id));
|
||||
}
|
||||
let bounds = glyph.bounding_box;
|
||||
let valid_bounds = [bounds.left.0, bounds.bottom.0, bounds.right.0, bounds.top.0]
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
&& bounds.left.0 <= bounds.right.0
|
||||
&& bounds.bottom.0 <= bounds.top.0;
|
||||
if !valid_bounds {
|
||||
return Err(ConstrainedValidationError::InvalidGlyphBounds(id));
|
||||
}
|
||||
if glyph.baseline.quantize().is_none() || glyph.anchor.quantize().is_none() {
|
||||
return Err(ConstrainedValidationError::InvalidGeometry(id));
|
||||
}
|
||||
}
|
||||
|
||||
let mut slot_ids = BTreeSet::new();
|
||||
let mut slot_memberships = BTreeMap::new();
|
||||
for slot in &self.horizontal_slots {
|
||||
if !slot_ids.insert(slot.id) {
|
||||
return Err(ConstrainedValidationError::DuplicateSlotId(slot.id));
|
||||
}
|
||||
let min = slot.min_width.0;
|
||||
let preferred = slot.preferred_width.0;
|
||||
let max_valid = match slot.max_width {
|
||||
Some(maximum) => maximum.0.is_finite() && maximum.0 >= preferred,
|
||||
None => true,
|
||||
};
|
||||
if !min.is_finite()
|
||||
|| !preferred.is_finite()
|
||||
|| min < 0.0
|
||||
|| preferred < min
|
||||
|| !max_valid
|
||||
|| !slot.stretch_factor.is_finite()
|
||||
|| !slot.compress_factor.is_finite()
|
||||
|| slot.stretch_factor < 0.0
|
||||
|| slot.compress_factor < 0.0
|
||||
{
|
||||
return Err(ConstrainedValidationError::InvalidSlotGeometry(slot.id));
|
||||
}
|
||||
for member in &slot.members {
|
||||
let Some(glyph) = glyphs_by_id.get(member) else {
|
||||
return Err(ConstrainedValidationError::UnknownSlotMember(*member));
|
||||
};
|
||||
if slot_memberships.insert(*member, slot.id).is_some() {
|
||||
return Err(ConstrainedValidationError::DuplicateSlotMember(*member));
|
||||
}
|
||||
if glyph.horizontal_slot != slot.id {
|
||||
return Err(ConstrainedValidationError::SlotMismatch(*member));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut band_ids = BTreeSet::new();
|
||||
let mut memberships = BTreeMap::new();
|
||||
for band in &self.vertical_bands {
|
||||
if !band_ids.insert(band.id) {
|
||||
return Err(ConstrainedValidationError::DuplicateBandId(band.id));
|
||||
}
|
||||
let min = band.min_height.0;
|
||||
let preferred = band.preferred_height.0;
|
||||
let max = band.max_height.map(|height| height.0);
|
||||
let valid_heights = min.is_finite()
|
||||
&& preferred.is_finite()
|
||||
&& min >= 0.0
|
||||
&& preferred >= min
|
||||
&& match max {
|
||||
Some(maximum) => maximum.is_finite() && maximum >= preferred,
|
||||
None => true,
|
||||
};
|
||||
if !valid_heights
|
||||
|| !band.stretch_factor.is_finite()
|
||||
|| !band.compress_factor.is_finite()
|
||||
|| band.stretch_factor < 0.0
|
||||
|| band.compress_factor < 0.0
|
||||
{
|
||||
return Err(ConstrainedValidationError::InvalidBandGeometry(band.id));
|
||||
}
|
||||
for member in &band.members {
|
||||
let Some(glyph) = glyphs_by_id.get(member) else {
|
||||
return Err(ConstrainedValidationError::UnknownBandMember(*member));
|
||||
};
|
||||
if memberships.insert(*member, band.id).is_some() {
|
||||
return Err(ConstrainedValidationError::DuplicateBandMember(*member));
|
||||
}
|
||||
if glyph.vertical_band != band.id {
|
||||
return Err(ConstrainedValidationError::BandMismatch(*member));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for glyph in &self.glyphs {
|
||||
if !slot_ids.contains(&glyph.horizontal_slot) {
|
||||
return Err(ConstrainedValidationError::UnknownSlot(
|
||||
glyph.horizontal_slot,
|
||||
));
|
||||
}
|
||||
if slot_memberships.get(&glyph.id()) != Some(&glyph.horizontal_slot) {
|
||||
return Err(ConstrainedValidationError::SlotMismatch(glyph.id()));
|
||||
}
|
||||
if !band_ids.contains(&glyph.vertical_band) {
|
||||
return Err(ConstrainedValidationError::UnknownBand(glyph.vertical_band));
|
||||
}
|
||||
if memberships.get(&glyph.id()) != Some(&glyph.vertical_band) {
|
||||
return Err(ConstrainedValidationError::BandMismatch(glyph.id()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks a bundled SMuFL glyph for a source, deterministically (a pure function
|
||||
/// of the source kind, so it never depends on traversal position).
|
||||
pub(crate) fn glyph_name_for(source: &TypedObjectId) -> GlyphReference {
|
||||
GlyphReference::borrowed(
|
||||
BRAVURA_METRICS[(source.discriminant() as usize) % BRAVURA_METRICS.len()]
|
||||
.name
|
||||
.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Flattens [`LogicalLayoutIR`] into [`ConstrainedLayoutIR`]: one glyph per
|
||||
/// layout object (including the region object itself), each laid out
|
||||
/// left-to-right on the `1/1024` grid, with provenance preserved
|
||||
/// object-for-object.
|
||||
///
|
||||
/// **Each glyph is routed to the band of its own staff** (Chapter 7 §"Vertical
|
||||
/// Bands"): a region manifesting two staves gets a staff band per staff, with
|
||||
/// each glyph a member of exactly its staff's band — never every staff's band.
|
||||
/// Region-level glyphs (the region object, cross-cutting, free-graphic) go to a
|
||||
/// margin band. Multi-staff regions also carry empty `InterStaffGap` spring
|
||||
/// bands between consecutive staves. Staff-band ids are the staff *layout
|
||||
/// object's* manifestation id, so a staff manifested in two regions gets two
|
||||
/// distinct bands.
|
||||
pub fn to_constrained(logical: &LogicalLayoutIR) -> ConstrainedLayoutIR {
|
||||
try_to_constrained(logical).expect("LogicalLayoutIR is malformed")
|
||||
}
|
||||
|
||||
/// Fallible form of [`to_constrained`] for callers accepting externally built
|
||||
/// logical IR. It rejects malformed provenance rather than silently dropping a
|
||||
/// region or spanning object.
|
||||
pub fn try_to_constrained(
|
||||
logical: &LogicalLayoutIR,
|
||||
) -> Result<ConstrainedLayoutIR, LayoutTransformError> {
|
||||
let mut glyphs = Vec::new();
|
||||
let mut vertical_bands = Vec::new();
|
||||
let mut horizontal_slots = Vec::new();
|
||||
let mut constrained_regions = Vec::new();
|
||||
let mut column: i64 = 0;
|
||||
|
||||
for region in &logical.regions {
|
||||
let region_id = match region.provenance.source {
|
||||
TypedObjectId::Region(id) => id,
|
||||
_ => {
|
||||
return Err(LayoutTransformError::RegionSourceIsNotRegion(
|
||||
region.provenance.stable_id,
|
||||
))
|
||||
}
|
||||
};
|
||||
let region_layout_id = region.provenance.stable_id;
|
||||
let band_of = |staff: Option<StaffId>| -> VerticalBandId {
|
||||
match staff {
|
||||
Some(s) => {
|
||||
VerticalBandId(manifestation_layout_id(&TypedObjectId::Staff(s), region_id).0)
|
||||
}
|
||||
None => VerticalBandId(region_layout_id.0),
|
||||
}
|
||||
};
|
||||
|
||||
// (provenance, owning staff) for the region object, then its contents.
|
||||
let mut specs: Vec<(&Provenance, Option<StaffId>)> =
|
||||
std::iter::once((®ion.provenance, None))
|
||||
.chain(region.objects.iter().map(|o| (o.provenance(), o.staff())))
|
||||
.collect();
|
||||
specs.extend(
|
||||
logical
|
||||
.cross_region
|
||||
.iter()
|
||||
.filter(|object| object.regions.first() == Some(®ion_id))
|
||||
.map(|object| (&object.provenance, object.staff)),
|
||||
);
|
||||
|
||||
// Distinct staves in first-appearance order, and members per band.
|
||||
let mut staves_in_order: Vec<StaffId> = Vec::new();
|
||||
let mut staff_members: BTreeMap<StaffId, Vec<GlyphObjectId>> = BTreeMap::new();
|
||||
let mut margin_members: Vec<GlyphObjectId> = Vec::new();
|
||||
let mut region_glyphs = Vec::new();
|
||||
|
||||
for (provenance, staff) in specs {
|
||||
let band = band_of(staff);
|
||||
let glyph = make_glyph(provenance, column, band);
|
||||
column += 1;
|
||||
let gid = glyph.id();
|
||||
horizontal_slots.push(SpringSlot {
|
||||
id: glyph.horizontal_slot,
|
||||
time: TimePoint::WallClock(WallClockTime(column - 1)),
|
||||
min_width: StaffSpace(1.0),
|
||||
preferred_width: StaffSpace(1.5),
|
||||
max_width: None,
|
||||
stretch_factor: 1.0,
|
||||
compress_factor: 1.0,
|
||||
members: vec![gid],
|
||||
});
|
||||
region_glyphs.push(gid);
|
||||
match staff {
|
||||
Some(s) => {
|
||||
if !staves_in_order.contains(&s) {
|
||||
staves_in_order.push(s);
|
||||
}
|
||||
staff_members.entry(s).or_default().push(gid);
|
||||
}
|
||||
None => margin_members.push(gid),
|
||||
}
|
||||
glyphs.push(glyph);
|
||||
}
|
||||
|
||||
// A staff band per manifested staff, in first-appearance order.
|
||||
for staff in &staves_in_order {
|
||||
let layout_id = manifestation_layout_id(&TypedObjectId::Staff(*staff), region_id);
|
||||
let members = staff_members.remove(staff).unwrap_or_default();
|
||||
vertical_bands.push(VerticalBand::staff_manifestation(
|
||||
layout_id, *staff, members,
|
||||
));
|
||||
}
|
||||
// An (empty) inter-staff gap band between each pair of adjacent staves.
|
||||
for gap in 1..staves_in_order.len() {
|
||||
let gap_id = inter_staff_gap_id(region_layout_id, gap);
|
||||
vertical_bands.push(VerticalBand::inter_staff_gap(gap_id));
|
||||
}
|
||||
// A margin band for region-level glyphs, if any.
|
||||
if !margin_members.is_empty() {
|
||||
vertical_bands.push(VerticalBand::margin(region_layout_id, margin_members));
|
||||
}
|
||||
constrained_regions.push(ConstrainedLayoutRegion {
|
||||
provenance: region.provenance.clone(),
|
||||
glyphs: region_glyphs,
|
||||
});
|
||||
}
|
||||
|
||||
let names: Vec<&str> = glyphs.iter().map(|glyph| glyph.glyph.as_str()).collect();
|
||||
let catalog = BravuraCatalog.identity(&names);
|
||||
if let Some(object) = logical
|
||||
.cross_region
|
||||
.iter()
|
||||
.find(|object| object.regions.is_empty())
|
||||
{
|
||||
return Err(LayoutTransformError::CrossRegionObjectHasNoRegion(
|
||||
object.provenance.stable_id,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ConstrainedLayoutIR {
|
||||
source: logical.source,
|
||||
regions: constrained_regions,
|
||||
horizontal_slots,
|
||||
glyphs,
|
||||
vertical_bands,
|
||||
constraints: Vec::new(),
|
||||
engraving_decisions: logical.engraving_decisions.clone(),
|
||||
catalog,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a glyph for a provenance at horizontal `column`, baseline one staff
|
||||
/// space apart per column (Chapter 7 §7.2 staff-space coordinates), in `band`.
|
||||
fn make_glyph(provenance: &Provenance, column: i64, band: VerticalBandId) -> GlyphObject {
|
||||
let glyph = glyph_name_for(&provenance.source);
|
||||
GlyphObject {
|
||||
bounding_box: metrics(glyph.as_str())
|
||||
.expect("pipeline glyph names are bundled")
|
||||
.bounding_box(),
|
||||
glyph,
|
||||
horizontal_slot: SpringSlotId(provenance.stable_id.0),
|
||||
baseline: Point::new(column as f32, 0.0),
|
||||
vertical_band: band,
|
||||
anchor: Point::ORIGIN,
|
||||
layer: 0,
|
||||
style: GlyphStyle { rgba: 0x0000_00ff },
|
||||
provenance: provenance.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::logical::to_logical;
|
||||
use epiphany_core::generators::valid_score_rich;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Band membership is a correct partition: every glyph names an existing
|
||||
/// band, no glyph is a member of two bands, and a glyph's `vertical_band`
|
||||
/// equals the band that lists it — so a glyph is never placed in another
|
||||
/// staff's band.
|
||||
#[test]
|
||||
fn glyphs_are_routed_to_exactly_their_band() {
|
||||
for seed in 0..48u64 {
|
||||
let c = to_constrained(&to_logical(&valid_score_rich(seed)));
|
||||
let band_ids: BTreeSet<_> = c.vertical_bands.iter().map(|b| b.id).collect();
|
||||
|
||||
let mut member_band: BTreeMap<GlyphObjectId, VerticalBandId> = BTreeMap::new();
|
||||
for b in &c.vertical_bands {
|
||||
for m in &b.members {
|
||||
assert!(
|
||||
member_band.insert(*m, b.id).is_none(),
|
||||
"a glyph is a member of two bands"
|
||||
);
|
||||
}
|
||||
}
|
||||
for g in &c.glyphs {
|
||||
assert!(
|
||||
band_ids.contains(&g.vertical_band),
|
||||
"glyph names an unknown band"
|
||||
);
|
||||
assert_eq!(
|
||||
member_band.get(&g.id()),
|
||||
Some(&g.vertical_band),
|
||||
"glyph is not a member of the band it names"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-staff region yields a staff band per staff (no cross-staff
|
||||
/// contamination) plus an inter-staff gap band; each staff's glyphs land in
|
||||
/// that staff's band only.
|
||||
#[test]
|
||||
fn multi_staff_region_routes_per_staff_with_a_gap_band() {
|
||||
use crate::logical::{LayoutObject, LayoutRegion, LogicalLayoutIR};
|
||||
use crate::provenance::Provenance;
|
||||
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
|
||||
use crate::vertical_band::VerticalBandKind;
|
||||
use epiphany_core::{EventId, RegionId, StaffId};
|
||||
|
||||
let region = RegionId::from_raw(1);
|
||||
let region_src = TypedObjectId::Region(region);
|
||||
let staff_a = StaffId::from_raw(10);
|
||||
let staff_b = StaffId::from_raw(20);
|
||||
let manifested = |src: TypedObjectId, staff: StaffId| {
|
||||
LayoutObject::from_projection(Provenance::manifested(src, region, vec![]), Some(staff))
|
||||
};
|
||||
let logical = LogicalLayoutIR {
|
||||
source: ScoreVersion::default(),
|
||||
regions: vec![LayoutRegion {
|
||||
provenance: Provenance::projected(region_src, vec![]),
|
||||
coordinate_system: crate::LocalCoordinateSystem::default(),
|
||||
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
|
||||
vertical_extent: crate::VerticalExtent {
|
||||
staves: vec![staff_a, staff_b],
|
||||
},
|
||||
objects: vec![
|
||||
manifested(TypedObjectId::Staff(staff_a), staff_a),
|
||||
manifested(TypedObjectId::Staff(staff_b), staff_b),
|
||||
manifested(TypedObjectId::Event(EventId::from_raw(1)), staff_a),
|
||||
manifested(TypedObjectId::Event(EventId::from_raw(2)), staff_b),
|
||||
],
|
||||
}],
|
||||
engraving_decisions: vec![],
|
||||
overrides: vec![],
|
||||
cross_region: vec![],
|
||||
};
|
||||
let c = to_constrained(&logical);
|
||||
|
||||
let staff_bands: Vec<_> = c
|
||||
.vertical_bands
|
||||
.iter()
|
||||
.filter(|b| matches!(b.kind, VerticalBandKind::Staff(_)))
|
||||
.collect();
|
||||
let gap_bands = c
|
||||
.vertical_bands
|
||||
.iter()
|
||||
.filter(|b| matches!(b.kind, VerticalBandKind::InterStaffGap))
|
||||
.count();
|
||||
assert_eq!(staff_bands.len(), 2, "one staff band per staff");
|
||||
assert_eq!(gap_bands, 1, "one inter-staff gap band between two staves");
|
||||
|
||||
// Staff A's two glyphs (staff object + event) are in A's band only.
|
||||
let band_a = staff_bands
|
||||
.iter()
|
||||
.find(|b| b.kind == VerticalBandKind::Staff(staff_a))
|
||||
.unwrap();
|
||||
let band_b = staff_bands
|
||||
.iter()
|
||||
.find(|b| b.kind == VerticalBandKind::Staff(staff_b))
|
||||
.unwrap();
|
||||
assert_eq!(band_a.members.len(), 2);
|
||||
assert_eq!(band_b.members.len(), 2);
|
||||
let a_set: BTreeSet<_> = band_a.members.iter().collect();
|
||||
assert!(
|
||||
band_b.members.iter().all(|m| !a_set.contains(m)),
|
||||
"no glyph is in both staves' bands"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_region_provenance_is_rejected_not_dropped() {
|
||||
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
|
||||
use crate::LayoutRegion;
|
||||
use epiphany_core::EventId;
|
||||
|
||||
let logical = LogicalLayoutIR {
|
||||
source: ScoreVersion::default(),
|
||||
regions: vec![LayoutRegion {
|
||||
provenance: Provenance::projected(
|
||||
TypedObjectId::Event(EventId::from_raw(9)),
|
||||
vec![],
|
||||
),
|
||||
coordinate_system: crate::LocalCoordinateSystem::default(),
|
||||
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
|
||||
vertical_extent: crate::VerticalExtent::default(),
|
||||
objects: vec![],
|
||||
}],
|
||||
engraving_decisions: vec![],
|
||||
overrides: vec![],
|
||||
cross_region: vec![],
|
||||
};
|
||||
assert!(matches!(
|
||||
try_to_constrained(&logical),
|
||||
Err(LayoutTransformError::RegionSourceIsNotRegion(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
//! Engraving-decision records (Chapter 7 §"Engraving Decisions").
|
||||
//!
|
||||
//! "When the engraver makes a decision (stem direction, accidental ordering,
|
||||
//! beam consolidation), the decision is recorded in the IR. The decision can be
|
||||
//! inspected, overridden, and traced" (Chapter 7 §"Design Principles"). The
|
||||
//! pipeline records decisions explicitly so they survive every stage and remain
|
||||
//! attributable to their source. v0 implements the decision *records* and their
|
||||
//! provenance and override interfaces (the QUICKSTART scope item); production
|
||||
//! engraving algorithms remain layered specifications beyond the v0 stub.
|
||||
|
||||
use epiphany_core::{StemDirection, TypedObjectId};
|
||||
use epiphany_determinism::{DomainTag, Preimage};
|
||||
|
||||
use crate::provenance::LayoutObjectId;
|
||||
use crate::spatial::Point;
|
||||
|
||||
/// A content-derived identifier for an engraving decision. Derived from the
|
||||
/// decision's target and kind, so equal decisions on the same target share an
|
||||
/// id and the records stay stable across re-engraving.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct EngravingDecisionId(pub u128);
|
||||
|
||||
/// A registry id for an extension-defined [`EngravingDecisionKind::Registered`].
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct EngravingDecisionRegistryId(pub u128);
|
||||
|
||||
/// A user-override identifier referenced by [`DecisionSource::UserOverride`].
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct EngravingOverrideId(pub u128);
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct AuthorId(pub u128);
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct ForeignFormatId(pub u128);
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct PluginId(pub u128);
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct Timestamp(pub i64);
|
||||
|
||||
/// The authoritative or transient target of an engraving override.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum OverrideTarget {
|
||||
ScoreGraph(TypedObjectId),
|
||||
IrSynthesized(LayoutObjectId),
|
||||
}
|
||||
|
||||
/// An override's binding strength.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum OverridePriority {
|
||||
Hard,
|
||||
Soft,
|
||||
}
|
||||
|
||||
/// Provenance of a user/import/plugin override.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum OverrideOrigin {
|
||||
User {
|
||||
author: AuthorId,
|
||||
timestamp: Timestamp,
|
||||
},
|
||||
Import {
|
||||
format: ForeignFormatId,
|
||||
},
|
||||
Plugin {
|
||||
plugin: PluginId,
|
||||
},
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// Core override vocabulary. More detailed engraving payloads are represented
|
||||
/// by stable registered ids until their companion algorithm specifications land.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum OverrideKind {
|
||||
StemDirection(StemDirection),
|
||||
AccidentalParenthesized(bool),
|
||||
AccidentalVisible(bool),
|
||||
SystemBreak,
|
||||
PageBreak,
|
||||
HiddenObject,
|
||||
CustomPosition(Point),
|
||||
LedgerLineSuppression,
|
||||
Registered(u128),
|
||||
}
|
||||
|
||||
/// A projected engraving override (Chapter 7 §"Engraving Overrides").
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct EngravingOverride {
|
||||
pub id: EngravingOverrideId,
|
||||
pub target: OverrideTarget,
|
||||
pub kind: OverrideKind,
|
||||
pub priority: OverridePriority,
|
||||
pub origin: OverrideOrigin,
|
||||
}
|
||||
|
||||
/// Where an engraving decision came from (Chapter 7 §"Note Layout":
|
||||
/// `DecisionSource`).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum DecisionSource {
|
||||
/// Derived from automatic engraving rules.
|
||||
Automatic,
|
||||
/// Derived from a user override in the score graph.
|
||||
UserOverride(EngravingOverrideId),
|
||||
/// Derived from an IR-stage override.
|
||||
IrOverride,
|
||||
}
|
||||
|
||||
/// A decision the engraver recorded (Chapter 7 §"Engraving Decisions":
|
||||
/// `EngravingDecisionKind`). v0 carries a representative subset; the catalog is
|
||||
/// extensible (the trailing `Registered` variant).
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum EngravingDecisionKind {
|
||||
/// The stem direction chosen for a note or chord.
|
||||
StemDirection(StemDirection),
|
||||
/// The number of ledger lines a note requires.
|
||||
LedgerLineCount(u8),
|
||||
/// A system break placed here.
|
||||
SystemBreak,
|
||||
/// A page break placed here.
|
||||
PageBreak,
|
||||
/// An extension-defined decision kind.
|
||||
Registered(EngravingDecisionRegistryId),
|
||||
}
|
||||
|
||||
impl EngravingDecisionKind {
|
||||
/// A stable discriminant byte, part of the decision-id preimage.
|
||||
fn discriminant(&self) -> u8 {
|
||||
match self {
|
||||
EngravingDecisionKind::StemDirection(_) => 0,
|
||||
EngravingDecisionKind::LedgerLineCount(_) => 1,
|
||||
EngravingDecisionKind::SystemBreak => 2,
|
||||
EngravingDecisionKind::PageBreak => 3,
|
||||
EngravingDecisionKind::Registered(_) => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An engraving-decision record (Chapter 7 §"Engraving Decisions":
|
||||
/// `EngravingDecision`). Carried forward through every IR stage.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct EngravingDecision {
|
||||
pub id: EngravingDecisionId,
|
||||
pub target: LayoutObjectId,
|
||||
pub kind: EngravingDecisionKind,
|
||||
pub source: DecisionSource,
|
||||
}
|
||||
|
||||
impl EngravingDecision {
|
||||
/// An automatic decision on `target`, with a content-derived id.
|
||||
pub fn automatic(target: LayoutObjectId, kind: EngravingDecisionKind) -> Self {
|
||||
let source = DecisionSource::Automatic;
|
||||
EngravingDecision {
|
||||
id: derive_decision_id(target, &kind, source),
|
||||
target,
|
||||
kind,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// A decision on `target` attributed to `source`, with a content-derived id
|
||||
/// that includes the source (so the same target+kind from an automatic rule
|
||||
/// and from a user override are distinct decisions).
|
||||
pub fn with_source(
|
||||
target: LayoutObjectId,
|
||||
kind: EngravingDecisionKind,
|
||||
source: DecisionSource,
|
||||
) -> Self {
|
||||
EngravingDecision {
|
||||
id: derive_decision_id(target, &kind, source),
|
||||
target,
|
||||
kind,
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives an [`EngravingDecisionId`] from its target, kind, and source, so
|
||||
/// equal decisions share an id and differing ones do not.
|
||||
///
|
||||
/// The preimage **borrows** the `MUSCCONF` domain tag (the determinism crate,
|
||||
/// frozen, defines no layout-object domain) and prefixes a literal
|
||||
/// `engraving-decision` discriminator so it cannot alias a real conflict id.
|
||||
/// This is *type-tag*, not *domain*, separation; a dedicated layout domain tag
|
||||
/// is a Pass 11 candidate (see `DECISIONS.md`).
|
||||
fn derive_decision_id(
|
||||
target: LayoutObjectId,
|
||||
kind: &EngravingDecisionKind,
|
||||
source: DecisionSource,
|
||||
) -> EngravingDecisionId {
|
||||
let mut p = Preimage::new(DomainTag::CONFLICT);
|
||||
p.push_bytes(b"engraving-decision");
|
||||
p.push_u64_le((target.0 >> 64) as u64);
|
||||
p.push_u64_le(target.0 as u64);
|
||||
p.push_u64_le(kind.discriminant() as u64);
|
||||
match kind {
|
||||
EngravingDecisionKind::StemDirection(d) => {
|
||||
p.push_u64_le(matches!(d, StemDirection::Up) as u64);
|
||||
}
|
||||
EngravingDecisionKind::LedgerLineCount(n) => {
|
||||
p.push_u64_le(*n as u64);
|
||||
}
|
||||
EngravingDecisionKind::Registered(r) => {
|
||||
p.push_u64_le((r.0 >> 64) as u64);
|
||||
p.push_u64_le(r.0 as u64);
|
||||
}
|
||||
EngravingDecisionKind::SystemBreak | EngravingDecisionKind::PageBreak => {}
|
||||
}
|
||||
match source {
|
||||
DecisionSource::Automatic => {
|
||||
p.push_u64_le(0);
|
||||
}
|
||||
DecisionSource::UserOverride(id) => {
|
||||
p.push_u64_le(1);
|
||||
p.push_u64_le((id.0 >> 64) as u64);
|
||||
p.push_u64_le(id.0 as u64);
|
||||
}
|
||||
DecisionSource::IrOverride => {
|
||||
p.push_u64_le(2);
|
||||
}
|
||||
}
|
||||
EngravingDecisionId(p.finish_trunc128())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decision_id_is_content_derived_and_stable() {
|
||||
let target = LayoutObjectId(0xABCD);
|
||||
let a = EngravingDecision::automatic(target, EngravingDecisionKind::SystemBreak);
|
||||
let b = EngravingDecision::automatic(target, EngravingDecisionKind::SystemBreak);
|
||||
assert_eq!(a, b);
|
||||
// Different kind on the same target → different id.
|
||||
let c = EngravingDecision::automatic(target, EngravingDecisionKind::PageBreak);
|
||||
assert_ne!(a.id, c.id);
|
||||
// Different target → different id.
|
||||
let d = EngravingDecision::automatic(LayoutObjectId(1), EngravingDecisionKind::SystemBreak);
|
||||
assert_ne!(a.id, d.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_source_changes_the_id() {
|
||||
let target = LayoutObjectId(5);
|
||||
let auto = EngravingDecision::automatic(target, EngravingDecisionKind::SystemBreak);
|
||||
let over = EngravingDecision::with_source(
|
||||
target,
|
||||
EngravingDecisionKind::SystemBreak,
|
||||
DecisionSource::IrOverride,
|
||||
);
|
||||
assert_eq!(auto.source, DecisionSource::Automatic);
|
||||
assert_ne!(
|
||||
auto.id, over.id,
|
||||
"the decision source participates in the id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stem_direction_payload_changes_the_id() {
|
||||
let target = LayoutObjectId(7);
|
||||
let up = EngravingDecision::automatic(
|
||||
target,
|
||||
EngravingDecisionKind::StemDirection(StemDirection::Up),
|
||||
);
|
||||
let down = EngravingDecision::automatic(
|
||||
target,
|
||||
EngravingDecisionKind::StemDirection(StemDirection::Down),
|
||||
);
|
||||
assert_ne!(up.id, down.id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,458 @@
|
|||
//! The glyph catalog (Chapter 7 §"Glyph Catalog Interface") and its
|
||||
//! reproducibility identity (§7.3.2), with Bravura metrics bundled in-tree for
|
||||
//! testing (QUICKSTART, Agent E).
|
||||
//!
|
||||
//! The IR references glyphs by name and queries metrics from a font catalog;
|
||||
//! metrics are never embedded in pipeline objects (Chapter 7 §"Glyph metrics
|
||||
//! live elsewhere"). For reproducible layout, the exact catalog consumed by a
|
||||
//! solve MUST be identifiable: [`GlyphCatalogIdentity`] carries the font id, its
|
||||
//! version, the SMuFL version, and a content hash over the canonical
|
||||
//! serialization of every consulted glyph's metrics (bounding box, advance
|
||||
//! width, **and named anchors**), computed with the Appendix D domain tag
|
||||
//! `MUSCFNTM` ([`DomainTag::FONT_METRICS`]).
|
||||
//!
|
||||
//! v0 bundles a small but representative slice of the real
|
||||
//! [Bravura](https://github.com/steinbergmedia/bravura) SMuFL font's metrics, in
|
||||
//! `1/1024`-staff-space units (the catalog's exact, hashable unit), so the
|
||||
//! catalog identity is exercised end to end without shipping a font file. A full
|
||||
//! catalog (and the render-data side of the interface) is an out-of-core concern.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use epiphany_determinism::{DomainTag, Preimage};
|
||||
|
||||
use crate::spatial::{BoundingBox, Point};
|
||||
|
||||
/// The SMuFL version a catalog targets (Chapter 7: `SmuflVersion`).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct SmuflVersion {
|
||||
pub major: u16,
|
||||
pub minor: u16,
|
||||
}
|
||||
|
||||
/// Identifier of a specific SMuFL font (Chapter 7: `FontId`).
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct FontId(pub Cow<'static, str>);
|
||||
|
||||
impl FontId {
|
||||
/// The reference font v0 bundles metrics for.
|
||||
pub const BRAVURA: FontId = FontId(Cow::Borrowed("Bravura"));
|
||||
|
||||
/// Constructs an identifier for a catalog loaded at runtime.
|
||||
pub fn owned(name: impl Into<String>) -> Self {
|
||||
FontId(Cow::Owned(name.into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A font-catalog glyph identifier that may be bundled or loaded at runtime.
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct GlyphReference(pub Cow<'static, str>);
|
||||
|
||||
impl GlyphReference {
|
||||
pub const fn borrowed(name: &'static str) -> Self {
|
||||
GlyphReference(Cow::Borrowed(name))
|
||||
}
|
||||
|
||||
pub fn owned(name: impl Into<String>) -> Self {
|
||||
GlyphReference(Cow::Owned(name.into()))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// A semantic font version (Chapter 7 §7.3.2: the optional `font_version`).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct SemVer {
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
pub patch: u32,
|
||||
}
|
||||
|
||||
impl SemVer {
|
||||
pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
|
||||
SemVer {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A reproducibility-quality identifier for the glyph catalog used to produce a
|
||||
/// layout (Chapter 7 §7.3.2). Required for any layout-conformance claim that
|
||||
/// depends on byte-equal output across runs.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct GlyphCatalogIdentity {
|
||||
/// SMuFL version targeted.
|
||||
pub smufl_version: SmuflVersion,
|
||||
/// The specific font in use.
|
||||
pub font_id: FontId,
|
||||
/// The font's release version, if the publisher uses versioned releases
|
||||
/// (Chapter 7 §7.3.2: the spec's optional `font_version`). Bravura is
|
||||
/// versioned, so v0 records the release the bundled metrics track.
|
||||
pub font_version: Option<SemVer>,
|
||||
/// Content hash (BLAKE3 / `MUSCFNTM`) over the canonical serialization of
|
||||
/// every consulted glyph's metrics (Chapter 7 §7.3.2).
|
||||
pub metrics_hash: [u8; 32],
|
||||
}
|
||||
|
||||
/// The Bravura release whose metrics the in-tree table approximates (the latest
|
||||
/// stable Bravura release).
|
||||
pub const BRAVURA_VERSION: SemVer = SemVer::new(1, 38, 0);
|
||||
|
||||
impl Default for GlyphCatalogIdentity {
|
||||
/// The bundled Bravura identity, with `metrics_hash` over the *whole*
|
||||
/// in-tree table. A [`crate::ConstrainedLayoutIR`] overrides the hash with
|
||||
/// one over only the glyphs it references (the solve's true inputs).
|
||||
fn default() -> Self {
|
||||
GlyphCatalogIdentity {
|
||||
smufl_version: SmuflVersion { major: 1, minor: 4 },
|
||||
font_id: FontId::BRAVURA,
|
||||
font_version: Some(BRAVURA_VERSION),
|
||||
metrics_hash: metrics_hash_for(BRAVURA_METRICS.iter().map(|m| m.name.as_ref())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named anchor on a glyph (Chapter 7 §"Glyph Catalog Interface":
|
||||
/// `GlyphMetrics.anchors`), e.g. a notehead's stem-attachment point, in
|
||||
/// `1/1024`-staff-space units relative to the glyph origin.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct GlyphAnchor {
|
||||
pub name: Cow<'static, str>,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
/// One glyph's metrics: advance width, bounding box, and named anchors, in
|
||||
/// `1/1024`-staff-space units (Chapter 7 §"Glyph Catalog Interface":
|
||||
/// `GlyphMetrics`). Units are exact integers (hashable, deterministic);
|
||||
/// [`GlyphMetric::bounding_box`] converts to staff-space `f32` on demand.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct GlyphMetric {
|
||||
pub name: Cow<'static, str>,
|
||||
pub advance: i32,
|
||||
pub bbox: [i32; 4],
|
||||
pub anchors: Cow<'static, [GlyphAnchor]>,
|
||||
}
|
||||
|
||||
impl GlyphMetric {
|
||||
/// Constructs a metrics entry with no named anchors.
|
||||
pub const fn new(name: &'static str, advance: i32, bbox: [i32; 4]) -> Self {
|
||||
GlyphMetric {
|
||||
name: Cow::Borrowed(name),
|
||||
advance,
|
||||
bbox,
|
||||
anchors: Cow::Borrowed(&[]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a metrics entry with named anchors.
|
||||
pub const fn anchored(
|
||||
name: &'static str,
|
||||
advance: i32,
|
||||
bbox: [i32; 4],
|
||||
anchors: &'static [GlyphAnchor],
|
||||
) -> Self {
|
||||
GlyphMetric {
|
||||
name: Cow::Borrowed(name),
|
||||
advance,
|
||||
bbox,
|
||||
anchors: Cow::Borrowed(anchors),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounding box in staff spaces (Chapter 7: `GlyphMetrics.bounding_box`).
|
||||
pub fn bounding_box(&self) -> BoundingBox {
|
||||
let g = |u: i32| u as f32 / 1024.0;
|
||||
let [l, b, r, t] = self.bbox;
|
||||
BoundingBox::new(g(l), g(b), g(r), g(t))
|
||||
}
|
||||
}
|
||||
|
||||
const STEM_UP_NW: GlyphAnchor = GlyphAnchor {
|
||||
name: Cow::Borrowed("stemUpNW"),
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
const STEM_DOWN_SE: GlyphAnchor = GlyphAnchor {
|
||||
name: Cow::Borrowed("stemDownSE"),
|
||||
x: 1180,
|
||||
y: 0,
|
||||
};
|
||||
const NOTEHEAD_ANCHORS: &[GlyphAnchor] = &[STEM_UP_NW, STEM_DOWN_SE];
|
||||
|
||||
/// A representative in-tree slice of Bravura's SMuFL metrics
|
||||
/// (`(name, advance, [left, bottom, right, top])`, `1/1024`-staff-space units).
|
||||
/// Every glyph the v0 pipeline names is in this table; the stub solver checks
|
||||
/// that, so a missing entry surfaces as [`crate::SolveStatus::InternalError`].
|
||||
pub const BRAVURA_METRICS: &[GlyphMetric] = &[
|
||||
GlyphMetric::anchored(
|
||||
"noteheadBlack",
|
||||
1180,
|
||||
[0, -512, 1180, 512],
|
||||
NOTEHEAD_ANCHORS,
|
||||
),
|
||||
GlyphMetric::anchored("noteheadHalf", 1180, [0, -512, 1180, 512], NOTEHEAD_ANCHORS),
|
||||
GlyphMetric::new("noteheadWhole", 1690, [0, -512, 1690, 512]),
|
||||
GlyphMetric::new("noteheadDoubleWhole", 2616, [0, -512, 2616, 512]),
|
||||
GlyphMetric::new("gClef", 2684, [0, -2048, 2600, 4660]),
|
||||
GlyphMetric::new("fClef", 2776, [0, -1024, 2776, 1024]),
|
||||
GlyphMetric::new("cClef", 2884, [0, -2048, 2884, 2048]),
|
||||
GlyphMetric::new("accidentalSharp", 994, [0, -1392, 994, 1392]),
|
||||
GlyphMetric::new("accidentalFlat", 821, [0, -703, 821, 1751]),
|
||||
GlyphMetric::new("accidentalNatural", 686, [0, -1377, 686, 1377]),
|
||||
GlyphMetric::new("accidentalDoubleSharp", 1006, [0, -260, 1006, 260]),
|
||||
GlyphMetric::new("restWhole", 1280, [0, 0, 1280, 512]),
|
||||
GlyphMetric::new("restHalf", 1280, [0, -512, 1280, 0]),
|
||||
GlyphMetric::new("restQuarter", 1024, [0, -1536, 1024, 1536]),
|
||||
GlyphMetric::new("rest8th", 845, [0, -1024, 845, 1024]),
|
||||
GlyphMetric::new("flag8thUp", 1007, [0, -84, 1007, 2607]),
|
||||
GlyphMetric::new("flag8thDown", 1007, [0, -2607, 1007, 84]),
|
||||
GlyphMetric::new("augmentationDot", 400, [0, -154, 308, 154]),
|
||||
GlyphMetric::new("timeSig4", 1280, [40, 0, 1240, 2048]),
|
||||
GlyphMetric::new("timeSigCommon", 1480, [80, 0, 1400, 2048]),
|
||||
GlyphMetric::new("barlineSingle", 160, [0, -2048, 160, 2048]),
|
||||
GlyphMetric::new("barlineFinal", 620, [0, -2048, 620, 2048]),
|
||||
GlyphMetric::new("dynamicForte", 1480, [0, -706, 1480, 1565]),
|
||||
GlyphMetric::new("dynamicPiano", 1700, [0, -509, 1700, 1565]),
|
||||
];
|
||||
|
||||
/// Looks up one glyph's metrics by SMuFL name, if bundled.
|
||||
pub fn metrics(name: &str) -> Option<&'static GlyphMetric> {
|
||||
BRAVURA_METRICS.iter().find(|m| m.name.as_ref() == name)
|
||||
}
|
||||
|
||||
/// Whether every name in `names` has bundled metrics.
|
||||
pub fn all_available<'a>(names: impl IntoIterator<Item = &'a str>) -> bool {
|
||||
names.into_iter().all(|n| metrics(n).is_some())
|
||||
}
|
||||
|
||||
/// A glyph's rendering data (Chapter 7 §"Glyph Catalog Interface":
|
||||
/// `GlyphRenderData`). The full outline/bitmap vocabulary (`PathCommand`,
|
||||
/// `GlyphBitmap`) belongs to the out-of-core renderer; v0 carries an opaque
|
||||
/// marker so the interface is complete without bundling outlines.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum PathCommand {
|
||||
MoveTo(Point),
|
||||
LineTo(Point),
|
||||
CurveTo {
|
||||
control1: Point,
|
||||
control2: Point,
|
||||
to: Point,
|
||||
},
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct GlyphBitmap {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba8: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug, Default)]
|
||||
pub struct GlyphRenderData {
|
||||
pub outline: Vec<PathCommand>,
|
||||
pub bitmap: Option<GlyphBitmap>,
|
||||
}
|
||||
|
||||
/// The font-catalog query interface (Chapter 7 §"Glyph Catalog Interface":
|
||||
/// `GlyphCatalog`). `Send + Sync` per the spec, so a catalog can be shared
|
||||
/// across threads during parallel re-engraving.
|
||||
pub trait GlyphCatalog: Send + Sync {
|
||||
/// Resolve a glyph name to its metrics.
|
||||
fn metrics(&self, name: &str) -> Option<&GlyphMetric>;
|
||||
/// Resolve a glyph name to its rendering data, if any.
|
||||
fn render_data(&self, name: &str) -> Option<GlyphRenderData>;
|
||||
/// The SMuFL version this catalog supports.
|
||||
fn smufl_version(&self) -> SmuflVersion;
|
||||
/// This catalog's reproducibility identity over the given consulted names.
|
||||
fn identity(&self, consulted: &[&str]) -> GlyphCatalogIdentity;
|
||||
}
|
||||
|
||||
/// The bundled in-tree Bravura catalog. **Metric-only**: it bundles no render
|
||||
/// data (outlines/bitmaps are a renderer concern), so
|
||||
/// [`BravuraCatalog::render_data`] honestly returns `None` for every glyph.
|
||||
pub struct BravuraCatalog;
|
||||
|
||||
impl GlyphCatalog for BravuraCatalog {
|
||||
fn metrics(&self, name: &str) -> Option<&GlyphMetric> {
|
||||
metrics(name)
|
||||
}
|
||||
|
||||
fn render_data(&self, _name: &str) -> Option<GlyphRenderData> {
|
||||
// No outlines or bitmaps are bundled; reporting `Some` would claim render
|
||||
// data that does not exist.
|
||||
None
|
||||
}
|
||||
|
||||
fn smufl_version(&self) -> SmuflVersion {
|
||||
SmuflVersion { major: 1, minor: 4 }
|
||||
}
|
||||
|
||||
fn identity(&self, consulted: &[&str]) -> GlyphCatalogIdentity {
|
||||
GlyphCatalogIdentity {
|
||||
metrics_hash: metrics_hash_for(consulted.iter().copied()),
|
||||
..GlyphCatalogIdentity::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog metrics identity (Chapter 7 §7.3.2): a **domain-tagged**
|
||||
/// (`MUSCFNTM`) BLAKE3 hash over the canonical serialization of the consulted
|
||||
/// glyph metrics (advance, bounding box, and named anchors), rather than a raw
|
||||
/// hash of a descriptive string.
|
||||
///
|
||||
/// Names are de-duplicated and put in canonical (sorted) order first, so two
|
||||
/// solves consulting the same metric set hash identically regardless of glyph
|
||||
/// ordering (Appendix D §"Ordered Iteration"). Panics if a name has no bundled
|
||||
/// metrics — every glyph delivered to a solve MUST name available metrics.
|
||||
pub fn metrics_hash_for<'a>(names: impl IntoIterator<Item = &'a str>) -> [u8; 32] {
|
||||
let names: BTreeSet<&str> = names.into_iter().collect();
|
||||
let mut p = Preimage::new(DomainTag::FONT_METRICS);
|
||||
p.push_u64_le(names.len() as u64);
|
||||
for name in names {
|
||||
let m = metrics(name).expect("every consulted glyph must name bundled metrics");
|
||||
p.push_u64_le(name.len() as u64);
|
||||
p.push_bytes(name.as_bytes());
|
||||
p.push_u64_le(m.advance as u64);
|
||||
for coord in m.bbox {
|
||||
p.push_u64_le(coord as u64);
|
||||
}
|
||||
// Anchors are a *map* keyed by name (Chapter 7 §"Glyph Catalog
|
||||
// Interface": `anchors: HashMap<AnchorName, Point2D>`), so hash them in
|
||||
// canonical name order (Appendix D §"Ordered Iteration over Sets and
|
||||
// Maps"). A duplicate anchor name is invalid map data and is **rejected**
|
||||
// (a panic), not silently order-collapsed — so the hash never depends on
|
||||
// anchor slice order.
|
||||
let mut anchors: BTreeMap<&str, (i32, i32)> = BTreeMap::new();
|
||||
for a in m.anchors.iter() {
|
||||
assert!(
|
||||
anchors.insert(a.name.as_ref(), (a.x, a.y)).is_none(),
|
||||
"glyph {} has a duplicate anchor name {}",
|
||||
name,
|
||||
a.name
|
||||
);
|
||||
}
|
||||
p.push_u64_le(anchors.len() as u64);
|
||||
for (anchor_name, (x, y)) in anchors {
|
||||
p.push_u64_le(anchor_name.len() as u64);
|
||||
p.push_bytes(anchor_name.as_bytes());
|
||||
p.push_u64_le(x as u64);
|
||||
p.push_u64_le(y as u64);
|
||||
}
|
||||
}
|
||||
*p.finish().as_bytes()
|
||||
}
|
||||
|
||||
/// The bundled Bravura catalog identity (the [`GlyphCatalogIdentity::default`]).
|
||||
pub fn bravura_catalog_identity() -> GlyphCatalogIdentity {
|
||||
GlyphCatalogIdentity::default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn metrics_hash_is_domain_tagged_and_nonzero() {
|
||||
let h = bravura_catalog_identity().metrics_hash;
|
||||
assert_ne!(h, [0u8; 32]);
|
||||
assert_eq!(GlyphCatalogIdentity::default().metrics_hash, h);
|
||||
assert_eq!(
|
||||
GlyphCatalogIdentity::default().font_version,
|
||||
Some(BRAVURA_VERSION)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_hash_is_order_and_duplicate_independent() {
|
||||
let a = metrics_hash_for(["gClef", "noteheadBlack", "accidentalSharp"]);
|
||||
let b = metrics_hash_for(["accidentalSharp", "gClef", "noteheadBlack", "gClef"]);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_participate_in_the_hash() {
|
||||
// noteheadBlack carries stem anchors; noteheadWhole does not. Even with
|
||||
// equal bbox/advance they must hash differently.
|
||||
assert_ne!(
|
||||
metrics_hash_for(["noteheadBlack"]),
|
||||
metrics_hash_for(["noteheadWhole"])
|
||||
);
|
||||
assert!(!metrics("noteheadBlack").unwrap().anchors.is_empty());
|
||||
assert!(metrics("noteheadWhole").unwrap().anchors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_trait_resolves_and_identifies() {
|
||||
let cat = BravuraCatalog;
|
||||
assert_eq!(cat.metrics("gClef"), metrics("gClef"));
|
||||
assert!(cat.metrics("noSuchGlyph").is_none());
|
||||
assert_eq!(
|
||||
cat.identity(&["gClef"]).metrics_hash,
|
||||
metrics_hash_for(["gClef"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_catalog_can_use_owned_names_and_report_identity_through_dyn_trait() {
|
||||
struct RuntimeCatalog {
|
||||
metric: GlyphMetric,
|
||||
}
|
||||
impl GlyphCatalog for RuntimeCatalog {
|
||||
fn metrics(&self, name: &str) -> Option<&GlyphMetric> {
|
||||
(self.metric.name.as_ref() == name).then_some(&self.metric)
|
||||
}
|
||||
fn render_data(&self, _name: &str) -> Option<GlyphRenderData> {
|
||||
None
|
||||
}
|
||||
fn smufl_version(&self) -> SmuflVersion {
|
||||
SmuflVersion { major: 1, minor: 4 }
|
||||
}
|
||||
fn identity(&self, _consulted: &[&str]) -> GlyphCatalogIdentity {
|
||||
GlyphCatalogIdentity {
|
||||
smufl_version: self.smufl_version(),
|
||||
font_id: FontId::owned("Runtime Font"),
|
||||
font_version: None,
|
||||
metrics_hash: [7; 32],
|
||||
}
|
||||
}
|
||||
}
|
||||
let catalog: Box<dyn GlyphCatalog> = Box::new(RuntimeCatalog {
|
||||
metric: GlyphMetric {
|
||||
name: Cow::Owned("runtimeGlyph".to_owned()),
|
||||
advance: 1024,
|
||||
bbox: [0, 0, 1024, 1024],
|
||||
anchors: Cow::Owned(vec![GlyphAnchor {
|
||||
name: Cow::Owned("runtimeAnchor".to_owned()),
|
||||
x: 0,
|
||||
y: 0,
|
||||
}]),
|
||||
},
|
||||
});
|
||||
assert!(catalog.metrics("runtimeGlyph").is_some());
|
||||
assert_eq!(catalog.identity(&["runtimeGlyph"]).metrics_hash, [7; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_bundled_name_is_unique_and_resolvable() {
|
||||
let mut seen = BTreeSet::new();
|
||||
for m in BRAVURA_METRICS {
|
||||
assert!(
|
||||
seen.insert(m.name.as_ref()),
|
||||
"duplicate bundled glyph {}",
|
||||
m.name
|
||||
);
|
||||
assert_eq!(metrics(m.name.as_ref()), Some(m));
|
||||
}
|
||||
assert!(all_available(
|
||||
BRAVURA_METRICS.iter().map(|m| m.name.as_ref())
|
||||
));
|
||||
assert!(!all_available(["noSuchGlyph"]));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
#![forbid(unsafe_code)]
|
||||
//! # epiphany-layout-ir
|
||||
//!
|
||||
//! The Epiphany **layout intermediate representation** and the
|
||||
//! **constraint-solver interface**, implementing the normative requirements of
|
||||
//! **Chapter 7** ("Layout Intermediate Representation") and the interface of
|
||||
//! **Chapter 9** ("Constraint-Solver Interface") of the core specification. This
|
||||
//! is Agent E's crate per `spec/QUICKSTART.md`; it lands last among the
|
||||
//! implementation crates, building on Agent A's [`epiphany_determinism`] and
|
||||
//! Agent B's [`epiphany_core`] (and on Agent C's `OperationKindTag` for the
|
||||
//! edit-barrier types — see `DECISIONS.md`).
|
||||
//!
|
||||
//! The IR is a pipeline of four stages, each with its own type and a
|
||||
//! deterministic, provenance-preserving contract for the next (Chapter 7
|
||||
//! §"Design Principles"):
|
||||
//!
|
||||
//! 1. [`LogicalLayoutIR`] — the structural projection of the score graph, with
|
||||
//! engraving decisions made and positions unresolved.
|
||||
//! 2. [`ConstrainedLayoutIR`] — composite objects flattened to glyphs, each with
|
||||
//! the anchor geometry the solver consumes, plus the vertical-band model.
|
||||
//! 3. [`ResolvedLayoutIR`] — every glyph positioned, the solver's output.
|
||||
//! 4. [`RenderIR`] — renderer-bound primitives (**interface only**; no
|
||||
//! rendering — QUICKSTART, Agent E).
|
||||
//!
|
||||
//! ## What v0 implements (QUICKSTART, Agent E)
|
||||
//!
|
||||
//! * The four IR stages and the [`TimeAxisModel`] tagged enum (Metric /
|
||||
//! Proportional / Aleatoric / Registered — *not* `Box<dyn TimeAxis>`).
|
||||
//! * The [`Provenance`] back-references every layout object carries — what makes
|
||||
//! incremental layout possible and what the round-trip proves is preserved.
|
||||
//! * [`EngravingDecision`] records (Chapter 7 §"Engraving Decisions").
|
||||
//! * The vertical-band model ([`VerticalBand`], Chapter 7 §"Vertical Bands").
|
||||
//! * [`GlyphCatalogIdentity`] with Bravura metrics bundled in-tree
|
||||
//! ([`glyph::BRAVURA_METRICS`]) and the `MUSCFNTM`-domain-tagged
|
||||
//! [`metrics_hash_for`] (Chapter 7 §7.3.2).
|
||||
//! * The [`EditBarrier`] types keyed on Agent C's [`OperationKindTag`]
|
||||
//! (Chapter 8 §"Forward Compatibility and Edit Barriers").
|
||||
//! * The [`ConstraintSolver`] interface (Chapter 9) and the v0 [`StubSolver`],
|
||||
//! which "returns `SolveStatus::Solved` with the input geometry verbatim."
|
||||
//!
|
||||
//! The Chapter 7 interface surface includes the composite-object taxonomy,
|
||||
//! spring slots and constraints, pages/systems, engraving overrides, and the
|
||||
//! incremental dependency/cache model. The v0 algorithms remain intentionally
|
||||
//! simple: they do not perform production engraving, casting-off, quality-metric
|
||||
//! computation, or rendering.
|
||||
//!
|
||||
//! ## Determinism
|
||||
//!
|
||||
//! IR coordinates are single-precision staff spaces ([`StaffSpace`], Chapter 7
|
||||
//! §7.2). The **canonical** `ResolvedLayoutIR` output quantizes them to the
|
||||
//! `1/1024` grid ([`epiphany_determinism::QuantizedCoord`]) at serialization
|
||||
//! time ([`ResolvedLayoutIR::canonical_bytes`]), exactly as Appendix D
|
||||
//! §"Quantized Layout Coordinates" prescribes — quantization absorbs all f32
|
||||
//! variation below `1/2048` staff space, and a non-finite coordinate is
|
||||
//! *rejected* (a panic), never normalized. That canonical encoding covers the
|
||||
//! full resolved layout (every glyph's provenance and quantized position, the
|
||||
//! engraving decisions, and the catalog identity), so any change that
|
||||
//! distinguishes two layouts changes their canonical bytes.
|
||||
//!
|
||||
//! ## The round-trip (v0 acceptance criterion 6)
|
||||
//!
|
||||
//! [`round_trip`] runs graph → logical → constrained → stub-solved → render and
|
||||
//! asserts the pipeline completes without losing any provenance back-reference,
|
||||
//! recovering exactly the laid-out graph objects ([`laid_out_object_ids`]). The
|
||||
//! testkit's layout harness drives this entry point.
|
||||
|
||||
pub mod barrier;
|
||||
pub mod cache;
|
||||
pub mod constrained;
|
||||
pub mod engraving;
|
||||
pub mod glyph;
|
||||
pub mod logical;
|
||||
pub mod provenance;
|
||||
pub mod render;
|
||||
pub mod resolved;
|
||||
pub mod roundtrip;
|
||||
pub mod solver;
|
||||
pub mod spatial;
|
||||
pub mod time_axis;
|
||||
pub mod vertical_band;
|
||||
|
||||
pub use barrier::{
|
||||
AlwaysLiveOracle, BarrierCondition, BarrierConditionRegistryId, BarrierScope,
|
||||
BarrierScopeRegistryId, EditBarrier, EditContext, EditOracle, ExtensionRef, ObjectKind,
|
||||
};
|
||||
pub use cache::{
|
||||
ConstrainedRegionCache, DependencyIndex, FineLayoutCache, LayoutCache, LogicalRegionCache,
|
||||
ResolvedSystemCache, SystemId,
|
||||
};
|
||||
pub use constrained::{
|
||||
to_constrained, try_to_constrained, Axis, BreakKind, ConstrainedLayoutIR,
|
||||
ConstrainedLayoutRegion, ConstrainedValidationError, ConstraintParameters,
|
||||
ConstraintRegistryId, GlyphObject, GlyphObjectId, GlyphStyle, LayoutConstraint,
|
||||
LayoutTransformError, SpringSlot,
|
||||
};
|
||||
pub use engraving::{
|
||||
AuthorId, DecisionSource, EngravingDecision, EngravingDecisionId, EngravingDecisionKind,
|
||||
EngravingDecisionRegistryId, EngravingOverride, EngravingOverrideId, ForeignFormatId,
|
||||
OverrideKind, OverrideOrigin, OverridePriority, OverrideTarget, PluginId, Timestamp,
|
||||
};
|
||||
pub use glyph::{
|
||||
all_available, bravura_catalog_identity, metrics, metrics_hash_for, BravuraCatalog, FontId,
|
||||
GlyphAnchor, GlyphBitmap, GlyphCatalog, GlyphCatalogIdentity, GlyphMetric, GlyphReference,
|
||||
GlyphRenderData, PathCommand, SemVer, SmuflVersion, BRAVURA_METRICS, BRAVURA_VERSION,
|
||||
};
|
||||
pub use logical::{
|
||||
to_logical, BarLineLayout, BeamGroupLayout, ChordLayout, ClefLayout, CompositeLayoutObject,
|
||||
CrossRegionObject, CueLayout, GraphicLayout, GroupLayout, KeySignatureLayout, LayoutObject,
|
||||
LayoutRegion, LocalCoordinateSystem, LogicalLayoutIR, MarkerLayout, MultimeasureRestLayout,
|
||||
NoteLayout, RestLayout, ScoreVersion, SlurLayout, SpannerLayout, StaffLayout, TextLayout,
|
||||
TieLayout, TimeSignatureDisplayLayout, TrajectoryLayout, TupletDisplayLayout, VerticalExtent,
|
||||
};
|
||||
pub use provenance::{
|
||||
manifestation_layout_id, stable_layout_id, synthesized_layout_id, LayoutObjectId, Provenance,
|
||||
SynthesisInstanceKey, SynthesisKind, SynthesisRegistryId,
|
||||
};
|
||||
pub use render::{
|
||||
to_render, ColorConfiguration, ColorSpace, PassthroughRenderProducer,
|
||||
RasterizationConfiguration, RenderConfiguration, RenderIR, RenderIRProducer, RenderPrimitive,
|
||||
RenderTarget,
|
||||
};
|
||||
pub use resolved::{
|
||||
ResolvedGlyph, ResolvedLayoutIR, ResolvedMeasure, ResolvedPage, ResolvedStaff, ResolvedSystem,
|
||||
};
|
||||
pub use roundtrip::{laid_out_object_ids, round_trip, RoundTripReport};
|
||||
pub use solver::{
|
||||
ConstraintId, ConstraintSolver, ExtensionMetric, ExtensionMetricId, ExtensionWarningId,
|
||||
InvalidationScope, InvalidationSet, NormalizedMetric, QualityMetricKind, QualityMetricVector,
|
||||
SolveReport, SolveStatus, SolverBudget, SolverBudgetUsed, SolverConfig, SolverProfile,
|
||||
SolverState, SolverTier, SolverVersion, SolverWarning, SolverWarningKind, SpringSlotId,
|
||||
StubSolver, TieBreakingWeights,
|
||||
};
|
||||
pub use spatial::{
|
||||
BoundingBox, Margins, Point, Rect, ScaleContext, Size2D, StaffSpace, Transform2D,
|
||||
};
|
||||
pub use time_axis::{
|
||||
time_axis_of, AleatoricTimeAxis, MetricTimeAxis, ProportionalTimeAxis,
|
||||
SerializedRegisteredAxis, TimeAxis, TimeAxisKind, TimeAxisModel, TimeAxisRegistryId, TimePoint,
|
||||
TimeRange,
|
||||
};
|
||||
pub use vertical_band::{inter_staff_gap_id, VerticalBand, VerticalBandId, VerticalBandKind};
|
||||
|
||||
// Re-exported so doc links and the `OperationKindTag`-keyed edit-barrier API are
|
||||
// reachable from this crate's root.
|
||||
pub use epiphany_ops::OperationKindTag;
|
||||
// `StemDirection` (Agent B) is the payload of `EngravingDecisionKind::StemDirection`;
|
||||
// re-exported so callers constructing that decision need not also import from core.
|
||||
pub use epiphany_core::StemDirection;
|
||||
|
|
@ -0,0 +1,609 @@
|
|||
//! Stage 1 — `LogicalLayoutIR` (Chapter 7 §"LogicalLayoutIR").
|
||||
//!
|
||||
//! The structural projection of the score graph into layout objects, with
|
||||
//! engraving decisions notionally made but spatial positions unresolved. It is
|
||||
//! the output of the engraving pass and the input to the spacing pass.
|
||||
//!
|
||||
//! v0 projects every score-graph object that participates in the round-trip into
|
||||
//! a thin [`LayoutObject`] carrying its [`Provenance`]; the full composite-object
|
||||
//! taxonomy of Chapter 7 §"Layout Objects" (`NoteLayout`, `ChordLayout`, …) is a
|
||||
//! layered engraving concern past v0. What v0 *does* guarantee is the contract
|
||||
//! that matters for incremental layout: every object carries a complete
|
||||
//! provenance back-reference (its `source` plus every score-graph object whose
|
||||
//! change should invalidate it, Chapter 7 §7.1's requirement), and that
|
||||
//! provenance survives the whole pipeline.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use epiphany_core::{AnnotationAnchor, RegionId, Score, StaffId, TimeAnchor, TypedObjectId};
|
||||
use epiphany_determinism::{CanonicalEncode, DomainTag, Preimage};
|
||||
|
||||
use crate::engraving::{EngravingDecision, EngravingDecisionKind, EngravingOverride};
|
||||
use crate::provenance::{LayoutObjectId, Provenance};
|
||||
use crate::spatial::Transform2D;
|
||||
use crate::time_axis::{time_axis_of, TimeAxisModel};
|
||||
|
||||
/// A structural layout object before spacing (Chapter 7 §"Layout Objects"). v0
|
||||
/// carries its [`Provenance`] and the staff it belongs to (used to route it to
|
||||
/// the correct vertical band); the composite glyph content is materialized at
|
||||
/// the [`crate::ConstrainedLayoutIR`] stage.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct CompositeLayoutObject {
|
||||
pub provenance: Provenance,
|
||||
/// The staff this object belongs to, or `None` for region-level and
|
||||
/// score-level (cross-cutting / free-graphic) objects.
|
||||
pub staff: Option<StaffId>,
|
||||
}
|
||||
|
||||
pub type NoteLayout = CompositeLayoutObject;
|
||||
pub type ChordLayout = CompositeLayoutObject;
|
||||
pub type RestLayout = CompositeLayoutObject;
|
||||
pub type BeamGroupLayout = CompositeLayoutObject;
|
||||
pub type TupletDisplayLayout = CompositeLayoutObject;
|
||||
pub type SlurLayout = CompositeLayoutObject;
|
||||
pub type TieLayout = CompositeLayoutObject;
|
||||
pub type SpannerLayout = CompositeLayoutObject;
|
||||
pub type MarkerLayout = CompositeLayoutObject;
|
||||
pub type BarLineLayout = CompositeLayoutObject;
|
||||
pub type ClefLayout = CompositeLayoutObject;
|
||||
pub type KeySignatureLayout = CompositeLayoutObject;
|
||||
pub type TimeSignatureDisplayLayout = CompositeLayoutObject;
|
||||
pub type StaffLayout = CompositeLayoutObject;
|
||||
pub type TextLayout = CompositeLayoutObject;
|
||||
pub type GraphicLayout = CompositeLayoutObject;
|
||||
pub type MultimeasureRestLayout = CompositeLayoutObject;
|
||||
pub type CueLayout = CompositeLayoutObject;
|
||||
pub type TrajectoryLayout = CompositeLayoutObject;
|
||||
pub type GroupLayout = CompositeLayoutObject;
|
||||
|
||||
/// The complete Chapter 7 logical composite-object taxonomy. The prototype
|
||||
/// payload shared by each variant is provenance/staff ownership; companion
|
||||
/// engraving algorithms can refine the aliased payloads without changing the
|
||||
/// stage container or variant vocabulary.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum LayoutObject {
|
||||
Note(NoteLayout),
|
||||
Chord(ChordLayout),
|
||||
Rest(RestLayout),
|
||||
BeamGroup(BeamGroupLayout),
|
||||
TupletDisplay(TupletDisplayLayout),
|
||||
Slur(SlurLayout),
|
||||
Tie(TieLayout),
|
||||
Spanner(SpannerLayout),
|
||||
Marker(MarkerLayout),
|
||||
BarLine(BarLineLayout),
|
||||
Clef(ClefLayout),
|
||||
KeySignature(KeySignatureLayout),
|
||||
TimeSignatureDisplay(TimeSignatureDisplayLayout),
|
||||
Staff(StaffLayout),
|
||||
Text(TextLayout),
|
||||
Graphic(GraphicLayout),
|
||||
MultimeasureRest(MultimeasureRestLayout),
|
||||
Cue(CueLayout),
|
||||
Trajectory(TrajectoryLayout),
|
||||
Group(GroupLayout),
|
||||
}
|
||||
|
||||
impl LayoutObject {
|
||||
pub fn from_projection(provenance: Provenance, staff: Option<StaffId>) -> Self {
|
||||
let payload = CompositeLayoutObject { provenance, staff };
|
||||
match payload.provenance.source {
|
||||
TypedObjectId::Event(_) | TypedObjectId::Pitch(_) => LayoutObject::Note(payload),
|
||||
TypedObjectId::Beam(_) => LayoutObject::BeamGroup(payload),
|
||||
TypedObjectId::Tuplet(_) => LayoutObject::TupletDisplay(payload),
|
||||
TypedObjectId::Slur(_) => LayoutObject::Slur(payload),
|
||||
TypedObjectId::Tie(_) => LayoutObject::Tie(payload),
|
||||
TypedObjectId::Spanner(_) => LayoutObject::Spanner(payload),
|
||||
TypedObjectId::Marker(_) | TypedObjectId::RepeatStructure(_) => {
|
||||
LayoutObject::Marker(payload)
|
||||
}
|
||||
TypedObjectId::Measure(_) => LayoutObject::BarLine(payload),
|
||||
TypedObjectId::Staff(_) => LayoutObject::Staff(payload),
|
||||
TypedObjectId::GraphicObject(_) | TypedObjectId::GraphicGesture(_) => {
|
||||
LayoutObject::Graphic(payload)
|
||||
}
|
||||
TypedObjectId::LyricLine(_)
|
||||
| TypedObjectId::ChordSymbol(_)
|
||||
| TypedObjectId::Comment(_)
|
||||
| TypedObjectId::AnalyticalAnnotation(_) => LayoutObject::Text(payload),
|
||||
_ => LayoutObject::Group(payload),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn provenance(&self) -> &Provenance {
|
||||
self.payload().0
|
||||
}
|
||||
|
||||
pub fn staff(&self) -> Option<StaffId> {
|
||||
self.payload().1
|
||||
}
|
||||
|
||||
fn payload(&self) -> (&Provenance, Option<StaffId>) {
|
||||
let payload = match self {
|
||||
LayoutObject::Note(value)
|
||||
| LayoutObject::Chord(value)
|
||||
| LayoutObject::Rest(value)
|
||||
| LayoutObject::BeamGroup(value)
|
||||
| LayoutObject::TupletDisplay(value)
|
||||
| LayoutObject::Slur(value)
|
||||
| LayoutObject::Tie(value)
|
||||
| LayoutObject::Spanner(value)
|
||||
| LayoutObject::Marker(value)
|
||||
| LayoutObject::BarLine(value)
|
||||
| LayoutObject::Clef(value)
|
||||
| LayoutObject::KeySignature(value)
|
||||
| LayoutObject::TimeSignatureDisplay(value)
|
||||
| LayoutObject::Staff(value)
|
||||
| LayoutObject::Text(value)
|
||||
| LayoutObject::Graphic(value)
|
||||
| LayoutObject::MultimeasureRest(value)
|
||||
| LayoutObject::Cue(value)
|
||||
| LayoutObject::Trajectory(value)
|
||||
| LayoutObject::Group(value) => value,
|
||||
};
|
||||
(&payload.provenance, payload.staff)
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque identity of the score version projected into a layout pipeline.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
|
||||
pub struct ScoreVersion(pub [u8; 32]);
|
||||
|
||||
/// Region-local coordinate system and its canvas transform.
|
||||
#[derive(Copy, Clone, PartialEq, Debug, Default)]
|
||||
pub struct LocalCoordinateSystem {
|
||||
pub transform: Transform2D,
|
||||
}
|
||||
|
||||
/// The globally identified staff bands occupied by a logical region.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct VerticalExtent {
|
||||
pub staves: Vec<StaffId>,
|
||||
}
|
||||
|
||||
/// A region projected into layout space, carrying its time axis (Chapter 7
|
||||
/// §"Layout Regions"). All region kinds use this one container type
|
||||
/// (Chapter 7 §"Region Uniformity").
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct LayoutRegion {
|
||||
pub provenance: Provenance,
|
||||
pub coordinate_system: LocalCoordinateSystem,
|
||||
pub time_axis: TimeAxisModel,
|
||||
pub vertical_extent: VerticalExtent,
|
||||
pub objects: Vec<LayoutObject>,
|
||||
}
|
||||
|
||||
/// A spanning object whose dependencies occupy more than one score region.
|
||||
/// `regions` is in score-canvas order and identifies the complete span; the
|
||||
/// spacing pass places its prototype glyph at the first anchored region while
|
||||
/// preserving all regions in provenance dependencies.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct CrossRegionObject {
|
||||
pub provenance: Provenance,
|
||||
pub regions: Vec<RegionId>,
|
||||
pub staff: Option<StaffId>,
|
||||
}
|
||||
|
||||
/// The logical IR: the structural projection of the score graph (Chapter 7
|
||||
/// §"LogicalLayoutIR"), plus the engraving decisions made during this pass.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct LogicalLayoutIR {
|
||||
pub source: ScoreVersion,
|
||||
pub regions: Vec<LayoutRegion>,
|
||||
/// Engraving decisions made during the engraving pass (Chapter 7
|
||||
/// §"Engraving Decisions"), carried forward through the pipeline.
|
||||
pub engraving_decisions: Vec<EngravingDecision>,
|
||||
/// User engraving overrides projected from the score graph. Agent B's
|
||||
/// current graph exposes no override registry, so the projection is empty.
|
||||
pub overrides: Vec<EngravingOverride>,
|
||||
/// Objects spanning two or more layout regions.
|
||||
pub cross_region: Vec<CrossRegionObject>,
|
||||
}
|
||||
|
||||
/// Projects a score graph into [`LogicalLayoutIR`].
|
||||
///
|
||||
/// Every layout object carries a [`Provenance`] whose `source` is the
|
||||
/// score-graph object it represents, with dependency back-references for
|
||||
/// incremental layout. One [`LayoutRegion`] per score region carries that
|
||||
/// region's [`TimeAxisModel`]. The set of projected sources is exactly
|
||||
/// [`crate::laid_out_object_ids`] — the two are kept in lockstep so the
|
||||
/// round-trip's source-set surjection (each source recovered; manifestation
|
||||
/// multiplicity carried by distinct stable ids) holds.
|
||||
///
|
||||
/// A score-graph object manifested within a region is laid out **per
|
||||
/// manifestation**: its stable id derives from `(source, region)`
|
||||
/// ([`Provenance::manifested`]), so a staff manifested in two time-disjoint
|
||||
/// regions (Chapter 5 §"Region Overlap and Concurrency") yields *two* distinct
|
||||
/// layout objects — both visual staves are preserved, neither is dropped. A
|
||||
/// stable-id collision (the same `(source, region)` reached twice, e.g. a staff
|
||||
/// listed twice in one staff extent) is de-duplicated.
|
||||
pub fn to_logical(score: &Score) -> LogicalLayoutIR {
|
||||
let mut regions = Vec::new();
|
||||
let mut engraving_decisions = Vec::new();
|
||||
let mut cross_region = Vec::new();
|
||||
let mut seen: BTreeSet<LayoutObjectId> = BTreeSet::new();
|
||||
|
||||
for region in &score.canvas.regions {
|
||||
let region_id = region.id;
|
||||
let mut objects = Vec::new();
|
||||
let mut push =
|
||||
|source: TypedObjectId, dependencies: Vec<TypedObjectId>, staff: Option<StaffId>| {
|
||||
let provenance = Provenance::manifested(source, region_id, dependencies);
|
||||
if seen.insert(provenance.stable_id) {
|
||||
objects.push(LayoutObject::from_projection(provenance, staff));
|
||||
}
|
||||
};
|
||||
|
||||
// Staves manifested in this region (via the staff extent).
|
||||
for staff_id in ®ion.staff_extent.staves {
|
||||
push(TypedObjectId::Staff(*staff_id), vec![], Some(*staff_id));
|
||||
}
|
||||
|
||||
// Staff instances, voices, and their events + pitches — all belong to
|
||||
// the instance's staff.
|
||||
for si in region.staff_instances() {
|
||||
let staff = Some(si.staff);
|
||||
let si_src = TypedObjectId::StaffInstance(si.id);
|
||||
push(si_src, vec![TypedObjectId::Staff(si.staff)], staff);
|
||||
for voice in &si.voices {
|
||||
let v_src = TypedObjectId::Voice(voice.id);
|
||||
push(v_src, vec![si_src], staff);
|
||||
for eid in &voice.events {
|
||||
let e_src = TypedObjectId::Event(*eid);
|
||||
// The event's pitches become its invalidation dependencies.
|
||||
let pitches = identified_pitch_ids(score, *eid);
|
||||
let mut deps = vec![v_src];
|
||||
deps.extend(pitches.iter().copied().map(TypedObjectId::Pitch));
|
||||
push(e_src, deps, staff);
|
||||
// And the pitches themselves, as their own objects.
|
||||
for pid in pitches {
|
||||
push(TypedObjectId::Pitch(pid), vec![e_src], staff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Measures, per staff instance (Chapter 5 §"Measures").
|
||||
for si in region.staff_instances() {
|
||||
for measure in &si.measures {
|
||||
push(
|
||||
TypedObjectId::Measure(measure.id),
|
||||
vec![TypedObjectId::StaffInstance(si.id)],
|
||||
Some(si.staff),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Free-graphic and hybrid-overlay graphic objects (Chapter 5 §"Graphic
|
||||
// Content"; Chapter 7 §"Region Uniformity"). These are region-level, not
|
||||
// staff-owned.
|
||||
for go in region.content.graphic_objects() {
|
||||
push(TypedObjectId::GraphicObject(go.id), vec![], None);
|
||||
}
|
||||
|
||||
let r_src = TypedObjectId::Region(region.id);
|
||||
let region_provenance = Provenance::projected(
|
||||
r_src,
|
||||
region
|
||||
.staff_extent
|
||||
.staves
|
||||
.iter()
|
||||
.map(|s| TypedObjectId::Staff(*s))
|
||||
.collect(),
|
||||
);
|
||||
// Each region notionally begins a system: record that decision against
|
||||
// the region's stable layout id (Chapter 7 §"Engraving Decisions").
|
||||
engraving_decisions.push(EngravingDecision::automatic(
|
||||
region_provenance.stable_id,
|
||||
EngravingDecisionKind::SystemBreak,
|
||||
));
|
||||
regions.push(LayoutRegion {
|
||||
provenance: region_provenance,
|
||||
coordinate_system: LocalCoordinateSystem::default(),
|
||||
time_axis: time_axis_of(region),
|
||||
vertical_extent: VerticalExtent {
|
||||
staves: region.staff_extent.staves.clone(),
|
||||
},
|
||||
objects,
|
||||
});
|
||||
}
|
||||
|
||||
// Place spanning structures according to the locations of their real
|
||||
// dependencies. A single-region object joins that region and, when all
|
||||
// located dependencies agree, that staff. A multi-region object uses the
|
||||
// dedicated cross-region collection instead of being misfiled in region 0.
|
||||
for (src, deps) in cross_cutting_objects(score) {
|
||||
let provenance = Provenance::projected(src, deps.clone());
|
||||
if !seen.insert(provenance.stable_id) {
|
||||
continue;
|
||||
}
|
||||
let mut anchored_regions = Vec::new();
|
||||
let mut anchored_staves = BTreeSet::new();
|
||||
for region in ®ions {
|
||||
let TypedObjectId::Region(region_id) = region.provenance.source else {
|
||||
continue;
|
||||
};
|
||||
let mut touches_region = deps.contains(®ion.provenance.source);
|
||||
for object in ®ion.objects {
|
||||
if deps.contains(&object.provenance().source) {
|
||||
touches_region = true;
|
||||
if let Some(staff) = object.staff() {
|
||||
anchored_staves.insert(staff);
|
||||
}
|
||||
}
|
||||
}
|
||||
if touches_region {
|
||||
anchored_regions.push(region_id);
|
||||
}
|
||||
}
|
||||
let staff = if anchored_staves.len() == 1 {
|
||||
anchored_staves.iter().next().copied()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match anchored_regions.as_slice() {
|
||||
[region_id] => {
|
||||
let region = regions
|
||||
.iter_mut()
|
||||
.find(|region| region.provenance.source == TypedObjectId::Region(*region_id))
|
||||
.expect("anchored region was collected from this vector");
|
||||
region
|
||||
.objects
|
||||
.push(LayoutObject::from_projection(provenance, staff));
|
||||
}
|
||||
[] => {
|
||||
// Wall-clock-only annotations have no graph anchor from which
|
||||
// to infer a region; retain deterministic fallback placement.
|
||||
if let Some(first) = regions.first_mut() {
|
||||
first
|
||||
.objects
|
||||
.push(LayoutObject::from_projection(provenance, staff));
|
||||
}
|
||||
}
|
||||
_ => cross_region.push(CrossRegionObject {
|
||||
provenance,
|
||||
regions: anchored_regions,
|
||||
staff,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
let source = derive_score_version(®ions, &cross_region);
|
||||
LogicalLayoutIR {
|
||||
source,
|
||||
regions,
|
||||
engraving_decisions,
|
||||
overrides: Vec::new(),
|
||||
cross_region,
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_score_version(
|
||||
regions: &[LayoutRegion],
|
||||
cross_region: &[CrossRegionObject],
|
||||
) -> ScoreVersion {
|
||||
let mut preimage = Preimage::new(DomainTag::CONFLICT);
|
||||
preimage.push_bytes(b"layout-score-version");
|
||||
for region in regions {
|
||||
preimage.push_bytes(®ion.provenance.source.to_canonical_bytes());
|
||||
match ®ion.time_axis {
|
||||
TimeAxisModel::Metric(_) => {
|
||||
preimage.push_u64_le(0);
|
||||
}
|
||||
TimeAxisModel::Proportional(axis) => {
|
||||
preimage.push_u64_le(1);
|
||||
preimage.push_u64_le(axis.duration_ns as u64);
|
||||
preimage.push_u64_le(axis.space_per_second.0.to_bits() as u64);
|
||||
}
|
||||
TimeAxisModel::Aleatoric(_) => {
|
||||
preimage.push_u64_le(2);
|
||||
}
|
||||
TimeAxisModel::Registered(id, payload) => {
|
||||
preimage.push_u64_le(3);
|
||||
preimage.push_u64_le((id.0 >> 64) as u64);
|
||||
preimage.push_u64_le(id.0 as u64);
|
||||
preimage.push_bytes(&payload.0);
|
||||
}
|
||||
}
|
||||
for object in ®ion.objects {
|
||||
preimage.push_u64_le((object.provenance().stable_id.0 >> 64) as u64);
|
||||
preimage.push_u64_le(object.provenance().stable_id.0 as u64);
|
||||
}
|
||||
}
|
||||
for object in cross_region {
|
||||
preimage.push_u64_le((object.provenance.stable_id.0 >> 64) as u64);
|
||||
preimage.push_u64_le(object.provenance.stable_id.0 as u64);
|
||||
}
|
||||
ScoreVersion(*preimage.finish().as_bytes())
|
||||
}
|
||||
|
||||
/// The identified-pitch ids of an event, in arena order (empty if the event is
|
||||
/// absent or carries no pitches).
|
||||
pub(crate) fn identified_pitch_ids(
|
||||
score: &Score,
|
||||
event: epiphany_core::EventId,
|
||||
) -> Vec<epiphany_core::PitchId> {
|
||||
let mut ids = Vec::new();
|
||||
if let Some(event) = score.events.get(event) {
|
||||
let mut buf = Vec::new();
|
||||
event.collect_identified_pitches(&mut buf);
|
||||
ids.extend(buf.iter().map(|p| p.id));
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// The score-graph object a [`TimeAnchor`] depends on, if any (a wall-clock
|
||||
/// anchor depends on no object). Anchors are real invalidation dependencies: if
|
||||
/// the anchored event/measure/region changes, the spanning object must relayout.
|
||||
fn time_anchor_dep(anchor: &TimeAnchor) -> Option<TypedObjectId> {
|
||||
match anchor {
|
||||
TimeAnchor::Event { id, .. } => Some(TypedObjectId::Event(*id)),
|
||||
TimeAnchor::Measure { id, .. } => Some(TypedObjectId::Measure(*id)),
|
||||
TimeAnchor::Region { id, .. } => Some(TypedObjectId::Region(*id)),
|
||||
TimeAnchor::WallClock { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The score-graph objects an [`AnnotationAnchor`] depends on.
|
||||
fn annotation_anchor_deps(anchor: &AnnotationAnchor) -> Vec<TypedObjectId> {
|
||||
match anchor {
|
||||
AnnotationAnchor::Event(id) => vec![TypedObjectId::Event(*id)],
|
||||
AnnotationAnchor::Range { start, end } => [start, end]
|
||||
.iter()
|
||||
.filter_map(|a| time_anchor_dep(a))
|
||||
.collect(),
|
||||
AnnotationAnchor::Region(id) => vec![TypedObjectId::Region(*id)],
|
||||
}
|
||||
}
|
||||
|
||||
/// The score's cross-cutting objects as `(source, dependencies)` pairs, in the
|
||||
/// canonical order the projection emits them. Every cross-cutting registry
|
||||
/// (Chapter 5 §"Cross-Cutting Structures") is projected, and each object's
|
||||
/// dependencies are its real references — member events, anchored objects, and
|
||||
/// attached staves — so an edit to any of them invalidates the spanning layout
|
||||
/// object (Chapter 7 §"Invalidation Rules").
|
||||
pub(crate) fn cross_cutting_objects(score: &Score) -> Vec<(TypedObjectId, Vec<TypedObjectId>)> {
|
||||
let cc = &score.cross_cutting;
|
||||
let mut out: Vec<(TypedObjectId, Vec<TypedObjectId>)> = Vec::new();
|
||||
for t in &cc.ties {
|
||||
out.push((
|
||||
TypedObjectId::Tie(t.id),
|
||||
vec![
|
||||
TypedObjectId::Event(t.start_event),
|
||||
TypedObjectId::Event(t.end_event),
|
||||
],
|
||||
));
|
||||
}
|
||||
for s in &cc.slurs {
|
||||
out.push((
|
||||
TypedObjectId::Slur(s.id),
|
||||
vec![
|
||||
TypedObjectId::Event(s.start_event),
|
||||
TypedObjectId::Event(s.end_event),
|
||||
],
|
||||
));
|
||||
}
|
||||
for b in &cc.beams {
|
||||
out.push((
|
||||
TypedObjectId::Beam(b.id),
|
||||
b.events.iter().map(|e| TypedObjectId::Event(*e)).collect(),
|
||||
));
|
||||
}
|
||||
for tu in &cc.tuplets {
|
||||
out.push((
|
||||
TypedObjectId::Tuplet(tu.id),
|
||||
tu.members
|
||||
.iter()
|
||||
.map(|e| TypedObjectId::Event(*e))
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
for sp in &cc.spanners {
|
||||
let mut deps: Vec<TypedObjectId> = [&sp.start, &sp.end]
|
||||
.iter()
|
||||
.filter_map(|a| time_anchor_dep(a))
|
||||
.collect();
|
||||
deps.extend(sp.staves.iter().map(|s| TypedObjectId::Staff(*s)));
|
||||
out.push((TypedObjectId::Spanner(sp.id), deps));
|
||||
}
|
||||
for mk in &cc.markers {
|
||||
out.push((
|
||||
TypedObjectId::Marker(mk.id),
|
||||
time_anchor_dep(&mk.anchor).into_iter().collect(),
|
||||
));
|
||||
}
|
||||
for rp in &cc.repeats {
|
||||
let deps = [&rp.start, &rp.end]
|
||||
.iter()
|
||||
.filter_map(|a| time_anchor_dep(a))
|
||||
.collect();
|
||||
out.push((TypedObjectId::RepeatStructure(rp.id), deps));
|
||||
}
|
||||
for an in &cc.analytical {
|
||||
let mut deps = annotation_anchor_deps(&an.anchor);
|
||||
deps.extend(an.layer.map(TypedObjectId::AnalysisLayer));
|
||||
out.push((TypedObjectId::AnalyticalAnnotation(an.id), deps));
|
||||
}
|
||||
for cm in &cc.comments {
|
||||
out.push((
|
||||
TypedObjectId::Comment(cm.id),
|
||||
annotation_anchor_deps(&cm.anchor),
|
||||
));
|
||||
}
|
||||
for gg in &cc.graphic_gestures {
|
||||
out.push((
|
||||
TypedObjectId::GraphicGesture(gg.id),
|
||||
gg.objects
|
||||
.iter()
|
||||
.map(|o| TypedObjectId::GraphicObject(*o))
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
for ly in &cc.lyrics {
|
||||
out.push((
|
||||
TypedObjectId::LyricLine(ly.id),
|
||||
ly.events.iter().map(|e| TypedObjectId::Event(*e)).collect(),
|
||||
));
|
||||
}
|
||||
for ch in &cc.chord_symbols {
|
||||
out.push((
|
||||
TypedObjectId::ChordSymbol(ch.id),
|
||||
time_anchor_dep(&ch.anchor).into_iter().collect(),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use epiphany_core::generators::valid_score_rich;
|
||||
use epiphany_core::{AnchorOffset, RegionEdge, Spanner, SpannerId, TimeAnchor};
|
||||
|
||||
#[test]
|
||||
fn spanning_object_uses_cross_region_collection() {
|
||||
let mut score = valid_score_rich(5);
|
||||
let first = score.canvas.regions[0].id;
|
||||
let second = score.canvas.regions[1].id;
|
||||
let first_staff = score.canvas.regions[0].staff_extent.staves[0];
|
||||
let second_staff = score.canvas.regions[1].staff_extent.staves[0];
|
||||
let id: SpannerId = score.identity.mint();
|
||||
score.cross_cutting.spanners.push(Spanner {
|
||||
id,
|
||||
start: TimeAnchor::Region {
|
||||
id: first,
|
||||
edge: RegionEdge::Start,
|
||||
offset: AnchorOffset::Zero,
|
||||
},
|
||||
end: TimeAnchor::Region {
|
||||
id: second,
|
||||
edge: RegionEdge::End,
|
||||
offset: AnchorOffset::Zero,
|
||||
},
|
||||
staves: vec![first_staff, second_staff],
|
||||
});
|
||||
|
||||
let logical = to_logical(&score);
|
||||
let spanning = logical
|
||||
.cross_region
|
||||
.iter()
|
||||
.find(|object| object.provenance.source == TypedObjectId::Spanner(id))
|
||||
.expect("cross-region spanner must not be assigned to region zero");
|
||||
assert_eq!(spanning.regions, vec![first, second]);
|
||||
assert_eq!(spanning.staff, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_region_tie_is_attached_to_its_real_staff() {
|
||||
let score = valid_score_rich(6);
|
||||
let tie = score.cross_cutting.ties[0].id;
|
||||
let expected_staff = score.canvas.regions[0].staff_extent.staves[0];
|
||||
let logical = to_logical(&score);
|
||||
let object = logical.regions[0]
|
||||
.objects
|
||||
.iter()
|
||||
.find(|object| object.provenance().source == TypedObjectId::Tie(tie))
|
||||
.expect("tie must be in its events' region");
|
||||
assert_eq!(object.staff(), Some(expected_staff));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
//! Provenance back-references (Chapter 7 §"Provenance").
|
||||
//!
|
||||
//! Every IR object at every stage carries a [`Provenance`] tracing it back to
|
||||
//! the score-graph object that originated it. This is what makes selection,
|
||||
//! error reporting, and — above all — *incremental layout* possible: an edit to
|
||||
//! a score-graph object invalidates only the IR objects whose provenance lists
|
||||
//! it (Chapter 7 §"Incremental Layout"). The round-trip harness proves the
|
||||
//! complete provenance of every object survives the whole pipeline unchanged.
|
||||
|
||||
use epiphany_core::{RegionId, TypedObjectId};
|
||||
use epiphany_determinism::{blake3_256, trunc128, DomainTag, Preimage};
|
||||
|
||||
/// An IR object's stable identifier across re-layouts where its source is
|
||||
/// unchanged (Chapter 7 §"Provenance"). Carried unchanged through every stage.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct LayoutObjectId(pub u128);
|
||||
|
||||
/// A registry id for an extension-defined [`SynthesisKind::Registered`]
|
||||
/// (Chapter 7: `SynthesisRegistryId`). v0 hardcodes the core registry and
|
||||
/// loads no external registries (QUICKSTART "Don't … implement extension
|
||||
/// registries"); the id is an opaque value.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct SynthesisRegistryId(pub u128);
|
||||
|
||||
/// A stable semantic key distinguishing multiple synthesized layout objects
|
||||
/// with the same source and synthesis kind. Callers derive this from the
|
||||
/// object's role (for example, the affected pitch and cancellation position),
|
||||
/// never from traversal order.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct SynthesisInstanceKey(pub u128);
|
||||
|
||||
/// Why an IR object exists without a direct score-graph source (Chapter 7
|
||||
/// §"Provenance"). Engraver-synthesized objects MUST declare one; the variant
|
||||
/// set is the spec's normative `SynthesisKind`.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum SynthesisKind {
|
||||
/// An accidental inserted to cancel a prior alteration.
|
||||
CancellationAccidental,
|
||||
/// A natural sign generated by key-signature rules.
|
||||
KeySignatureNatural,
|
||||
/// A rest inserted to fill a voice gap.
|
||||
GeneratedRest,
|
||||
/// A system or page break selected by the engraver.
|
||||
EngravedBreak,
|
||||
/// A multimeasure rest combining consecutive empty measures.
|
||||
MultimeasureRest,
|
||||
/// A cautionary key or time signature at a system break.
|
||||
Cautionary,
|
||||
/// A custom synthesis declared by a layout extension.
|
||||
Registered(SynthesisRegistryId),
|
||||
}
|
||||
|
||||
/// The provenance back-reference every layout object carries (Chapter 7
|
||||
/// §"Provenance"). The pipeline's contract is that this record — `source`,
|
||||
/// `synthesis`, `dependencies`, and `stable_id` together — is preserved across
|
||||
/// every stage transition (Chapter 7 §"Stage Contracts").
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct Provenance {
|
||||
/// The score-graph object this IR object represents or derives from.
|
||||
pub source: TypedObjectId,
|
||||
/// For engraver-synthesized objects, the synthesis kind; `None` for objects
|
||||
/// with a direct score-graph source.
|
||||
pub synthesis: Option<SynthesisKind>,
|
||||
/// Every score-graph object whose change should invalidate this layout
|
||||
/// object (the incremental-layout dependency set, Chapter 7
|
||||
/// §"Invalidation Rules").
|
||||
pub dependencies: Vec<TypedObjectId>,
|
||||
/// Stable across re-layouts where the source is unchanged.
|
||||
pub stable_id: LayoutObjectId,
|
||||
}
|
||||
|
||||
impl Provenance {
|
||||
/// A provenance for an object projected directly from a score-graph
|
||||
/// `source`, with the given invalidation `dependencies` and no synthesis.
|
||||
/// The stable id is a pure function of the source — use this for objects
|
||||
/// with a single manifestation (a region, a score-level cross-cutting
|
||||
/// object). For objects manifested *within* a region, use
|
||||
/// [`Provenance::manifested`].
|
||||
pub fn projected(source: TypedObjectId, dependencies: Vec<TypedObjectId>) -> Self {
|
||||
Provenance {
|
||||
stable_id: stable_layout_id(&source),
|
||||
source,
|
||||
synthesis: None,
|
||||
dependencies,
|
||||
}
|
||||
}
|
||||
|
||||
/// A provenance for an object **manifested within `region`** (Chapter 5
|
||||
/// §"Staff-Based Content": a staff instance manifests a globally-identified
|
||||
/// staff *for the duration of the region*). The stable id derives from both
|
||||
/// the source and the region, so the same score-graph object manifested in
|
||||
/// two time-disjoint regions (Chapter 5 §"Region Overlap and Concurrency")
|
||||
/// yields two distinct layout objects, not one. The id is still independent
|
||||
/// of traversal position, so it is stable across relayouts.
|
||||
pub fn manifested(
|
||||
source: TypedObjectId,
|
||||
region: RegionId,
|
||||
dependencies: Vec<TypedObjectId>,
|
||||
) -> Self {
|
||||
Provenance {
|
||||
stable_id: manifestation_layout_id(&source, region),
|
||||
source,
|
||||
synthesis: None,
|
||||
dependencies,
|
||||
}
|
||||
}
|
||||
|
||||
/// A provenance for an engraver-synthesized object: it still names a
|
||||
/// `source` (the score-graph object it derives from), declares a
|
||||
/// [`SynthesisKind`], and lists its invalidation `dependencies`.
|
||||
///
|
||||
/// The stable id incorporates the **synthesis kind** and a semantic key, so
|
||||
/// distinct synthesized objects from the same source never collide — neither
|
||||
/// a cancellation and a cautionary (distinct kinds) nor two cautionaries
|
||||
/// (distinct keys). The key must remain stable when unrelated synthesized
|
||||
/// objects are inserted, removed, or reordered.
|
||||
pub fn synthesized(
|
||||
source: TypedObjectId,
|
||||
synthesis: SynthesisKind,
|
||||
instance: SynthesisInstanceKey,
|
||||
dependencies: Vec<TypedObjectId>,
|
||||
) -> Self {
|
||||
Provenance {
|
||||
stable_id: synthesized_layout_id(&source, synthesis, instance),
|
||||
source,
|
||||
synthesis: Some(synthesis),
|
||||
dependencies,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives the stable layout id of an object from its score-graph **source**
|
||||
/// alone — never from its traversal position.
|
||||
///
|
||||
/// This is what makes the id stable across relayouts (Chapter 7 §"Provenance":
|
||||
/// stable across re-layouts where the source is unchanged): inserting,
|
||||
/// removing, or reordering other objects cannot change any object's stable id,
|
||||
/// because each depends solely on its own source's canonical bytes. v0 derives
|
||||
/// it as `trunc128(BLAKE3(source.canonical_bytes()))`; see `DECISIONS.md` for
|
||||
/// why this is not (yet) domain-separated.
|
||||
pub fn stable_layout_id(source: &TypedObjectId) -> LayoutObjectId {
|
||||
LayoutObjectId(trunc128(&blake3_256(&source.canonical_bytes())))
|
||||
}
|
||||
|
||||
/// Derives the stable layout id of an object manifested **within a region**,
|
||||
/// from its source *and* that region (Chapter 5 §"Region Overlap and
|
||||
/// Concurrency"). Distinct regions give the same source distinct manifestation
|
||||
/// ids, so multiple manifestations of one score-graph object are distinct layout
|
||||
/// objects; the id remains independent of traversal position.
|
||||
pub fn manifestation_layout_id(source: &TypedObjectId, region: RegionId) -> LayoutObjectId {
|
||||
let mut bytes = source.canonical_bytes();
|
||||
bytes.extend_from_slice(®ion.canonical_bytes());
|
||||
LayoutObjectId(trunc128(&blake3_256(&bytes)))
|
||||
}
|
||||
|
||||
/// Derives the stable layout id of an **engraver-synthesized** object from its
|
||||
/// `source` and its [`SynthesisKind`], so distinct synthesis kinds from one
|
||||
/// source do not collide (Chapter 7 §"Provenance"). Domain-tagged via the
|
||||
/// borrowed `MUSCCONF` tag with a `synthesized` discriminator prefix (the
|
||||
/// determinism crate defines no layout tag — see `DECISIONS.md`).
|
||||
pub fn synthesized_layout_id(
|
||||
source: &TypedObjectId,
|
||||
kind: SynthesisKind,
|
||||
instance: SynthesisInstanceKey,
|
||||
) -> LayoutObjectId {
|
||||
let mut p = Preimage::new(DomainTag::CONFLICT);
|
||||
p.push_bytes(b"synthesized-layout");
|
||||
p.push_bytes(&source.canonical_bytes());
|
||||
let (disc, reg) = match kind {
|
||||
SynthesisKind::CancellationAccidental => (0u64, 0u128),
|
||||
SynthesisKind::KeySignatureNatural => (1, 0),
|
||||
SynthesisKind::GeneratedRest => (2, 0),
|
||||
SynthesisKind::EngravedBreak => (3, 0),
|
||||
SynthesisKind::MultimeasureRest => (4, 0),
|
||||
SynthesisKind::Cautionary => (5, 0),
|
||||
SynthesisKind::Registered(id) => (6, id.0),
|
||||
};
|
||||
p.push_u64_le(disc);
|
||||
p.push_u64_le((reg >> 64) as u64);
|
||||
p.push_u64_le(reg as u64);
|
||||
p.push_u64_le((instance.0 >> 64) as u64);
|
||||
p.push_u64_le(instance.0 as u64);
|
||||
LayoutObjectId(p.finish_trunc128())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use epiphany_core::{EventId, RegionId};
|
||||
|
||||
#[test]
|
||||
fn stable_id_is_a_pure_function_of_source() {
|
||||
let src = TypedObjectId::Event(EventId::from_raw(0x1234));
|
||||
assert_eq!(stable_layout_id(&src), stable_layout_id(&src));
|
||||
assert_eq!(
|
||||
Provenance::projected(src, vec![]).stable_id,
|
||||
stable_layout_id(&src)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_sources_have_distinct_stable_ids() {
|
||||
let a = TypedObjectId::Event(EventId::from_raw(1));
|
||||
let b = TypedObjectId::Region(RegionId::from_raw(1));
|
||||
assert_ne!(stable_layout_id(&a), stable_layout_id(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifestations_in_distinct_regions_are_distinct() {
|
||||
use epiphany_core::StaffId;
|
||||
let staff = TypedObjectId::Staff(StaffId::from_raw(1));
|
||||
let a = RegionId::from_raw(10);
|
||||
let b = RegionId::from_raw(11);
|
||||
// Same source, different regions → different manifestation ids.
|
||||
assert_ne!(
|
||||
manifestation_layout_id(&staff, a),
|
||||
manifestation_layout_id(&staff, b)
|
||||
);
|
||||
// A manifestation id is stable for the same (source, region).
|
||||
assert_eq!(
|
||||
manifestation_layout_id(&staff, a),
|
||||
Provenance::manifested(staff, a, vec![]).stable_id
|
||||
);
|
||||
// It differs from the context-free source-only id.
|
||||
assert_ne!(manifestation_layout_id(&staff, a), stable_layout_id(&staff));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesized_objects_from_one_source_do_not_collide() {
|
||||
let src = TypedObjectId::Event(EventId::from_raw(1));
|
||||
let cancel = Provenance::synthesized(
|
||||
src,
|
||||
SynthesisKind::CancellationAccidental,
|
||||
SynthesisInstanceKey(0),
|
||||
vec![],
|
||||
);
|
||||
let caution = Provenance::synthesized(
|
||||
src,
|
||||
SynthesisKind::Cautionary,
|
||||
SynthesisInstanceKey(0),
|
||||
vec![],
|
||||
);
|
||||
// Same source, different synthesis kinds → distinct ids.
|
||||
assert_ne!(cancel.stable_id, caution.stable_id);
|
||||
// Same source AND kind, different semantic keys → still distinct ids.
|
||||
let caution_b = Provenance::synthesized(
|
||||
src,
|
||||
SynthesisKind::Cautionary,
|
||||
SynthesisInstanceKey(1),
|
||||
vec![],
|
||||
);
|
||||
assert_ne!(caution.stable_id, caution_b.stable_id);
|
||||
// And distinct from the plain source-only id.
|
||||
assert_ne!(cancel.stable_id, stable_layout_id(&src));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
//! Stage 4 — `RenderIR` (Chapter 7 §"RenderIR (Interface Only)").
|
||||
//!
|
||||
//! The renderer's input. Its full specification (page templates, draw calls,
|
||||
//! rasterization) is delivered separately; this module defines only the
|
||||
//! interface contract and the one obligation that crosses it: "every renderer
|
||||
//! primitive MUST be traceable to its originating `ResolvedGlyph` (and therefore
|
||||
//! to its score graph source)" (Chapter 7 §"RenderIR"). v0 implements that
|
||||
//! interface — provenance and position flow through — and performs **no actual
|
||||
//! rendering** (QUICKSTART, Agent E).
|
||||
|
||||
use crate::provenance::Provenance;
|
||||
use crate::resolved::ResolvedLayoutIR;
|
||||
use crate::spatial::{Point, ScaleContext};
|
||||
use crate::{BoundingBox, GlyphReference, GlyphStyle, Transform2D};
|
||||
|
||||
/// A single renderer primitive (Chapter 7 §"RenderIR"). Interface only — it
|
||||
/// carries just enough to prove the provenance-preservation contract: every
|
||||
/// primitive traces back to its `ResolvedGlyph`'s source.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct RenderPrimitive {
|
||||
pub provenance: Provenance,
|
||||
/// The SMuFL glyph to draw — what the renderer needs to know to produce
|
||||
/// output (Chapter 7 §"RenderIR": every primitive is traceable to its
|
||||
/// `ResolvedGlyph`, including its glyph reference).
|
||||
pub glyph: GlyphReference,
|
||||
pub position: Point,
|
||||
pub transform: Option<Transform2D>,
|
||||
pub bounding_box: BoundingBox,
|
||||
pub style: GlyphStyle,
|
||||
pub layer: i32,
|
||||
}
|
||||
|
||||
/// The render IR interface output (Chapter 7 §"RenderIR").
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct RenderIR {
|
||||
pub primitives: Vec<RenderPrimitive>,
|
||||
}
|
||||
|
||||
/// The render target (Chapter 7 §"RenderIR": `RenderConfiguration.target`).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum RenderTarget {
|
||||
Pdf,
|
||||
Svg,
|
||||
Screen,
|
||||
Print,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum ColorSpace {
|
||||
Srgb,
|
||||
DisplayP3,
|
||||
Cmyk,
|
||||
Grayscale,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct ColorConfiguration {
|
||||
pub color_space: ColorSpace,
|
||||
pub embed_profile: bool,
|
||||
}
|
||||
|
||||
impl Default for ColorConfiguration {
|
||||
fn default() -> Self {
|
||||
ColorConfiguration {
|
||||
color_space: ColorSpace::Srgb,
|
||||
embed_profile: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct RasterizationConfiguration {
|
||||
pub antialias: bool,
|
||||
pub dpi: u32,
|
||||
}
|
||||
|
||||
impl Default for RasterizationConfiguration {
|
||||
fn default() -> Self {
|
||||
RasterizationConfiguration {
|
||||
antialias: true,
|
||||
dpi: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render configuration (Chapter 7 §"RenderIR": `RenderConfiguration`).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct RenderConfiguration {
|
||||
pub target: RenderTarget,
|
||||
pub color: ColorConfiguration,
|
||||
pub rasterization: RasterizationConfiguration,
|
||||
}
|
||||
|
||||
/// The render-projection interface (Chapter 7 §"RenderIR": `RenderIRProducer`).
|
||||
/// The spec's `produce(resolved, scale, config)` signature is honored; v0
|
||||
/// performs **no** rendering (the primitive vocabulary belongs to the
|
||||
/// out-of-core renderer) but guarantees provenance and glyph identity flow
|
||||
/// across the boundary.
|
||||
pub trait RenderIRProducer {
|
||||
/// Converts resolved IR to renderer-bound primitives, preserving provenance.
|
||||
fn produce(
|
||||
&self,
|
||||
resolved: &ResolvedLayoutIR,
|
||||
scale: ScaleContext,
|
||||
config: RenderConfiguration,
|
||||
) -> RenderIR;
|
||||
}
|
||||
|
||||
/// The v0 render producer: one primitive per resolved glyph, provenance, glyph
|
||||
/// identity, and position preserved, no rendering performed. The `scale` and
|
||||
/// `config` are accepted (interface fidelity) but not consumed in v0.
|
||||
pub struct PassthroughRenderProducer;
|
||||
|
||||
impl RenderIRProducer for PassthroughRenderProducer {
|
||||
fn produce(
|
||||
&self,
|
||||
resolved: &ResolvedLayoutIR,
|
||||
_scale: ScaleContext,
|
||||
_config: RenderConfiguration,
|
||||
) -> RenderIR {
|
||||
to_render(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
/// The RenderIR interface call (Chapter 7 §"RenderIR"): one primitive per
|
||||
/// resolved glyph, provenance and position preserved.
|
||||
pub fn to_render(resolved: &ResolvedLayoutIR) -> RenderIR {
|
||||
RenderIR {
|
||||
primitives: resolved
|
||||
.glyphs
|
||||
.iter()
|
||||
.map(|g| RenderPrimitive {
|
||||
provenance: g.provenance.clone(),
|
||||
glyph: g.glyph.clone(),
|
||||
position: g.position,
|
||||
transform: g.transform,
|
||||
bounding_box: g.bounding_box,
|
||||
style: g.style,
|
||||
layer: g.layer,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,461 @@
|
|||
//! Stage 3 — `ResolvedLayoutIR` (Chapter 7 §"ResolvedLayoutIR").
|
||||
//!
|
||||
//! The output of the constraint solver: every glyph has a definitive position.
|
||||
//! This is the IR the renderer consumes. v0 carries the resolved glyphs, the
|
||||
//! engraving decisions (including any the solver itself made), and the catalog
|
||||
//! identity under which the solve ran (Chapter 7 §7.3.2 / Chapter 9
|
||||
//! within-implementation determinism), together with the page/system interface
|
||||
//! populated by casting-off implementations.
|
||||
//!
|
||||
//! ## Canonical serialization
|
||||
//!
|
||||
//! Positions are working `f32` staff-space coordinates ([`Point`]); the
|
||||
//! **canonical** form quantizes them to the `1/1024` grid at serialization time
|
||||
//! (Appendix D §"Quantized Layout Coordinates"). [`ResolvedLayoutIR`] implements
|
||||
//! [`CanonicalEncode`] over its *full* content — every glyph's provenance
|
||||
//! (source, stable id, synthesis kind, dependencies) and quantized position,
|
||||
//! every engraving decision, and the complete catalog identity — so two layouts
|
||||
//! that differ in any of these produce different canonical bytes. A non-finite or
|
||||
//! out-of-range coordinate is a determinism violation; it is **rejected** with a
|
||||
//! panic (faulting in every build, debug and release alike), never silently
|
||||
//! normalized to the origin (Appendix D: invalid geometry is rejected, not
|
||||
//! aliased).
|
||||
|
||||
use epiphany_core::{MeasureId, StaffId, TypedObjectId};
|
||||
use epiphany_determinism::{CanonicalEncode, CanonicalF64, QuantizedCoord};
|
||||
|
||||
use crate::constrained::{GlyphObjectId, GlyphStyle};
|
||||
use crate::engraving::{DecisionSource, EngravingDecision, EngravingDecisionKind};
|
||||
use crate::glyph::{GlyphCatalogIdentity, GlyphReference};
|
||||
use crate::logical::ScoreVersion;
|
||||
use crate::provenance::{Provenance, SynthesisKind};
|
||||
use crate::spatial::{BoundingBox, Margins, Point, Rect, Size2D, StaffSpace, Transform2D};
|
||||
use crate::StemDirection;
|
||||
|
||||
/// A glyph with a definitive position (Chapter 7 §"ResolvedLayoutIR":
|
||||
/// `ResolvedGlyph`). Carries the SMuFL [`GlyphReference`] so the renderer knows
|
||||
/// *what symbol to draw*, and
|
||||
/// the `f32` staff-space position; canonical output quantizes the position (see
|
||||
/// the module's canonical-serialization note).
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct ResolvedGlyph {
|
||||
pub provenance: Provenance,
|
||||
/// The SMuFL glyph to draw (carried from the constrained glyph).
|
||||
pub glyph: GlyphReference,
|
||||
pub position: Point,
|
||||
pub transform: Option<Transform2D>,
|
||||
pub bounding_box: BoundingBox,
|
||||
pub style: GlyphStyle,
|
||||
pub layer: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct ResolvedPage {
|
||||
pub provenance: Provenance,
|
||||
pub number: u32,
|
||||
pub size: Size2D,
|
||||
pub margins: Margins,
|
||||
pub systems: Vec<ResolvedSystem>,
|
||||
pub free_objects: Vec<GlyphObjectId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct ResolvedSystem {
|
||||
pub provenance: Provenance,
|
||||
pub bounding_box: Rect,
|
||||
pub staves: Vec<ResolvedStaff>,
|
||||
pub measures: Vec<ResolvedMeasure>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct ResolvedStaff {
|
||||
pub provenance: Provenance,
|
||||
pub staff: StaffId,
|
||||
pub bounding_box: Rect,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct ResolvedMeasure {
|
||||
pub provenance: Provenance,
|
||||
pub measure: MeasureId,
|
||||
pub bounding_box: Rect,
|
||||
}
|
||||
|
||||
/// The resolved IR: every glyph positioned (Chapter 7 §"ResolvedLayoutIR").
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct ResolvedLayoutIR {
|
||||
pub source: ScoreVersion,
|
||||
pub pages: Vec<ResolvedPage>,
|
||||
pub glyphs: Vec<ResolvedGlyph>,
|
||||
pub engraving_decisions: Vec<EngravingDecision>,
|
||||
/// The catalog identity under which this layout was produced — required for
|
||||
/// any byte-equal conformance claim (Chapter 7 §7.3.2).
|
||||
pub catalog: GlyphCatalogIdentity,
|
||||
}
|
||||
|
||||
impl ResolvedLayoutIR {
|
||||
/// The canonical serialized output (Appendix D §"Quantized Layout
|
||||
/// Coordinates"): the full resolved layout, with glyph positions quantized
|
||||
/// to the `1/1024` grid. Equivalent to [`CanonicalEncode::to_canonical_bytes`].
|
||||
///
|
||||
/// Two solves whose internal f32 computations agree to better than `1/2048`
|
||||
/// staff space at every coordinate produce identical bytes; two layouts that
|
||||
/// differ in any provenance, engraving decision, or catalog field produce
|
||||
/// different bytes. Panics on a non-finite or out-of-range coordinate (a
|
||||
/// determinism violation that must be rejected, not normalized).
|
||||
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||||
self.to_canonical_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
impl CanonicalEncode for ResolvedLayoutIR {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.source.0);
|
||||
push_u64(out, self.pages.len() as u64);
|
||||
for page in &self.pages {
|
||||
encode_page(out, page);
|
||||
}
|
||||
push_u64(out, self.glyphs.len() as u64);
|
||||
for glyph in &self.glyphs {
|
||||
encode_provenance(out, &glyph.provenance);
|
||||
// The glyph reference itself (so swapping two glyphs' symbols, even
|
||||
// with the consulted-name set unchanged, changes the canonical bytes
|
||||
// — the encoding is injective in glyph identity).
|
||||
let name = glyph.glyph.as_str().as_bytes();
|
||||
push_u64(out, name.len() as u64);
|
||||
out.extend_from_slice(name);
|
||||
let (qx, qy) = quantize(glyph.position);
|
||||
qx.encode_canonical(out);
|
||||
qy.encode_canonical(out);
|
||||
match glyph.transform {
|
||||
None => out.push(0),
|
||||
Some(transform) => {
|
||||
out.push(1);
|
||||
for row in transform.matrix {
|
||||
for value in row {
|
||||
encode_f32(out, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
encode_bounding_box(out, glyph.bounding_box);
|
||||
out.extend_from_slice(&glyph.style.rgba.to_le_bytes());
|
||||
out.extend_from_slice(&glyph.layer.to_le_bytes());
|
||||
}
|
||||
push_u64(out, self.engraving_decisions.len() as u64);
|
||||
for decision in &self.engraving_decisions {
|
||||
encode_decision(out, decision);
|
||||
}
|
||||
encode_catalog(out, &self.catalog);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_page(out: &mut Vec<u8>, page: &ResolvedPage) {
|
||||
encode_provenance(out, &page.provenance);
|
||||
out.extend_from_slice(&page.number.to_le_bytes());
|
||||
encode_staff_space(out, page.size.width);
|
||||
encode_staff_space(out, page.size.height);
|
||||
for margin in [
|
||||
page.margins.top,
|
||||
page.margins.right,
|
||||
page.margins.bottom,
|
||||
page.margins.left,
|
||||
] {
|
||||
encode_staff_space(out, margin);
|
||||
}
|
||||
push_u64(out, page.systems.len() as u64);
|
||||
for system in &page.systems {
|
||||
encode_provenance(out, &system.provenance);
|
||||
encode_rect(out, system.bounding_box);
|
||||
push_u64(out, system.staves.len() as u64);
|
||||
for staff in &system.staves {
|
||||
encode_provenance(out, &staff.provenance);
|
||||
out.extend_from_slice(&staff.staff.canonical_bytes());
|
||||
encode_rect(out, staff.bounding_box);
|
||||
}
|
||||
push_u64(out, system.measures.len() as u64);
|
||||
for measure in &system.measures {
|
||||
encode_provenance(out, &measure.provenance);
|
||||
out.extend_from_slice(&measure.measure.canonical_bytes());
|
||||
encode_rect(out, measure.bounding_box);
|
||||
}
|
||||
}
|
||||
push_u64(out, page.free_objects.len() as u64);
|
||||
for object in &page.free_objects {
|
||||
push_u128(out, object.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_rect(out: &mut Vec<u8>, rect: Rect) {
|
||||
let (x, y) = quantize(rect.origin);
|
||||
x.encode_canonical(out);
|
||||
y.encode_canonical(out);
|
||||
encode_staff_space(out, rect.size.width);
|
||||
encode_staff_space(out, rect.size.height);
|
||||
}
|
||||
|
||||
fn encode_bounding_box(out: &mut Vec<u8>, bounds: BoundingBox) {
|
||||
for coordinate in [bounds.left, bounds.bottom, bounds.right, bounds.top] {
|
||||
encode_staff_space(out, coordinate);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_staff_space(out: &mut Vec<u8>, value: StaffSpace) {
|
||||
value
|
||||
.quantize()
|
||||
.unwrap_or_else(|| panic!("invalid staff-space value in canonical layout"))
|
||||
.encode_canonical(out);
|
||||
}
|
||||
|
||||
fn encode_f32(out: &mut Vec<u8>, value: f32) {
|
||||
CanonicalF64::new(value as f64)
|
||||
.unwrap_or_else(|| panic!("non-finite transform in canonical layout"))
|
||||
.encode_canonical(out);
|
||||
}
|
||||
|
||||
fn push_u64(out: &mut Vec<u8>, v: u64) {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
fn push_u128(out: &mut Vec<u8>, v: u128) {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Quantizes a working f32 position to the canonical grid, **rejecting** a
|
||||
/// non-finite or out-of-range coordinate with a panic (Appendix D: invalid
|
||||
/// geometry must be rejected, not aliased to the origin).
|
||||
fn quantize(p: Point) -> (QuantizedCoord, QuantizedCoord) {
|
||||
p.quantize().unwrap_or_else(|| {
|
||||
panic!("non-finite or out-of-range resolved coordinate in canonical output")
|
||||
})
|
||||
}
|
||||
|
||||
/// Length-prefixes an id's canonical bytes (self-delimiting).
|
||||
fn encode_source(out: &mut Vec<u8>, source: &TypedObjectId) {
|
||||
let bytes = source.to_canonical_bytes();
|
||||
push_u64(out, bytes.len() as u64);
|
||||
out.extend_from_slice(&bytes);
|
||||
}
|
||||
|
||||
fn encode_provenance(out: &mut Vec<u8>, p: &Provenance) {
|
||||
encode_source(out, &p.source);
|
||||
push_u128(out, p.stable_id.0);
|
||||
match p.synthesis {
|
||||
None => out.push(0),
|
||||
Some(kind) => {
|
||||
out.push(1);
|
||||
encode_synthesis(out, kind);
|
||||
}
|
||||
}
|
||||
// Dependencies are a set: canonical (sorted) order, deduplicated.
|
||||
let mut deps: Vec<Vec<u8>> = p
|
||||
.dependencies
|
||||
.iter()
|
||||
.map(|d| d.to_canonical_bytes())
|
||||
.collect();
|
||||
deps.sort();
|
||||
deps.dedup();
|
||||
push_u64(out, deps.len() as u64);
|
||||
for bytes in deps {
|
||||
push_u64(out, bytes.len() as u64);
|
||||
out.extend_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_synthesis(out: &mut Vec<u8>, kind: SynthesisKind) {
|
||||
match kind {
|
||||
SynthesisKind::CancellationAccidental => out.push(0),
|
||||
SynthesisKind::KeySignatureNatural => out.push(1),
|
||||
SynthesisKind::GeneratedRest => out.push(2),
|
||||
SynthesisKind::EngravedBreak => out.push(3),
|
||||
SynthesisKind::MultimeasureRest => out.push(4),
|
||||
SynthesisKind::Cautionary => out.push(5),
|
||||
SynthesisKind::Registered(id) => {
|
||||
out.push(6);
|
||||
push_u128(out, id.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_decision(out: &mut Vec<u8>, d: &EngravingDecision) {
|
||||
push_u128(out, d.id.0);
|
||||
push_u128(out, d.target.0);
|
||||
match &d.kind {
|
||||
EngravingDecisionKind::StemDirection(dir) => {
|
||||
out.push(0);
|
||||
out.push(matches!(dir, StemDirection::Up) as u8);
|
||||
}
|
||||
EngravingDecisionKind::LedgerLineCount(n) => {
|
||||
out.push(1);
|
||||
out.push(*n);
|
||||
}
|
||||
EngravingDecisionKind::SystemBreak => out.push(2),
|
||||
EngravingDecisionKind::PageBreak => out.push(3),
|
||||
EngravingDecisionKind::Registered(id) => {
|
||||
out.push(4);
|
||||
push_u128(out, id.0);
|
||||
}
|
||||
}
|
||||
match d.source {
|
||||
DecisionSource::Automatic => out.push(0),
|
||||
DecisionSource::UserOverride(id) => {
|
||||
out.push(1);
|
||||
push_u128(out, id.0);
|
||||
}
|
||||
DecisionSource::IrOverride => out.push(2),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_catalog(out: &mut Vec<u8>, c: &GlyphCatalogIdentity) {
|
||||
out.extend_from_slice(&c.smufl_version.major.to_le_bytes());
|
||||
out.extend_from_slice(&c.smufl_version.minor.to_le_bytes());
|
||||
let font = c.font_id.0.as_bytes();
|
||||
push_u64(out, font.len() as u64);
|
||||
out.extend_from_slice(font);
|
||||
match c.font_version {
|
||||
None => out.push(0),
|
||||
Some(v) => {
|
||||
out.push(1);
|
||||
out.extend_from_slice(&v.major.to_le_bytes());
|
||||
out.extend_from_slice(&v.minor.to_le_bytes());
|
||||
out.extend_from_slice(&v.patch.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&c.metrics_hash);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::engraving::EngravingDecisionKind;
|
||||
use crate::provenance::{LayoutObjectId, Provenance};
|
||||
use epiphany_core::EventId;
|
||||
|
||||
fn glyph(raw: u128, x: f32) -> ResolvedGlyph {
|
||||
glyph_named(raw, x, "noteheadBlack")
|
||||
}
|
||||
|
||||
fn glyph_named(raw: u128, x: f32, name: &'static str) -> ResolvedGlyph {
|
||||
let source = TypedObjectId::Event(EventId::from_raw(raw));
|
||||
ResolvedGlyph {
|
||||
provenance: Provenance::projected(source, vec![]),
|
||||
glyph: GlyphReference::borrowed(name),
|
||||
position: Point::new(x, 0.0),
|
||||
transform: None,
|
||||
bounding_box: BoundingBox::default(),
|
||||
style: GlyphStyle { rgba: 0x0000_00ff },
|
||||
layer: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn ir(glyphs: Vec<ResolvedGlyph>, decisions: Vec<EngravingDecision>) -> ResolvedLayoutIR {
|
||||
ResolvedLayoutIR {
|
||||
source: ScoreVersion::default(),
|
||||
pages: vec![],
|
||||
glyphs,
|
||||
engraving_decisions: decisions,
|
||||
catalog: GlyphCatalogIdentity::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bytes_are_quantized_and_stable() {
|
||||
let base = ir(vec![glyph(1, 1.0), glyph(2, 2.5)], vec![]);
|
||||
let a = base.canonical_bytes();
|
||||
assert_eq!(a, base.canonical_bytes(), "canonical bytes must be stable");
|
||||
|
||||
// Sub-grid f32 jitter is absorbed by quantization.
|
||||
let mut jittered = base.clone();
|
||||
jittered.glyphs[1].position = Point::new(2.5 + 1.0 / 4096.0, 0.0);
|
||||
assert_eq!(a, jittered.canonical_bytes());
|
||||
|
||||
// A full grid unit changes the output.
|
||||
let mut moved = base.clone();
|
||||
moved.glyphs[1].position = Point::new(2.5 + 1.0 / 1024.0, 0.0);
|
||||
assert_ne!(a, moved.canonical_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bytes_capture_engraving_decisions_and_catalog() {
|
||||
let base = ir(vec![glyph(1, 1.0)], vec![]);
|
||||
// Adding/altering an engraving decision changes the bytes.
|
||||
let with_decision = ir(
|
||||
vec![glyph(1, 1.0)],
|
||||
vec![EngravingDecision::automatic(
|
||||
LayoutObjectId(7),
|
||||
EngravingDecisionKind::SystemBreak,
|
||||
)],
|
||||
);
|
||||
assert_ne!(base.canonical_bytes(), with_decision.canonical_bytes());
|
||||
|
||||
// A different catalog identity changes the bytes.
|
||||
let mut other_catalog = base.clone();
|
||||
other_catalog.catalog.metrics_hash[0] ^= 1;
|
||||
assert_ne!(base.canonical_bytes(), other_catalog.canonical_bytes());
|
||||
|
||||
let mut other_source = base.clone();
|
||||
other_source.source.0[0] = 1;
|
||||
assert_ne!(base.canonical_bytes(), other_source.canonical_bytes());
|
||||
|
||||
let mut other_style = base.clone();
|
||||
other_style.glyphs[0].style.rgba ^= 1;
|
||||
assert_ne!(base.canonical_bytes(), other_style.canonical_bytes());
|
||||
|
||||
let mut other_bounds = base.clone();
|
||||
other_bounds.glyphs[0].bounding_box.right = StaffSpace(1.0);
|
||||
assert_ne!(base.canonical_bytes(), other_bounds.canonical_bytes());
|
||||
|
||||
let mut transformed = base.clone();
|
||||
transformed.glyphs[0].transform = Some(Transform2D::default());
|
||||
assert_ne!(base.canonical_bytes(), transformed.canonical_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapping_glyph_names_changes_canonical_bytes() {
|
||||
// Two glyphs whose names are swapped between their sources — the
|
||||
// consulted-name *set* (and so the metrics hash) is unchanged, but the
|
||||
// per-glyph assignment differs, so the canonical bytes MUST differ
|
||||
// (the encoding is injective in glyph identity).
|
||||
let a = ir(
|
||||
vec![
|
||||
glyph_named(1, 1.0, "noteheadBlack"),
|
||||
glyph_named(2, 2.0, "gClef"),
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
let b = ir(
|
||||
vec![
|
||||
glyph_named(1, 1.0, "gClef"),
|
||||
glyph_named(2, 2.0, "noteheadBlack"),
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
assert_ne!(a.canonical_bytes(), b.canonical_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesis_and_stable_id_are_part_of_canonical_bytes() {
|
||||
let src = TypedObjectId::Event(EventId::from_raw(1));
|
||||
let mut plain = glyph(1, 1.0);
|
||||
plain.provenance = Provenance::projected(src, vec![]);
|
||||
let mut synth = glyph(1, 1.0);
|
||||
synth.provenance = Provenance::synthesized(
|
||||
src,
|
||||
SynthesisKind::Cautionary,
|
||||
crate::SynthesisInstanceKey(0),
|
||||
vec![],
|
||||
);
|
||||
// Same source and position, but synthesis kind + stable id differ.
|
||||
assert_ne!(
|
||||
ir(vec![plain], vec![]).canonical_bytes(),
|
||||
ir(vec![synth], vec![]).canonical_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "non-finite")]
|
||||
fn non_finite_geometry_is_rejected_not_normalized() {
|
||||
let bad = ir(vec![glyph(1, f32::NAN)], vec![]);
|
||||
let _ = bad.canonical_bytes();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,419 @@
|
|||
//! The layout round-trip (v0 acceptance criterion 6 — Chapter 7's IR contract):
|
||||
//!
|
||||
//! > A score graph → LogicalLayoutIR → ConstrainedLayoutIR → stub-solved
|
||||
//! > ResolvedLayoutIR → RenderIR interface call → back to graph identity with
|
||||
//! > all provenance preserved.
|
||||
//!
|
||||
//! [`round_trip`] runs the whole pipeline and asserts the contract every IR
|
||||
//! stage must satisfy: the stub solver reports [`SolveStatus::Solved`] with all
|
||||
//! hard constraints satisfied; the **complete** [`Provenance`] of every object
|
||||
//! survives every stage unchanged; no two objects share a stable id; the stub
|
||||
//! solver returns the input geometry verbatim; and the *set* of score-graph
|
||||
//! sources recovered from the RenderIR is exactly the set laid out — a surjection
|
||||
//! onto graph identity (one source may back several manifestations, each a
|
||||
//! distinct layout object). This is the contract the testkit's layout harness
|
||||
//! drives, now against the real crate.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use epiphany_core::{Score, TypedObjectId};
|
||||
|
||||
use crate::constrained::to_constrained;
|
||||
use crate::logical::{cross_cutting_objects, identified_pitch_ids, to_logical, LayoutObject};
|
||||
use crate::provenance::{LayoutObjectId, Provenance};
|
||||
use crate::render::to_render;
|
||||
use crate::solver::{ConstraintSolver, SolveStatus, SolverConfig, StubSolver};
|
||||
|
||||
/// The set of score-graph objects the pipeline lays out — the [`TypedObjectId`]s
|
||||
/// the round-trip expects to recover from the RenderIR. Kept in lockstep with
|
||||
/// [`to_logical`]'s projection so the source-set surjection holds.
|
||||
pub fn laid_out_object_ids(score: &Score) -> BTreeSet<TypedObjectId> {
|
||||
let mut ids = BTreeSet::new();
|
||||
for region in &score.canvas.regions {
|
||||
ids.insert(TypedObjectId::Region(region.id));
|
||||
for staff_id in ®ion.staff_extent.staves {
|
||||
ids.insert(TypedObjectId::Staff(*staff_id));
|
||||
}
|
||||
for si in region.staff_instances() {
|
||||
ids.insert(TypedObjectId::StaffInstance(si.id));
|
||||
for voice in &si.voices {
|
||||
ids.insert(TypedObjectId::Voice(voice.id));
|
||||
for eid in &voice.events {
|
||||
ids.insert(TypedObjectId::Event(*eid));
|
||||
for pid in identified_pitch_ids(score, *eid) {
|
||||
ids.insert(TypedObjectId::Pitch(pid));
|
||||
}
|
||||
}
|
||||
}
|
||||
for measure in &si.measures {
|
||||
ids.insert(TypedObjectId::Measure(measure.id));
|
||||
}
|
||||
}
|
||||
for go in region.content.graphic_objects() {
|
||||
ids.insert(TypedObjectId::GraphicObject(go.id));
|
||||
}
|
||||
}
|
||||
for (src, _deps) in cross_cutting_objects(score) {
|
||||
ids.insert(src);
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// What the round-trip recovered, for inspection by tests.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RoundTripReport {
|
||||
pub status: SolveStatus,
|
||||
pub logical_objects: usize,
|
||||
pub glyphs: usize,
|
||||
pub render_primitives: usize,
|
||||
/// Every score-graph source recovered from the RenderIR.
|
||||
pub recovered_sources: BTreeSet<TypedObjectId>,
|
||||
}
|
||||
|
||||
/// Collects `stable_id -> Provenance` for a stage's objects, asserting no two
|
||||
/// objects share a stable id (which would let set comparisons hide duplication).
|
||||
fn provenance_map<'a>(
|
||||
label: &str,
|
||||
provenances: impl Iterator<Item = &'a Provenance>,
|
||||
) -> BTreeMap<LayoutObjectId, Provenance> {
|
||||
let mut map = BTreeMap::new();
|
||||
for p in provenances {
|
||||
let prev = map.insert(p.stable_id, p.clone());
|
||||
assert!(
|
||||
prev.is_none(),
|
||||
"{label}: duplicate stable id {:?} (provenance duplication)",
|
||||
p.stable_id
|
||||
);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Runs the full pipeline (acceptance criterion 6): graph → LogicalLayoutIR →
|
||||
/// ConstrainedLayoutIR → stub-solved ResolvedLayoutIR → RenderIR, asserting it
|
||||
/// completes without panic and **without losing provenance back-references**.
|
||||
/// Specifically:
|
||||
///
|
||||
/// * the stub solver returns [`SolveStatus::Solved`] with all hard constraints
|
||||
/// satisfied;
|
||||
/// * the complete [`Provenance`] of every object — `source`, `synthesis`,
|
||||
/// `dependencies`, and `stable_id` — survives every stage unchanged (compared
|
||||
/// as `stable_id -> Provenance` maps, so a dropped dependency or synthesis
|
||||
/// kind fails, not just a changed id);
|
||||
/// * no two objects ever share a `stable_id`, so manifestation multiplicity is
|
||||
/// preserved through every stage (a source manifested twice stays two layout
|
||||
/// objects with two stable ids);
|
||||
/// * the stub solver returns the input geometry verbatim;
|
||||
/// * the *set* of score-graph sources recovered from the RenderIR equals the set
|
||||
/// laid out — a surjection onto graph identity (every laid-out source is
|
||||
/// recovered and nothing spurious appears; one source may back several layout
|
||||
/// objects, which the distinct stable ids account for).
|
||||
pub fn round_trip(score: &Score) -> RoundTripReport {
|
||||
let logical = to_logical(score);
|
||||
let constrained = to_constrained(&logical);
|
||||
|
||||
// Full-provenance maps at each stage (duplication is caught while building).
|
||||
let logical_map = provenance_map(
|
||||
"logical",
|
||||
logical
|
||||
.regions
|
||||
.iter()
|
||||
.flat_map(|r| {
|
||||
std::iter::once(&r.provenance).chain(r.objects.iter().map(LayoutObject::provenance))
|
||||
})
|
||||
.chain(logical.cross_region.iter().map(|object| &object.provenance)),
|
||||
);
|
||||
let constrained_map = provenance_map(
|
||||
"constrained",
|
||||
constrained.glyphs.iter().map(|g| &g.provenance),
|
||||
);
|
||||
assert_eq!(
|
||||
logical_map, constrained_map,
|
||||
"provenance not preserved logical -> constrained"
|
||||
);
|
||||
|
||||
let report = StubSolver.solve(&constrained, &SolverConfig::default());
|
||||
assert_eq!(
|
||||
report.status,
|
||||
SolveStatus::Solved,
|
||||
"the stub solver must report Solved"
|
||||
);
|
||||
assert!(
|
||||
report.satisfied_hard_constraints,
|
||||
"the stub solver must satisfy all hard constraints"
|
||||
);
|
||||
|
||||
// The stub solver's geometry contract: it returns the input geometry
|
||||
// *verbatim* — each resolved glyph's position is exactly its constrained
|
||||
// baseline (Chapter 9 / QUICKSTART: "the input geometry verbatim"). The
|
||||
// solver preserves order, so glyphs line up by index.
|
||||
assert_eq!(
|
||||
report.layout.glyphs.len(),
|
||||
constrained.glyphs.len(),
|
||||
"the solver must not add or drop glyphs"
|
||||
);
|
||||
for (constrained_glyph, resolved_glyph) in constrained.glyphs.iter().zip(&report.layout.glyphs)
|
||||
{
|
||||
assert_eq!(
|
||||
resolved_glyph.position, constrained_glyph.baseline,
|
||||
"stub solver must return the input geometry verbatim"
|
||||
);
|
||||
assert_eq!(resolved_glyph.glyph, constrained_glyph.glyph);
|
||||
assert_eq!(resolved_glyph.bounding_box, constrained_glyph.bounding_box);
|
||||
assert_eq!(resolved_glyph.style, constrained_glyph.style);
|
||||
assert_eq!(resolved_glyph.layer, constrained_glyph.layer);
|
||||
}
|
||||
|
||||
let resolved_map = provenance_map(
|
||||
"resolved",
|
||||
report.layout.glyphs.iter().map(|g| &g.provenance),
|
||||
);
|
||||
assert_eq!(
|
||||
constrained_map, resolved_map,
|
||||
"provenance not preserved constrained -> resolved"
|
||||
);
|
||||
|
||||
let render = to_render(&report.layout);
|
||||
for (resolved_glyph, primitive) in report.layout.glyphs.iter().zip(&render.primitives) {
|
||||
assert_eq!(primitive.glyph, resolved_glyph.glyph);
|
||||
assert_eq!(primitive.position, resolved_glyph.position);
|
||||
assert_eq!(primitive.transform, resolved_glyph.transform);
|
||||
assert_eq!(primitive.bounding_box, resolved_glyph.bounding_box);
|
||||
assert_eq!(primitive.style, resolved_glyph.style);
|
||||
assert_eq!(primitive.layer, resolved_glyph.layer);
|
||||
}
|
||||
let render_map = provenance_map("render", render.primitives.iter().map(|p| &p.provenance));
|
||||
assert_eq!(
|
||||
resolved_map, render_map,
|
||||
"provenance not preserved resolved -> render"
|
||||
);
|
||||
|
||||
// Provenance back to graph identity: the recovered source *set* is exactly
|
||||
// the set laid out (a surjection — every source recovered, nothing spurious),
|
||||
// while the primitive count equals the distinct-stable-id count, so each
|
||||
// layout object (including each manifestation of a multiply-manifested
|
||||
// source) is represented exactly once.
|
||||
let expected = laid_out_object_ids(score);
|
||||
let recovered: BTreeSet<TypedObjectId> = render
|
||||
.primitives
|
||||
.iter()
|
||||
.map(|p| p.provenance.source)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
expected, recovered,
|
||||
"RenderIR sources do not match the laid-out graph objects"
|
||||
);
|
||||
assert_eq!(
|
||||
render.primitives.len(),
|
||||
render_map.len(),
|
||||
"render produced two objects with the same stable id"
|
||||
);
|
||||
|
||||
RoundTripReport {
|
||||
status: report.status,
|
||||
logical_objects: logical
|
||||
.regions
|
||||
.iter()
|
||||
.map(|r| 1 + r.objects.len())
|
||||
.sum::<usize>()
|
||||
+ logical.cross_region.len(),
|
||||
glyphs: constrained.glyphs.len(),
|
||||
render_primitives: render.primitives.len(),
|
||||
recovered_sources: recovered,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use epiphany_core::generators::{valid_score, valid_score_rich};
|
||||
|
||||
/// A staff manifested in **two** regions traverses the *full pipeline*
|
||||
/// (logical → constrained → stub-solved → render) as two distinct layout
|
||||
/// objects: two render primitives with the same `source` but distinct stable
|
||||
/// ids. Built directly at the IR level (a graph-valid two-region shared-staff
|
||||
/// `Score` is awkward to synthesize from the generators — see the note on
|
||||
/// `multi_region_scores_have_no_colliding_layout_ids`), so the integration
|
||||
/// path itself is covered, not just the id helper.
|
||||
#[test]
|
||||
fn one_staff_manifested_in_two_regions_round_trips_as_two_objects() {
|
||||
use crate::constrained::to_constrained;
|
||||
use crate::logical::{LayoutObject, LayoutRegion, LogicalLayoutIR};
|
||||
use crate::render::to_render;
|
||||
use crate::solver::{ConstraintSolver, SolverConfig, StubSolver};
|
||||
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
|
||||
use epiphany_core::{RegionId, StaffId};
|
||||
|
||||
let staff = StaffId::from_raw(5);
|
||||
let region = |id: u128| {
|
||||
let rid = RegionId::from_raw(id);
|
||||
LayoutRegion {
|
||||
provenance: Provenance::projected(TypedObjectId::Region(rid), vec![]),
|
||||
coordinate_system: crate::LocalCoordinateSystem::default(),
|
||||
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
|
||||
vertical_extent: crate::VerticalExtent {
|
||||
staves: vec![staff],
|
||||
},
|
||||
objects: vec![LayoutObject::from_projection(
|
||||
Provenance::manifested(TypedObjectId::Staff(staff), rid, vec![]),
|
||||
Some(staff),
|
||||
)],
|
||||
}
|
||||
};
|
||||
let logical = LogicalLayoutIR {
|
||||
source: crate::ScoreVersion::default(),
|
||||
regions: vec![region(1), region(2)],
|
||||
engraving_decisions: vec![],
|
||||
overrides: vec![],
|
||||
cross_region: vec![],
|
||||
};
|
||||
|
||||
let constrained = to_constrained(&logical);
|
||||
let report = StubSolver.solve(&constrained, &SolverConfig::default());
|
||||
assert_eq!(report.status, SolveStatus::Solved);
|
||||
let render = to_render(&report.layout);
|
||||
|
||||
// Two primitives for the shared staff (one per region), distinct ids.
|
||||
let staff_prims: Vec<_> = render
|
||||
.primitives
|
||||
.iter()
|
||||
.filter(|p| p.provenance.source == TypedObjectId::Staff(staff))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
staff_prims.len(),
|
||||
2,
|
||||
"both manifestations reach the render stage"
|
||||
);
|
||||
let ids: BTreeSet<_> = staff_prims.iter().map(|p| p.provenance.stable_id).collect();
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
2,
|
||||
"the two manifestations have distinct stable ids"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_valid_shared_staff_score_exercises_projection_and_full_pipeline() {
|
||||
use epiphany_core::{
|
||||
check_invariants, RegionContent, StaffInstance, StaffInstanceId, TimeAnchor,
|
||||
TimeExtent, WallClockTime,
|
||||
};
|
||||
|
||||
let mut score = valid_score_rich(0x51AFF);
|
||||
let shared = score.canvas.regions[0].staff_extent.staves[0];
|
||||
score.canvas.regions[1].staff_extent.staves.push(shared);
|
||||
let shared_instance: StaffInstanceId = score.identity.mint();
|
||||
let RegionContent::StaffBased(content) = &mut score.canvas.regions[1].content else {
|
||||
panic!("rich fixture's second region is staff-based");
|
||||
};
|
||||
content
|
||||
.staff_instances
|
||||
.push(StaffInstance::new(shared_instance, shared));
|
||||
score.canvas.regions[1].time_extent = TimeExtent {
|
||||
start: TimeAnchor::WallClock {
|
||||
time: WallClockTime(2_000_000),
|
||||
},
|
||||
end: TimeAnchor::WallClock {
|
||||
time: WallClockTime(3_000_000),
|
||||
},
|
||||
};
|
||||
let violations = check_invariants(&score);
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"shared-staff fixture must remain graph-valid: {violations:#?}"
|
||||
);
|
||||
|
||||
let logical = to_logical(&score);
|
||||
let manifestations: Vec<_> = logical
|
||||
.regions
|
||||
.iter()
|
||||
.flat_map(|region| region.objects.iter())
|
||||
.filter(|object| object.provenance().source == TypedObjectId::Staff(shared))
|
||||
.map(|object| object.provenance().stable_id)
|
||||
.collect();
|
||||
assert_eq!(manifestations.len(), 2);
|
||||
assert_ne!(manifestations[0], manifestations[1]);
|
||||
let report = round_trip(&score);
|
||||
assert_eq!(report.status, SolveStatus::Solved);
|
||||
}
|
||||
|
||||
/// Across a multi-region score, `to_logical` never emits two layout objects
|
||||
/// with the same stable id — the manifestation id keys on `(source, region)`,
|
||||
/// so even a source reachable from two regions gets two distinct ids and
|
||||
/// `round_trip` (which itself asserts no duplicate stable id) does not panic.
|
||||
/// The id-distinctness of two manifestations of *one* source is unit-tested
|
||||
/// directly in [`crate::provenance`]
|
||||
/// (`manifestations_in_distinct_regions_are_distinct`); building a graph-valid
|
||||
/// shared-staff score from the generators is fragile (it must satisfy the
|
||||
/// region-overlap and coordinate-discipline invariants), so the integration
|
||||
/// coverage here is the no-collision property over real multi-region scores.
|
||||
#[test]
|
||||
fn multi_region_scores_have_no_colliding_layout_ids() {
|
||||
for seed in 0..64u64 {
|
||||
let score = valid_score_rich(seed);
|
||||
let logical = to_logical(&score);
|
||||
let mut ids = BTreeSet::new();
|
||||
for prov in logical.regions.iter().flat_map(|r| {
|
||||
std::iter::once(&r.provenance).chain(r.objects.iter().map(LayoutObject::provenance))
|
||||
}) {
|
||||
assert!(
|
||||
ids.insert(prov.stable_id),
|
||||
"to_logical emitted two objects with the same stable id"
|
||||
);
|
||||
}
|
||||
// The round-trip holds (its own provenance maps re-assert no dups).
|
||||
let _ = round_trip(&score);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_scores_round_trip() {
|
||||
for seed in 0..64u64 {
|
||||
let report = round_trip(&valid_score(seed));
|
||||
assert_eq!(report.glyphs, report.render_primitives);
|
||||
assert_eq!(report.recovered_sources.len(), report.render_primitives);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_scores_round_trip_with_cross_cutting() {
|
||||
for seed in 0..64u64 {
|
||||
let report = round_trip(&valid_score_rich(seed));
|
||||
assert!(report.glyphs >= 3);
|
||||
// The rich generator carries a tuplet, tie, spanner, marker, and
|
||||
// chord symbol — their omission could not pass unseen.
|
||||
assert!(report
|
||||
.recovered_sources
|
||||
.iter()
|
||||
.any(|s| matches!(s, TypedObjectId::Tuplet(_))));
|
||||
assert!(report
|
||||
.recovered_sources
|
||||
.iter()
|
||||
.any(|s| matches!(s, TypedObjectId::Tie(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reordering_regions_preserves_stable_ids() {
|
||||
// A relayout where the *sources* are unchanged must not change any
|
||||
// object's stable id (Chapter 7 §"Provenance").
|
||||
let score = valid_score_rich(9);
|
||||
let source_to_stable = |s: &Score| {
|
||||
to_logical(s)
|
||||
.regions
|
||||
.iter()
|
||||
.flat_map(|r| {
|
||||
std::iter::once((r.provenance.source, r.provenance.stable_id)).chain(
|
||||
r.objects
|
||||
.iter()
|
||||
.map(|o| (o.provenance().source, o.provenance().stable_id)),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
};
|
||||
let before = source_to_stable(&score);
|
||||
let mut reordered = score.clone();
|
||||
reordered.canvas.regions.reverse();
|
||||
let after = source_to_stable(&reordered);
|
||||
assert_eq!(before, after, "reordering regions changed stable ids");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,678 @@
|
|||
//! 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."
|
||||
//!
|
||||
//! **Quality-metric *computation* is deliberately not implemented** (QUICKSTART:
|
||||
//! "only the interface — don't implement quality metrics"): the
|
||||
//! [`QualityMetricVector`]/[`NormalizedMetric`] *types* and the
|
||||
//! [`TieBreakingWeights`] exist (the interface requires them), but the
|
||||
//! normalization functions of the Quality Metric Catalog are not. The
|
||||
//! `StubSolver` is not a conformant solver and passes no reference suite, so it
|
||||
//! reports the interface's `Minimal` tier 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::{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").
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
pub enum SolverTier {
|
||||
/// 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 per-profile thresholds live in the Quality
|
||||
/// Metric Catalog, deferred with the quality metrics.
|
||||
#[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 live in the
|
||||
/// Quality Metric Catalog (deferred); v0 defaults every weight to `1.0`.
|
||||
#[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`). v0 carries the type but computes **no** values: the
|
||||
/// stub's vector is a placeholder all-`0.0` (nominal-best) vector with no
|
||||
/// conformance meaning (the normalization functions are deferred).
|
||||
#[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 because it rejects
|
||||
/// explicit constraints it cannot evaluate.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct ConstraintId(pub u128);
|
||||
|
||||
/// 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).
|
||||
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);
|
||||
// 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 well_formed = structural_valid && catalog_valid && input.constraints.is_empty();
|
||||
|
||||
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()
|
||||
};
|
||||
let resolved_glyphs = glyphs.len();
|
||||
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(),
|
||||
})
|
||||
.collect(),
|
||||
free_objects: Vec::new(),
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
SolveReport {
|
||||
status: if well_formed {
|
||||
SolveStatus::Solved
|
||||
} else {
|
||||
SolveStatus::InternalError
|
||||
},
|
||||
satisfied_hard_constraints: well_formed,
|
||||
layout: ResolvedLayoutIR {
|
||||
source: input.source,
|
||||
pages,
|
||||
glyphs,
|
||||
engraving_decisions: input.engraving_decisions.clone(),
|
||||
catalog: input.catalog.clone(),
|
||||
},
|
||||
unsatisfied_constraints: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
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 {
|
||||
SolverTier::Minimal
|
||||
}
|
||||
|
||||
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,
|
||||
vertical_bands: vec![band],
|
||||
constraints: vec![],
|
||||
engraving_decisions: vec![],
|
||||
catalog,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_uses_the_minimal_interface_tier_and_worst_metrics() {
|
||||
assert_eq!(StubSolver.tier(), 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 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],
|
||||
vertical_bands: vec![band],
|
||||
constraints: vec![],
|
||||
engraving_decisions: 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 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 += 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());
|
||||
assert_eq!(report.status, SolveStatus::InternalError);
|
||||
assert!(!report.satisfied_hard_constraints);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
//! Spatial primitives for the layout IR (Chapter 7 §"Spatial Primitives").
|
||||
//!
|
||||
//! IR coordinates are single-precision **staff spaces** ([`StaffSpace`],
|
||||
//! Chapter 7 §7.2: "Single-precision floating point (`f32`) MUST be used for IR
|
||||
//! coordinates"). Quantization to the canonical `1/1024`-staff-space grid
|
||||
//! ([`epiphany_determinism::QuantizedCoord`]) happens only when *serializing*
|
||||
//! canonical `ResolvedLayoutIR` output — exactly as Appendix D §"Quantized
|
||||
//! Layout Coordinates" prescribes: "Internal solvers MAY use floating point
|
||||
//! during computation; canonical serialization rounds to `QuantizedCoord`." The
|
||||
//! quantization rule is round-to-nearest, ties-to-even
|
||||
//! ([`QuantizedCoord::from_staff_space_f32`]).
|
||||
//!
|
||||
//! Conversion to absolute units (points, millimeters) via [`ScaleContext`]
|
||||
//! occurs only at the render boundary, which is out of v0 scope.
|
||||
|
||||
use epiphany_determinism::QuantizedCoord;
|
||||
|
||||
/// Staff space: the fundamental unit of music engraving — the distance between
|
||||
/// adjacent staff lines (Chapter 7 §7.2). `f32`, per the IR-coordinate rule.
|
||||
#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Default)]
|
||||
pub struct StaffSpace(pub f32);
|
||||
|
||||
impl StaffSpace {
|
||||
/// Quantizes to the canonical `1/1024` grid (round-to-nearest, ties-to-even).
|
||||
/// Returns `None` for a non-finite or out-of-range value, which canonical
|
||||
/// state forbids (Appendix D).
|
||||
pub fn quantize(self) -> Option<QuantizedCoord> {
|
||||
QuantizedCoord::from_staff_space_f32(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Points-per-staff-space scaling, applied only at the render boundary
|
||||
/// (Chapter 7 §7.2: `ScaleContext`). Out of v0 scope beyond the type.
|
||||
#[derive(Copy, Clone, PartialEq, PartialOrd, Debug)]
|
||||
pub struct ScaleContext {
|
||||
/// Points per staff space (typically 4–10).
|
||||
pub points_per_staff_space: f32,
|
||||
}
|
||||
|
||||
/// A 2-D point in staff spaces (Chapter 7 §"Geometric Types": `Point2D`). This
|
||||
/// is the working IR coordinate; canonical output is its [`Point::quantize`].
|
||||
#[derive(Copy, Clone, PartialEq, Debug, Default)]
|
||||
pub struct Point {
|
||||
pub x: StaffSpace,
|
||||
pub y: StaffSpace,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
/// The origin (`0, 0`).
|
||||
pub const ORIGIN: Point = Point {
|
||||
x: StaffSpace(0.0),
|
||||
y: StaffSpace(0.0),
|
||||
};
|
||||
|
||||
/// Constructs a point from staff-space coordinates.
|
||||
pub const fn new(x: f32, y: f32) -> Self {
|
||||
Point {
|
||||
x: StaffSpace(x),
|
||||
y: StaffSpace(y),
|
||||
}
|
||||
}
|
||||
|
||||
/// Quantizes both coordinates to the canonical grid (Appendix D). Returns
|
||||
/// `None` if either coordinate is non-finite or out of range.
|
||||
pub fn quantize(self) -> Option<(QuantizedCoord, QuantizedCoord)> {
|
||||
Some((self.x.quantize()?, self.y.quantize()?))
|
||||
}
|
||||
}
|
||||
|
||||
/// A 2-D size in staff spaces (Chapter 7 §"Geometric Types": `Size2D`).
|
||||
#[derive(Copy, Clone, PartialEq, Debug, Default)]
|
||||
pub struct Size2D {
|
||||
pub width: StaffSpace,
|
||||
pub height: StaffSpace,
|
||||
}
|
||||
|
||||
/// A glyph's bounding box in staff spaces, relative to its anchor (Chapter 7
|
||||
/// §"Geometric Types"). Carried in the in-tree glyph catalog ([`crate::glyph`]);
|
||||
/// metrics are queried from the catalog, never embedded in pipeline objects
|
||||
/// (Chapter 7 §"Glyph metrics live elsewhere").
|
||||
#[derive(Copy, Clone, PartialEq, Debug, Default)]
|
||||
pub struct BoundingBox {
|
||||
pub left: StaffSpace,
|
||||
pub bottom: StaffSpace,
|
||||
pub right: StaffSpace,
|
||||
pub top: StaffSpace,
|
||||
}
|
||||
|
||||
/// An axis-aligned rectangle in staff-space coordinates.
|
||||
#[derive(Copy, Clone, PartialEq, Debug, Default)]
|
||||
pub struct Rect {
|
||||
pub origin: Point,
|
||||
pub size: Size2D,
|
||||
}
|
||||
|
||||
/// A 2-D affine/projective transform in homogeneous coordinates.
|
||||
#[derive(Copy, Clone, PartialEq, Debug)]
|
||||
pub struct Transform2D {
|
||||
pub matrix: [[f32; 3]; 3],
|
||||
}
|
||||
|
||||
impl Default for Transform2D {
|
||||
fn default() -> Self {
|
||||
Transform2D {
|
||||
matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Page margins in staff-space units.
|
||||
#[derive(Copy, Clone, PartialEq, Debug, Default)]
|
||||
pub struct Margins {
|
||||
pub top: StaffSpace,
|
||||
pub right: StaffSpace,
|
||||
pub bottom: StaffSpace,
|
||||
pub left: StaffSpace,
|
||||
}
|
||||
|
||||
impl BoundingBox {
|
||||
/// Constructs from staff-space extents `[left, bottom, right, top]`.
|
||||
pub const fn new(left: f32, bottom: f32, right: f32, top: f32) -> Self {
|
||||
BoundingBox {
|
||||
left: StaffSpace(left),
|
||||
bottom: StaffSpace(bottom),
|
||||
right: StaffSpace(right),
|
||||
top: StaffSpace(top),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quantization_rounds_to_the_grid() {
|
||||
// 1 staff space == 1024 grid units; exact on the grid.
|
||||
assert_eq!(
|
||||
Point::new(1.0, -2.0).quantize(),
|
||||
Some((
|
||||
QuantizedCoord::from_units(1024),
|
||||
QuantizedCoord::from_units(-2048)
|
||||
))
|
||||
);
|
||||
// Round-to-nearest, ties-to-even: 1/2048 staff space rounds toward even.
|
||||
let half_unit = 0.5 / 1024.0; // half a grid unit
|
||||
assert_eq!(
|
||||
StaffSpace(half_unit).quantize(),
|
||||
Some(QuantizedCoord::from_units(0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_coordinates_do_not_quantize() {
|
||||
assert_eq!(StaffSpace(f32::NAN).quantize(), None);
|
||||
assert_eq!(StaffSpace(f32::INFINITY).quantize(), None);
|
||||
assert_eq!(Point::new(f32::NAN, 0.0).quantize(), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
//! The per-region time axis (Chapter 7 §"Layout Regions" / §"The Time Axis").
|
||||
//!
|
||||
//! [`TimeAxisModel`] is a **tagged enum** over the three built-in region time
|
||||
//! models plus a registered variant for extension-defined axes — *not*
|
||||
//! `Box<dyn TimeAxis>` (QUICKSTART, Agent E; Chapter 7: "The enum form is
|
||||
//! canonical for serialization, hashing, and conformance comparison"). The
|
||||
//! dynamic [`TimeAxis`] interface is also provided for spacing implementations.
|
||||
|
||||
use epiphany_core::{MusicalPosition, Region, RegionTimeModel, WallClockTime};
|
||||
|
||||
use crate::{SpringSlotId, StaffSpace};
|
||||
|
||||
/// A registry id for an extension-defined [`TimeAxisModel::Registered`]
|
||||
/// (Chapter 7: `TimeAxisRegistryId`). Opaque in v0 (no external registries).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct TimeAxisRegistryId(pub u128);
|
||||
|
||||
/// Canonical opaque payload for an extension-defined time axis.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct SerializedRegisteredAxis(pub Vec<u8>);
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum TimePoint {
|
||||
Musical(MusicalPosition),
|
||||
WallClock(WallClockTime),
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum TimeRange {
|
||||
Musical {
|
||||
start: MusicalPosition,
|
||||
end: MusicalPosition,
|
||||
},
|
||||
WallClock {
|
||||
start: WallClockTime,
|
||||
end: WallClockTime,
|
||||
},
|
||||
}
|
||||
|
||||
/// Metric-axis projection data. The prototype populates slots during spacing.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct MetricTimeAxis {
|
||||
pub slots: Vec<SpringSlotId>,
|
||||
}
|
||||
|
||||
/// Proportional-axis projection data.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct ProportionalTimeAxis {
|
||||
pub duration_ns: i64,
|
||||
pub space_per_second: StaffSpace,
|
||||
pub slots: Vec<SpringSlotId>,
|
||||
}
|
||||
|
||||
/// Aleatoric-axis projection data. Slot order is topological layer order.
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||
pub struct AleatoricTimeAxis {
|
||||
pub slots: Vec<SpringSlotId>,
|
||||
}
|
||||
|
||||
/// The canonical representation of a region's time axis (Chapter 7). The
|
||||
/// variants correspond to the three built-in [`RegionTimeModel`]s; the
|
||||
/// `Registered` variant carries an extension-defined axis by registry id.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum TimeAxisModel {
|
||||
/// Metric time: positions map through measures and beats.
|
||||
Metric(MetricTimeAxis),
|
||||
/// Proportional time: horizontal position is linear in wall-clock time over
|
||||
/// the region's `duration_ns` nanoseconds.
|
||||
Proportional(ProportionalTimeAxis),
|
||||
/// Aleatoric time: ordering is a DAG, not a metric line.
|
||||
Aleatoric(AleatoricTimeAxis),
|
||||
/// An extension-defined axis kind.
|
||||
Registered(TimeAxisRegistryId, SerializedRegisteredAxis),
|
||||
}
|
||||
|
||||
/// The built-in axis kinds, the discriminator of [`TimeAxisModel`]
|
||||
/// (Chapter 7: `TimeAxisKind`).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub enum TimeAxisKind {
|
||||
Metric,
|
||||
Proportional,
|
||||
Aleatoric,
|
||||
Registered(TimeAxisRegistryId),
|
||||
}
|
||||
|
||||
/// Dynamic time-axis interface used by spacing implementations. The tagged
|
||||
/// [`TimeAxisModel`] remains the canonical representation.
|
||||
pub trait TimeAxis: Send + Sync {
|
||||
fn kind(&self) -> TimeAxisKind;
|
||||
fn project(&self, time: TimePoint) -> SpringSlotId;
|
||||
fn slots(&self) -> &[SpringSlotId];
|
||||
fn affected_slots(&self, range: TimeRange) -> Vec<SpringSlotId>;
|
||||
}
|
||||
|
||||
impl TimeAxisModel {
|
||||
/// This axis's kind discriminator.
|
||||
pub fn kind(&self) -> TimeAxisKind {
|
||||
match self {
|
||||
TimeAxisModel::Metric(_) => TimeAxisKind::Metric,
|
||||
TimeAxisModel::Proportional(_) => TimeAxisKind::Proportional,
|
||||
TimeAxisModel::Aleatoric(_) => TimeAxisKind::Aleatoric,
|
||||
TimeAxisModel::Registered(id, _) => TimeAxisKind::Registered(*id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeAxis for TimeAxisModel {
|
||||
fn kind(&self) -> TimeAxisKind {
|
||||
TimeAxisModel::kind(self)
|
||||
}
|
||||
|
||||
fn project(&self, _time: TimePoint) -> SpringSlotId {
|
||||
self.slots().first().copied().unwrap_or(SpringSlotId(0))
|
||||
}
|
||||
|
||||
fn slots(&self) -> &[SpringSlotId] {
|
||||
match self {
|
||||
TimeAxisModel::Metric(axis) => &axis.slots,
|
||||
TimeAxisModel::Proportional(axis) => &axis.slots,
|
||||
TimeAxisModel::Aleatoric(axis) => &axis.slots,
|
||||
TimeAxisModel::Registered(_, _) => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn affected_slots(&self, _range: TimeRange) -> Vec<SpringSlotId> {
|
||||
self.slots().to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a score region's [`RegionTimeModel`] to its layout [`TimeAxisModel`]
|
||||
/// (Chapter 7 §"Region Uniformity": the region kind shows up here and in the
|
||||
/// object mix, never in the container type).
|
||||
pub fn time_axis_of(region: &Region) -> TimeAxisModel {
|
||||
match ®ion.time_model {
|
||||
RegionTimeModel::Metric(_) => TimeAxisModel::Metric(MetricTimeAxis::default()),
|
||||
RegionTimeModel::Proportional(p) => TimeAxisModel::Proportional(ProportionalTimeAxis {
|
||||
duration_ns: p.duration.0,
|
||||
space_per_second: StaffSpace(1.0),
|
||||
slots: Vec::new(),
|
||||
}),
|
||||
RegionTimeModel::Aleatoric(_) => TimeAxisModel::Aleatoric(AleatoricTimeAxis::default()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kind_matches_variant() {
|
||||
assert_eq!(
|
||||
TimeAxisModel::Metric(MetricTimeAxis::default()).kind(),
|
||||
TimeAxisKind::Metric
|
||||
);
|
||||
assert_eq!(
|
||||
TimeAxisModel::Proportional(ProportionalTimeAxis {
|
||||
duration_ns: 42,
|
||||
space_per_second: StaffSpace(1.0),
|
||||
slots: vec![],
|
||||
})
|
||||
.kind(),
|
||||
TimeAxisKind::Proportional
|
||||
);
|
||||
assert_eq!(
|
||||
TimeAxisModel::Aleatoric(AleatoricTimeAxis::default()).kind(),
|
||||
TimeAxisKind::Aleatoric
|
||||
);
|
||||
let r = TimeAxisRegistryId(7);
|
||||
assert_eq!(
|
||||
TimeAxisModel::Registered(r, SerializedRegisteredAxis::default()).kind(),
|
||||
TimeAxisKind::Registered(r)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
//! The vertical-band model (Chapter 7 §"Vertical Bands").
|
||||
//!
|
||||
//! "Vertical layout uses the same spring model. A *vertical band* is a
|
||||
//! horizontal slice of the canvas (typically a staff or an inter-staff gap)
|
||||
//! with its own spring parameters." The solver resolves vertical positions by
|
||||
//! treating bands as springs, exactly as it treats horizontal time slots
|
||||
//! (Chapter 7 §"Spring-based spacing").
|
||||
//!
|
||||
//! All dimensions are staff-space `f32` per the Chapter 7 §7.2 IR-coordinate
|
||||
//! rule: the elastic spring parameters (`stretch_factor`, `compress_factor`) and
|
||||
//! the band heights ([`StaffSpace`]) alike are layout-engine inputs, quantized
|
||||
//! only if and when a band extent is serialized into canonical output.
|
||||
|
||||
use epiphany_core::{StaffId, TypedObjectId};
|
||||
use epiphany_determinism::{DomainTag, Preimage};
|
||||
|
||||
use crate::constrained::GlyphObjectId;
|
||||
use crate::provenance::{stable_layout_id, LayoutObjectId};
|
||||
use crate::spatial::StaffSpace;
|
||||
|
||||
/// A stable identifier for a vertical band (Chapter 7: `VerticalBandId`).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct VerticalBandId(pub u128);
|
||||
|
||||
/// Derives an inter-staff gap id in its own type-prefixed preimage namespace.
|
||||
/// It cannot alias the region's margin id by arithmetic construction.
|
||||
pub fn inter_staff_gap_id(region: LayoutObjectId, gap_index: usize) -> VerticalBandId {
|
||||
let mut preimage = Preimage::new(DomainTag::CONFLICT);
|
||||
preimage.push_bytes(b"vertical-band/inter-staff-gap");
|
||||
preimage.push_u64_le((region.0 >> 64) as u64);
|
||||
preimage.push_u64_le(region.0 as u64);
|
||||
preimage.push_u64_le(gap_index as u64);
|
||||
VerticalBandId(preimage.finish_trunc128())
|
||||
}
|
||||
|
||||
/// What a vertical band represents (Chapter 7: `VerticalBandKind`).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum VerticalBandKind {
|
||||
/// A staff's own band.
|
||||
Staff(StaffId),
|
||||
/// The gap between two staves of a system.
|
||||
InterStaffGap,
|
||||
/// The gap between two systems on a page.
|
||||
InterSystemGap,
|
||||
/// A page-margin band.
|
||||
MarginBand,
|
||||
}
|
||||
|
||||
/// A vertical band: a horizontal slice of the canvas with its own spring
|
||||
/// parameters (Chapter 7 §"Vertical Bands"). The constraint solver consumes
|
||||
/// bands uniformly, just like horizontal spring slots.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct VerticalBand {
|
||||
pub id: VerticalBandId,
|
||||
pub kind: VerticalBandKind,
|
||||
/// The smallest the band may compress to, in staff spaces.
|
||||
pub min_height: StaffSpace,
|
||||
/// The band's natural height under unconstrained spacing, in staff spaces.
|
||||
pub preferred_height: StaffSpace,
|
||||
/// The largest the band may stretch to, if bounded, in staff spaces.
|
||||
pub max_height: Option<StaffSpace>,
|
||||
/// How readily the band stretches when more height is available.
|
||||
pub stretch_factor: f32,
|
||||
/// How readily the band compresses when less height is available.
|
||||
pub compress_factor: f32,
|
||||
/// The glyphs belonging to this band.
|
||||
pub members: Vec<GlyphObjectId>,
|
||||
}
|
||||
|
||||
impl VerticalBand {
|
||||
/// A default staff band, with the band id derived from the staff source
|
||||
/// alone. Use this for a staff with a single manifestation; for a staff
|
||||
/// manifested in a specific region, use [`VerticalBand::staff_manifestation`]
|
||||
/// so two manifestations get two distinct band ids.
|
||||
pub fn staff(staff: StaffId, members: Vec<GlyphObjectId>) -> Self {
|
||||
Self::with_id(
|
||||
VerticalBandId(stable_layout_id(&TypedObjectId::Staff(staff)).0),
|
||||
staff,
|
||||
members,
|
||||
)
|
||||
}
|
||||
|
||||
/// A staff band for a specific manifestation, identified by the staff
|
||||
/// layout object's stable id (`(staff, region)` — see
|
||||
/// [`crate::Provenance::manifested`]). Two manifestations of one staff thus
|
||||
/// get two distinct band ids, mirroring the two distinct staff layout
|
||||
/// objects.
|
||||
pub fn staff_manifestation(
|
||||
manifestation: LayoutObjectId,
|
||||
staff: StaffId,
|
||||
members: Vec<GlyphObjectId>,
|
||||
) -> Self {
|
||||
Self::with_id(VerticalBandId(manifestation.0), staff, members)
|
||||
}
|
||||
|
||||
/// A margin band (for region content with no staff, e.g. a free-graphic
|
||||
/// region), identified by `id` (typically the region's layout id).
|
||||
pub fn margin(id: LayoutObjectId, members: Vec<GlyphObjectId>) -> Self {
|
||||
let four_spaces = StaffSpace(4.0);
|
||||
VerticalBand {
|
||||
id: VerticalBandId(id.0),
|
||||
kind: VerticalBandKind::MarginBand,
|
||||
min_height: four_spaces,
|
||||
preferred_height: four_spaces,
|
||||
max_height: None,
|
||||
stretch_factor: 1.0,
|
||||
compress_factor: 1.0,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
/// An inter-staff gap band: the empty (member-less) spacing region between
|
||||
/// two staves of a system (Chapter 7 §"Vertical Bands": `InterStaffGap`). Its
|
||||
/// height is a spring the solver resolves; it carries no glyphs.
|
||||
pub fn inter_staff_gap(id: VerticalBandId) -> Self {
|
||||
VerticalBand {
|
||||
id,
|
||||
kind: VerticalBandKind::InterStaffGap,
|
||||
min_height: StaffSpace(1.0),
|
||||
preferred_height: StaffSpace(2.0),
|
||||
max_height: None,
|
||||
stretch_factor: 1.0,
|
||||
compress_factor: 1.0,
|
||||
members: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An inter-system gap band used during page casting-off.
|
||||
pub fn inter_system_gap(id: VerticalBandId) -> Self {
|
||||
VerticalBand {
|
||||
id,
|
||||
kind: VerticalBandKind::InterSystemGap,
|
||||
min_height: StaffSpace(2.0),
|
||||
preferred_height: StaffSpace(4.0),
|
||||
max_height: None,
|
||||
stretch_factor: 1.0,
|
||||
compress_factor: 1.0,
|
||||
members: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_id(id: VerticalBandId, staff: StaffId, members: Vec<GlyphObjectId>) -> Self {
|
||||
// 4 staff spaces between the outer lines of a 5-line staff.
|
||||
let four_spaces = StaffSpace(4.0);
|
||||
VerticalBand {
|
||||
id,
|
||||
kind: VerticalBandKind::Staff(staff),
|
||||
min_height: four_spaces,
|
||||
preferred_height: four_spaces,
|
||||
max_height: None,
|
||||
stretch_factor: 1.0,
|
||||
compress_factor: 1.0,
|
||||
members,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn staff_band_is_stable_and_carries_members() {
|
||||
let staff = StaffId::from_raw(0x42);
|
||||
let members = vec![GlyphObjectId(1), GlyphObjectId(2)];
|
||||
let a = VerticalBand::staff(staff, members.clone());
|
||||
let b = VerticalBand::staff(staff, members);
|
||||
assert_eq!(a.id, b.id);
|
||||
assert_eq!(a.kind, VerticalBandKind::Staff(staff));
|
||||
assert_eq!(a.members.len(), 2);
|
||||
// A different staff → a different band id.
|
||||
let other = VerticalBand::staff(StaffId::from_raw(0x43), vec![]);
|
||||
assert_ne!(a.id, other.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inter_staff_gap_ids_are_stable_and_separate_from_the_region() {
|
||||
let region = LayoutObjectId(42);
|
||||
assert_eq!(inter_staff_gap_id(region, 1), inter_staff_gap_id(region, 1));
|
||||
assert_ne!(inter_staff_gap_id(region, 1), inter_staff_gap_id(region, 2));
|
||||
assert_ne!(inter_staff_gap_id(region, 1), VerticalBandId(region.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -149,10 +149,19 @@ impl CanonicalEncode for OperationKind {
|
|||
pub enum OperationKindTag {
|
||||
InsertEvent,
|
||||
DeleteEvent,
|
||||
ModifyEvent,
|
||||
RespellPitch,
|
||||
Transpose,
|
||||
CreateCrossCutting,
|
||||
DeleteCrossCutting,
|
||||
ModifyCrossCutting,
|
||||
ChangeRegionTimeModel,
|
||||
InsertRegion,
|
||||
DeleteRegion,
|
||||
InsertStaffInstance,
|
||||
DeleteStaffInstance,
|
||||
SetUserSystemBreak,
|
||||
SetUserPageBreak,
|
||||
DeclareTransaction,
|
||||
Registered(OperationKindRegistryId),
|
||||
}
|
||||
|
|
@ -162,12 +171,21 @@ impl OperationKindTag {
|
|||
match self {
|
||||
OperationKindTag::InsertEvent => 0,
|
||||
OperationKindTag::DeleteEvent => 1,
|
||||
OperationKindTag::RespellPitch => 2,
|
||||
OperationKindTag::CreateCrossCutting => 3,
|
||||
OperationKindTag::ChangeRegionTimeModel => 4,
|
||||
OperationKindTag::SetUserSystemBreak => 5,
|
||||
OperationKindTag::DeclareTransaction => 6,
|
||||
OperationKindTag::Registered(_) => 7,
|
||||
OperationKindTag::ModifyEvent => 2,
|
||||
OperationKindTag::RespellPitch => 3,
|
||||
OperationKindTag::Transpose => 4,
|
||||
OperationKindTag::CreateCrossCutting => 5,
|
||||
OperationKindTag::DeleteCrossCutting => 6,
|
||||
OperationKindTag::ModifyCrossCutting => 7,
|
||||
OperationKindTag::ChangeRegionTimeModel => 8,
|
||||
OperationKindTag::InsertRegion => 9,
|
||||
OperationKindTag::DeleteRegion => 10,
|
||||
OperationKindTag::InsertStaffInstance => 11,
|
||||
OperationKindTag::DeleteStaffInstance => 12,
|
||||
OperationKindTag::SetUserSystemBreak => 13,
|
||||
OperationKindTag::SetUserPageBreak => 14,
|
||||
OperationKindTag::DeclareTransaction => 15,
|
||||
OperationKindTag::Registered(_) => 16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -531,6 +549,33 @@ mod tests {
|
|||
assert_eq!(prim.tag(), OperationKindTag::RespellPitch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_normative_operation_tag_has_a_distinct_canonical_discriminant() {
|
||||
let tags = [
|
||||
OperationKindTag::InsertEvent,
|
||||
OperationKindTag::DeleteEvent,
|
||||
OperationKindTag::ModifyEvent,
|
||||
OperationKindTag::RespellPitch,
|
||||
OperationKindTag::Transpose,
|
||||
OperationKindTag::CreateCrossCutting,
|
||||
OperationKindTag::DeleteCrossCutting,
|
||||
OperationKindTag::ModifyCrossCutting,
|
||||
OperationKindTag::ChangeRegionTimeModel,
|
||||
OperationKindTag::InsertRegion,
|
||||
OperationKindTag::DeleteRegion,
|
||||
OperationKindTag::InsertStaffInstance,
|
||||
OperationKindTag::DeleteStaffInstance,
|
||||
OperationKindTag::SetUserSystemBreak,
|
||||
OperationKindTag::SetUserPageBreak,
|
||||
OperationKindTag::DeclareTransaction,
|
||||
];
|
||||
let encoded: std::collections::BTreeSet<_> = tags
|
||||
.iter()
|
||||
.map(CanonicalEncode::to_canonical_bytes)
|
||||
.collect();
|
||||
assert_eq!(encoded.len(), tags.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassign_remapping_is_order_independent() {
|
||||
let e1 = EventId::new(ReplicaId(1), 1);
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ edition.workspace = true
|
|||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
description = "The Epiphany conformance testkit (spec QUICKSTART, Agent F): deterministic generators for the public types, the canonical round-trip harness, the CRDT convergence and equivocation harnesses, and the crash-recovery and manifest-selection gates. Cross-cutting; drives the real epiphany-determinism/core/ops/bundle crates and carries a faithful in-tree stub only for the not-yet-landed epiphany-layout-ir."
|
||||
description = "The Epiphany conformance testkit (spec QUICKSTART, Agent F): deterministic generators for the public types, the canonical round-trip harness, the CRDT convergence and equivocation harnesses, the crash-recovery and manifest-selection gates, and the layout round-trip. Cross-cutting; drives the real epiphany-determinism/core/ops/bundle/layout-ir crates."
|
||||
|
||||
[dependencies]
|
||||
epiphany-determinism.workspace = true
|
||||
epiphany-core.workspace = true
|
||||
epiphany-bundle.workspace = true
|
||||
epiphany-ops.workspace = true
|
||||
# Agent E's layout IR has landed; the layout round-trip harness (criterion 6)
|
||||
# now drives the real crate instead of an in-tree stub.
|
||||
epiphany-layout-ir.workspace = true
|
||||
|
||||
# Drives the whole suite at scale outside the unit-test timeout — the analogue
|
||||
# of epiphany-determinism's `fuzz_roundtrip` and epiphany-bundle's `fuzz_crash`.
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ that proves the other crates work end to end and that runs in CI (see
|
|||
It provides:
|
||||
|
||||
- **Deterministic property-test generators** for the public types of A
|
||||
(`epiphany-determinism`), B (`epiphany-core`), C (`epiphany-ops`), and D
|
||||
(`epiphany-bundle`), plus the layout types of the E stub. Agent B's score-graph
|
||||
(`epiphany-determinism`), B (`epiphany-core`), C (`epiphany-ops`), D
|
||||
(`epiphany-bundle`), and E (`epiphany-layout-ir`). Agent B's score-graph
|
||||
generators/shrinkers are re-exported as `generators::graph`.
|
||||
- **The canonical round-trip harness** (`roundtrip`) — v0 acceptance criterion 4.
|
||||
- **The CRDT convergence harness** (`convergence`) — criteria 1 and 5.
|
||||
|
|
@ -18,12 +18,11 @@ It provides:
|
|||
- **The manifest-selection harness** (`bundle_harness`).
|
||||
- **The layout round-trip harness** (`layout_stub`) — criterion 6.
|
||||
|
||||
## Real vs. stub
|
||||
## All harnesses are real
|
||||
|
||||
The QUICKSTART charters Agent F to *"build against A and stubs for the others."*
|
||||
A, B, C, and D have all shipped; only Agent E (`epiphany-layout-ir`) has not
|
||||
landed. So all of the operation-semantics and serialization harnesses drive the
|
||||
**real** crates; only the layout round-trip uses an in-tree stub.
|
||||
All five implementation crates — A, B, C, D, and now E (`epiphany-layout-ir`) —
|
||||
have shipped, so every harness drives the **real** crate.
|
||||
|
||||
| Harness | Backend | Status |
|
||||
|---------|---------|--------|
|
||||
|
|
@ -31,18 +30,18 @@ landed. So all of the operation-semantics and serialization harnesses drive the
|
|||
| `bundle_harness` (criterion 2, manifest selection) | D, real | **real** |
|
||||
| `convergence` (criteria 1, 5) | C (`epiphany-ops`), real | **real** |
|
||||
| `equivocation` (criterion 3) | C (`epiphany-ops`), real | **real** |
|
||||
| `layout_stub` (criterion 6) | in-tree stub | **stub** for E |
|
||||
| `layout_stub` (criterion 6) | E (`epiphany-layout-ir`), real | **real** |
|
||||
|
||||
For criteria 1, 3, and 5 the testkit drives the real
|
||||
`epiphany_ops::OperationSet` / `canonical_reduction_order` / reduce and also
|
||||
re-exports Agent C's own authoritative gates
|
||||
(`convergence::ops_reduction_determinism_fuzz`,
|
||||
`equivocation::ops_equivocation_fuzz`). The layout stub is **not** a copy of the
|
||||
future crate: it implements only the slice of Chapters 7 & 9 the round-trip
|
||||
exercises — the four IR stages, the `TimeAxisModel` tagged enum, the
|
||||
`Provenance` back-references, and the stub solver that returns
|
||||
`SolveStatus::Solved` with the input geometry verbatim — using the spec's field
|
||||
names, so it re-points at the real crate with minimal churn.
|
||||
`equivocation::ops_equivocation_fuzz`). The `layout_stub` module — once a
|
||||
faithful in-tree stub of Chapters 7 & 9 — now re-exports the real
|
||||
`epiphany-layout-ir` IR types and stub solver behind the same `round_trip`
|
||||
signature; the provenance-preservation contract is implemented and tested inside
|
||||
that crate. (The "stub" in the module name now refers to the spec-sanctioned
|
||||
*stub constraint solver*, not to a stubbed crate.)
|
||||
|
||||
## Criterion 4: what is and isn't tested
|
||||
|
||||
|
|
@ -96,10 +95,9 @@ not just modeled.
|
|||
bounded draws and an overflow-safe full-range `range`), so every failure
|
||||
reproduces from its seed.
|
||||
2. **Drive the real crate once it ships; stub only what hasn't landed.** Earlier
|
||||
in development `epiphany-ops` was in-flight and the ops harnesses ran against a
|
||||
faithful in-tree stub; now that C has shipped they drive the real crate and
|
||||
re-export its gates. Only the layout round-trip remains stubbed (E has not
|
||||
landed).
|
||||
in development `epiphany-ops` (C) and `epiphany-layout-ir` (E) were in-flight
|
||||
and their harnesses ran against faithful in-tree stubs; now that both have
|
||||
shipped, every harness drives the real crate and re-exports its gates.
|
||||
|
||||
## Flagged for a future spec pass (Pass 11 candidates)
|
||||
|
||||
|
|
@ -109,10 +107,12 @@ Per the QUICKSTART, implementation-discovered gaps are batched, not improvised:
|
|||
decode round-trip at the canonical Chapter-6 `MaterializedState` layer. A
|
||||
separate direct wire format for the richer core `Score` remains owned by the
|
||||
Binary Format companion.
|
||||
- **Re-point the layout harness.** When `epiphany-layout-ir` lands, swap
|
||||
`layout_stub` for the real IR types behind the same `round_trip` signature; the
|
||||
provenance-preservation contract it asserts is the one the real crate must also
|
||||
satisfy.
|
||||
- **Layout harness re-pointed.** `epiphany-layout-ir` has landed, so `layout_stub`
|
||||
now drives the real IR types behind the same `round_trip` signature (done). IR
|
||||
coordinates are f32 staff spaces, quantized only when serializing canonical
|
||||
`ResolvedLayoutIR` (Appendix D); see that crate's `DECISIONS.md` for the
|
||||
remaining layout-specific Pass 11 candidates (the `OperationKindTag` variant set
|
||||
and the layout-object id derivation).
|
||||
|
||||
## Running
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
//! Deterministic property-test generators for the public types of A
|
||||
//! ([`epiphany_determinism`]), B ([`epiphany_core`]), C ([`epiphany_ops`]), and
|
||||
//! D ([`epiphany_bundle`]). Agent E has not landed; its types are stubbed in
|
||||
//! [`crate::layout_stub`] and generated there.
|
||||
//! D ([`epiphany_bundle`]). Agent E's layout-IR types
|
||||
//! ([`epiphany_layout_ir`]) are generated in [`crate::layout_stub`], which
|
||||
//! drives the real crate.
|
||||
//!
|
||||
//! Every generator draws from the seeded [`Rng`], so a failing case reproduces
|
||||
//! from its seed (Appendix D §"Randomness": no platform entropy in the harness).
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -12,12 +12,13 @@
|
|||
//! > from finding regressions in weeks 12+. … The harness is the architecture's
|
||||
//! > tripwire.
|
||||
//!
|
||||
//! ## What is real and what is stubbed
|
||||
//! ## What is real
|
||||
//!
|
||||
//! The QUICKSTART charters Agent F to *"build against A and stubs for the
|
||||
//! others."* At the time of writing, Agents A ([`epiphany_determinism`]),
|
||||
//! B ([`epiphany_core`]), C ([`epiphany_ops`]), and D ([`epiphany_bundle`]) have
|
||||
//! shipped, so the harnesses that depend on them are **real**:
|
||||
//! others."* All five implementation crates have now shipped — Agents A
|
||||
//! ([`epiphany_determinism`]), B ([`epiphany_core`]), C ([`epiphany_ops`]),
|
||||
//! D ([`epiphany_bundle`]), and E ([`epiphany_layout_ir`]) — so **every**
|
||||
//! harness drives the real crate:
|
||||
//!
|
||||
//! * [`roundtrip`] — the canonical round-trip harness, driving every
|
||||
//! `CanonicalEncode`/`CanonicalDecode` type in A and B, the real
|
||||
|
|
@ -34,20 +35,17 @@
|
|||
//! * [`equivocation`] — the equivocation harness (criterion 3), driving the real
|
||||
//! [`epiphany_ops::OperationSlot`] model and re-exporting Agent C's gate.
|
||||
//!
|
||||
//! Agent E (`epiphany-layout-ir`, Chapters 7 & 9) has **not** landed, so the
|
||||
//! layout round-trip runs against a **faithful in-tree stub** that implements
|
||||
//! the spec's contract directly:
|
||||
//!
|
||||
//! * [`layout_stub`] — a minimal but spec-faithful Chapter 7 / Chapter 9 model:
|
||||
//! the four IR stages, the `TimeAxisModel` tagged enum, the provenance
|
||||
//! back-references, and the stub solver that returns
|
||||
//! [`layout_stub::SolveStatus::Solved`] with the input geometry verbatim.
|
||||
//! Drives the layout round-trip (criterion 6).
|
||||
//!
|
||||
//! The stub is documented as a stub and is written so that, when
|
||||
//! `epiphany-layout-ir` lands, [`layout_stub::round_trip`] re-points at the real
|
||||
//! IR types with minimal churn: the stub types mirror the spec's field names and
|
||||
//! the provenance/solve contracts are the ones the real crate must also satisfy.
|
||||
//! * [`layout_stub`] — the layout round-trip harness (criterion 6). Agent E
|
||||
//! (`epiphany-layout-ir`, Chapters 7 & 9) has landed, so this module — once a
|
||||
//! faithful in-tree stub — now re-exports the **real** IR types (the four IR
|
||||
//! stages, the `TimeAxisModel` tagged enum, the provenance back-references,
|
||||
//! the engraving-decision and vertical-band models, the glyph-catalog
|
||||
//! identity, and the real stub solver) behind the same
|
||||
//! [`layout_stub::round_trip`] signature. The provenance-preservation contract
|
||||
//! it asserts is implemented and tested inside `epiphany-layout-ir`; the
|
||||
//! testkit retains deterministic generators for E's public types and exercises
|
||||
//! the real round-trip on its hand-off fixtures. (The module name is kept so
|
||||
//! the harness entry point stays `layout_stub::round_trip`.)
|
||||
//!
|
||||
//! ## Determinism of the harness itself
|
||||
//!
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
//! versions (1M / 10k iterations) live in `examples/conformance_suite.rs`; these
|
||||
//! run a meaningful slice under the `cargo test` timeout.
|
||||
//!
|
||||
//! Criteria 1–5 run against the real shipped crates (A/B/C/D). Criterion 6 runs
|
||||
//! against the testkit's faithful in-tree layout stub, since Agent E
|
||||
//! (`epiphany-layout-ir`) has not landed. See the crate docs for the stub policy.
|
||||
//! All six criteria run against the real shipped crates (A/B/C/D/E): criterion
|
||||
//! 6 drives the real `epiphany-layout-ir` through the `layout_stub` harness
|
||||
//! module (Agent E has landed). See the crate docs for the harness policy.
|
||||
|
||||
use epiphany_testkit::{
|
||||
bundle_harness, convergence, equivocation, fixtures, generators, layout_stub, roundtrip, Rng,
|
||||
|
|
|
|||
Loading…
Reference in New Issue