Editor T4-pre W1: the resolved layout stops discarding its own partition
ResolvedLayoutIR carried a page/system tree and three flat primitive arrays with nothing joining them, so a canvas wanting to re-tessellate one damaged system had to infer ownership spatially — ambiguous exactly where it matters, at cross-system slurs and boundary-straddling strokes. The partition was never missing, though: casting-off computes it (system_of_slot, stroke_system, curve_system) and the fold into ResolvedLayoutIR dropped it. This publishes it instead of inferring it, so the answer is exact. ResolvedSystem gains PrimitiveIndices — u32 index lists into the layout's flat glyphs/strokes/curves — and ResolvedLayoutIR gains an unowned bucket of the same shape. No primitive is split, merged, reordered, or renumbered; the flat arrays are exactly what they were. The partition is total and disjoint: for each array, every system's list plus the unowned bucket covers 0..len exactly once, tested directly rather than assumed from construction. Unowned is a first-class bucket with a real producer. The stub solver resolves no per-system geometry — one default-rect system per region — so it publishes every primitive unowned rather than fabricating an attribution it never computed. Cross-system primitives need no special case: casting-off already splits a spanning stroke or curve into per-system segments carrying SYSTEM_CONTINUATION_SYNTHESIS provenance, and each segment is owned by the system it was split into, not by the source's. Ownership is excluded from the canonical encoding, for the reason vertical_band already is: it draws nothing, so two layouts differing only in it are the same rendered layout and hash alike. Encoding it would make the fingerprint more fragile than the rendering it fingerprints — a casting-off refactor that re-partitions without moving a pixel would become a byte-level break for something no renderer and no conformance claim can observe. Verified against main: all eight reference fixtures produce byte-identical canonical layouts, and all five GUI goldens are untouched. One derivation, structurally. The quality census consumed its own copy of the glyph-to-system rule, and so did vertical_raw's system_of_glyph closure; both now read the published glyph_system. That left CastLayout::system_of_slot with no consumer outside the casting pass, so it is removed from the published struct — a future consumer cannot grow a third copy of the rule, because the raw material is no longer there. The per-glyph answer travels; the derivation does not. Six mutations, each killed and coordinator-re-verified independently: dropping an index from a system's list, coercing unowned onto system 0, an off-by-one system index, publishing a constant attribution, attributing a continuation to its source's system, and encoding ownership into the canonical bytes. The fourth is the one that proves the single-derivation claim rather than asserting it — it kills three quality tests, two of which survived it before vertical_raw was migrated. Gate: 34 suites / 1341 tests / 0 failed, conformance 9/9 with golden-gate and 8/8 without, requirement labels 6/6 at 212/282/282, clippy 0, five goldens byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
962eab3ac5
commit
dd33b34f08
|
|
@ -4745,6 +4745,10 @@ mod tests {
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
measures: Vec::new(),
|
measures: Vec::new(),
|
||||||
|
// Mechanical field-add only (CONTRACT_EDITOR_T4PRE_IR.md W1):
|
||||||
|
// this hit-test fixture tests neither ownership nor consumes
|
||||||
|
// it — editor-core adopts the accessor at T4, not here.
|
||||||
|
primitives: Default::default(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let strokes: Vec<Stroke> = origins
|
let strokes: Vec<Stroke> = origins
|
||||||
|
|
|
||||||
|
|
@ -1015,3 +1015,72 @@ cascade defect was adding. Single-staff and every stub golden are byte-stable.
|
||||||
**Still open:** genuinely staff-less content placed *between* two staves. No
|
**Still open:** genuinely staff-less content placed *between* two staves. No
|
||||||
primitive can name an `InterStaffGap` band today, so it remains unreachable
|
primitive can name an `InterStaffGap` band today, so it remains unreachable
|
||||||
rather than latent.
|
rather than latent.
|
||||||
|
|
||||||
|
## W1: casting-off publishes the per-system partition it already computes (Editor T4-pre, `CONTRACT_EDITOR_T4PRE_IR.md`, 2026-07-24)
|
||||||
|
|
||||||
|
`cast_off` already knew which system every glyph, stroke, and curve landed
|
||||||
|
in (`system_of_slot`/`stroke_system`/`curve_system`, private to `CastLayout`)
|
||||||
|
— it just never left the module. Three changes make it leave:
|
||||||
|
|
||||||
|
1. **`glyph_system: Vec<Option<usize>>` joins `CastLayout`**, computed once
|
||||||
|
in the same pass that bakes glyph positions (the glyphs-building `map`
|
||||||
|
that already reads `system_of_slot.get(&glyph.horizontal_slot)` for
|
||||||
|
`dx`/`dy` now also records that same lookup's result per glyph, via
|
||||||
|
`.unzip()`, instead of computing it twice). This is the single
|
||||||
|
glyph→system derivation pin 7 asks for: the quality census (`quality.rs`,
|
||||||
|
`census`) now reads `cast.glyph_system[index]` and no longer re-derives
|
||||||
|
the answer from `cast.system_of_slot` itself.
|
||||||
|
2. **The index partition** (`owned: Vec<PrimitiveIndices>` plus one
|
||||||
|
`unowned: PrimitiveIndices`) is built from `glyph_system`/`stroke_system`/
|
||||||
|
`curve_system` right before `resolved_systems` is constructed, and handed
|
||||||
|
to each `build_system` call (which now takes a `PrimitiveIndices`
|
||||||
|
parameter — pushing it to 8 arguments, `#[allow(clippy::too_many_arguments)]`
|
||||||
|
added alongside the crate's existing precedent on `walk_region`) and to
|
||||||
|
the returned `CastLayout.unowned`. `epiphany-layout-ir`'s solve-report fold
|
||||||
|
(`lib.rs`) copies `cast.unowned` straight into `ResolvedLayoutIR.unowned`
|
||||||
|
with no further computation; the `None`-cast branch (structurally invalid
|
||||||
|
input) gets `PrimitiveIndices::default()`, consistent with its empty
|
||||||
|
glyphs/strokes/curves/pages.
|
||||||
|
3. **No primitive changes shape.** The three flat arrays (`glyphs`, `strokes`
|
||||||
|
after continuation-append, `curves` after continuation-append) are exactly
|
||||||
|
what casting-off already produced; W1 publishes indices into them and
|
||||||
|
nothing else — including for the synthesized continuation segments a
|
||||||
|
system-spanning stroke or curve splits into, which were already being
|
||||||
|
pushed onto `stroke_system`/`curve_system` keyed by their *own* landing
|
||||||
|
system (`segments[k].0` inside the `Split` arm), not the source segment's.
|
||||||
|
W1 did not have to fix this; it only had to publish it faithfully, and the
|
||||||
|
mutation that deliberately breaks that faithfulness (attribute the
|
||||||
|
continuation to the source's system instead) kills a dedicated test
|
||||||
|
(`continuation_segments_are_owned_by_the_system_they_split_into`).
|
||||||
|
|
||||||
|
**A second, un-migrated re-derivation was found while doing this packet, and
|
||||||
|
closed rather than named.** `quality.rs`'s `vertical_raw` (the inter-staff-gap
|
||||||
|
axis) had its own local closure, `system_of_glyph`, independently re-deriving
|
||||||
|
a glyph's system from the slot map — the exact second copy of the attribution
|
||||||
|
rule pin 7 exists to prevent, just not the one m4 tests for. The packet's
|
||||||
|
first pass migrated only the census (pin 7 names it specifically) and recorded
|
||||||
|
this as an open finding; coordinator review closed it instead, because a pin
|
||||||
|
whose stated goal is "two copies drift, so make that impossible" is not met by
|
||||||
|
migrating one of two copies.
|
||||||
|
|
||||||
|
**And closing it made `CastLayout::system_of_slot` dead**, which is the real
|
||||||
|
lesson: with both consumers on `glyph_system`, nothing outside the casting
|
||||||
|
pass read the raw slot→system map any more. So the field is **removed** from
|
||||||
|
the published struct — the map stays a local inside `cast_off`, where the
|
||||||
|
stroke-fate walk still needs it. Pin 7 is now structural rather than
|
||||||
|
conventional: a future consumer *cannot* grow a third copy of the glyph→system
|
||||||
|
rule from `CastLayout`, because the raw material is no longer on it. The
|
||||||
|
per-glyph answer is published, the derivation is not.
|
||||||
|
|
||||||
|
**Byte-neutral:** see the `epiphany-layout-ir` DECISIONS.md entry of the same
|
||||||
|
date for the full canonical-encoding exclusion rationale (two consequences:
|
||||||
|
fingerprint fragility, and what a future cross-implementation relayout test
|
||||||
|
may and may not compare) — `casting.rs`'s `encode_canonical` path
|
||||||
|
(`epiphany-layout-ir/src/resolved.rs`) is unchanged by this tranche.
|
||||||
|
|
||||||
|
**Locked by** `primitive_ownership_partitions_every_flat_array_totally_and_
|
||||||
|
disjointly`, `attribution_correctness_matches_the_real_per_system_counts`,
|
||||||
|
and `continuation_segments_are_owned_by_the_system_they_split_into`
|
||||||
|
(`casting.rs`), each against the real engraver on real fixtures — the real
|
||||||
|
per-system glyph/stroke counts are `[26, 25]` and `[51, 45]` for the
|
||||||
|
`ten_measure_single_staff` two-system split, not asserted as `> 0`.
|
||||||
|
|
|
||||||
|
|
@ -76,10 +76,10 @@ use epiphany_layout_ir::{
|
||||||
continuation_instance_key, inter_staff_gap_id, is_barline_glyph, is_rigid_width_stroke,
|
continuation_instance_key, inter_staff_gap_id, is_barline_glyph, is_rigid_width_stroke,
|
||||||
synthesized_layout_id, BreakClass, BreakKind, ConstrainedLayoutIR, Curve, DecisionSource,
|
synthesized_layout_id, BreakClass, BreakKind, ConstrainedLayoutIR, Curve, DecisionSource,
|
||||||
EngravingDecision, EngravingDecisionKind, EngravingOverrideId, GlyphObject, GlyphObjectId,
|
EngravingDecision, EngravingDecisionKind, EngravingOverrideId, GlyphObject, GlyphObjectId,
|
||||||
LayoutConstraint, LayoutObjectId, Margins, Point, Provenance, Rect, ResolvedGlyph,
|
LayoutConstraint, LayoutObjectId, Margins, Point, PrimitiveIndices, Provenance, Rect,
|
||||||
ResolvedMeasure, ResolvedPage, ResolvedStaff, ResolvedSystem, Size2D, SpringSlotId, StaffSpace,
|
ResolvedGlyph, ResolvedMeasure, ResolvedPage, ResolvedStaff, ResolvedSystem, Size2D,
|
||||||
Stroke, SynthesisInstanceKey, SynthesisKind, SynthesisRegistryId, VerticalBand, VerticalBandId,
|
SpringSlotId, StaffSpace, Stroke, SynthesisInstanceKey, SynthesisKind, SynthesisRegistryId,
|
||||||
VerticalBandKind,
|
VerticalBand, VerticalBandId, VerticalBandKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::owning_glyph;
|
use crate::owning_glyph;
|
||||||
|
|
@ -188,14 +188,17 @@ pub(crate) struct CastLayout {
|
||||||
pub system_start_slots: BTreeSet<SpringSlotId>,
|
pub system_start_slots: BTreeSet<SpringSlotId>,
|
||||||
/// Slots at which a page begins: the first slot of each page's first system.
|
/// Slots at which a page begins: the first slot of each page's first system.
|
||||||
pub page_start_slots: BTreeSet<SpringSlotId>,
|
pub page_start_slots: BTreeSet<SpringSlotId>,
|
||||||
/// Which system (global index, page order) each realized slot landed in —
|
/// The system each baked glyph landed in, parallel to `glyphs` — derived
|
||||||
/// the casting pass's own assignment, which the quality-metric census
|
/// once, inside the casting pass, from the slot→system assignment that
|
||||||
/// ranges over (a slot absent here was claimed by no region and its glyphs
|
/// pass computes for its own use. **This is the glyph→system attribution,
|
||||||
/// belong to no per-system aggregate).
|
/// full stop** (W1 pin 7): the raw slot map is deliberately *not*
|
||||||
pub system_of_slot: BTreeMap<SpringSlotId, usize>,
|
/// published, so no consumer can grow a second copy of the rule that then
|
||||||
|
/// drifts. `None`: the glyph's slot was claimed by no region, so it
|
||||||
|
/// belongs to no per-system aggregate.
|
||||||
|
pub glyph_system: Vec<Option<usize>>,
|
||||||
/// The system each baked stroke landed in, parallel to `strokes` (including
|
/// The system each baked stroke landed in, parallel to `strokes` (including
|
||||||
/// the appended continuation segments). A stroke carries no spring slot, so
|
/// the appended continuation segments). A stroke carries no spring slot, so
|
||||||
/// `system_of_slot` cannot answer for it; the casting pass records what it
|
/// the slot map cannot answer for it; the casting pass records what it
|
||||||
/// already knew. `None`: claimed by no region.
|
/// already knew. `None`: claimed by no region.
|
||||||
pub stroke_system: Vec<Option<usize>>,
|
pub stroke_system: Vec<Option<usize>>,
|
||||||
/// The system each baked curve landed in, parallel to `curves`.
|
/// The system each baked curve landed in, parallel to `curves`.
|
||||||
|
|
@ -203,6 +206,11 @@ pub(crate) struct CastLayout {
|
||||||
/// The region each system slices, indexed by global system index (the
|
/// The region each system slices, indexed by global system index (the
|
||||||
/// per-region grouping the casting-off quality metrics aggregate by).
|
/// per-region grouping the casting-off quality metrics aggregate by).
|
||||||
pub region_of_system: Vec<usize>,
|
pub region_of_system: Vec<usize>,
|
||||||
|
/// The primitives no system claims — `glyph_system`/`stroke_system`/
|
||||||
|
/// `curve_system` entries of `None`, gathered into the same shape
|
||||||
|
/// [`ResolvedSystem::primitives`] uses (W1 pin 3: unowned is a first-class
|
||||||
|
/// bucket, never coerced onto a system).
|
||||||
|
pub unowned: PrimitiveIndices,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One realized spring slot in spaced (pre-casting) coordinates, with the
|
/// One realized spring slot in spaced (pre-casting) coordinates, with the
|
||||||
|
|
@ -987,13 +995,17 @@ pub(crate) fn cast_off(
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(0.0)
|
.unwrap_or(0.0)
|
||||||
};
|
};
|
||||||
let glyphs: Vec<ResolvedGlyph> = spaced_glyphs
|
// Computed once, alongside the positioning it also drives (W1 pin 7): the
|
||||||
|
// quality-metric census consumes this published vector rather than
|
||||||
|
// re-deriving the same attribution from `system_of_slot` itself.
|
||||||
|
let (glyphs, glyph_system): (Vec<ResolvedGlyph>, Vec<Option<usize>>) = spaced_glyphs
|
||||||
.iter()
|
.iter()
|
||||||
.zip(&input.glyphs)
|
.zip(&input.glyphs)
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(gi, (spaced, glyph))| {
|
.map(|(gi, (spaced, glyph))| {
|
||||||
let (dx, dy) = match system_of_slot.get(&glyph.horizontal_slot) {
|
let system = system_of_slot.get(&glyph.horizontal_slot).copied();
|
||||||
Some(&s) => {
|
let (dx, dy) = match system {
|
||||||
|
Some(s) => {
|
||||||
// Slot-relative: every member of a slot translates by the
|
// Slot-relative: every member of a slot translates by the
|
||||||
// map at the slot's source, so intra-slot offsets survive.
|
// map at the slot's source, so intra-slot offsets survive.
|
||||||
let sx = slot_source_x
|
let sx = slot_source_x
|
||||||
|
|
@ -1007,12 +1019,13 @@ pub(crate) fn cast_off(
|
||||||
}
|
}
|
||||||
None => (0.0, 0.0),
|
None => (0.0, 0.0),
|
||||||
};
|
};
|
||||||
ResolvedGlyph {
|
let resolved = ResolvedGlyph {
|
||||||
position: Point::new(spaced.position.x.0 + dx, spaced.position.y.0 + dy),
|
position: Point::new(spaced.position.x.0 + dx, spaced.position.y.0 + dy),
|
||||||
..spaced.clone()
|
..spaced.clone()
|
||||||
}
|
};
|
||||||
|
(resolved, system)
|
||||||
})
|
})
|
||||||
.collect();
|
.unzip();
|
||||||
|
|
||||||
// Per-system staff-line marks, for the resolved staff records below.
|
// Per-system staff-line marks, for the resolved staff records below.
|
||||||
let mut staff_marks: BTreeMap<(usize, StaffId), StaffAgg> = BTreeMap::new();
|
let mut staff_marks: BTreeMap<(usize, StaffId), StaffAgg> = BTreeMap::new();
|
||||||
|
|
@ -1164,6 +1177,34 @@ pub(crate) fn cast_off(
|
||||||
curves.extend(curve_continuations);
|
curves.extend(curve_continuations);
|
||||||
curve_system.extend(curve_continuation_system);
|
curve_system.extend(curve_continuation_system);
|
||||||
|
|
||||||
|
// ---- Per-system primitive ownership (W1) -------------------------------
|
||||||
|
// The partition already exists in `glyph_system`/`stroke_system`/
|
||||||
|
// `curve_system` above; this just stops discarding it. For each flat
|
||||||
|
// array, every index lands in exactly one system's list or in `unowned`
|
||||||
|
// (pin 4: a total, disjoint partition, tested in this module below).
|
||||||
|
let mut owned: Vec<PrimitiveIndices> = (0..systems.len())
|
||||||
|
.map(|_| PrimitiveIndices::default())
|
||||||
|
.collect();
|
||||||
|
let mut unowned = PrimitiveIndices::default();
|
||||||
|
for (i, system) in glyph_system.iter().enumerate() {
|
||||||
|
match system {
|
||||||
|
Some(s) => owned[*s].glyphs.push(i as u32),
|
||||||
|
None => unowned.glyphs.push(i as u32),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, system) in stroke_system.iter().enumerate() {
|
||||||
|
match system {
|
||||||
|
Some(s) => owned[*s].strokes.push(i as u32),
|
||||||
|
None => unowned.strokes.push(i as u32),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, system) in curve_system.iter().enumerate() {
|
||||||
|
match system {
|
||||||
|
Some(s) => owned[*s].curves.push(i as u32),
|
||||||
|
None => unowned.curves.push(i as u32),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- The resolved page tree ---------------------------------------------
|
// ---- The resolved page tree ---------------------------------------------
|
||||||
let resolved_systems: Vec<ResolvedSystem> = systems
|
let resolved_systems: Vec<ResolvedSystem> = systems
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -1177,6 +1218,7 @@ pub(crate) fn cast_off(
|
||||||
&extents,
|
&extents,
|
||||||
&placements,
|
&placements,
|
||||||
&staff_marks,
|
&staff_marks,
|
||||||
|
owned[s].clone(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -1225,10 +1267,11 @@ pub(crate) fn cast_off(
|
||||||
decisions,
|
decisions,
|
||||||
system_start_slots,
|
system_start_slots,
|
||||||
page_start_slots,
|
page_start_slots,
|
||||||
system_of_slot,
|
glyph_system,
|
||||||
stroke_system,
|
stroke_system,
|
||||||
curve_system,
|
curve_system,
|
||||||
region_of_system: systems.iter().map(|plan| plan.region).collect(),
|
region_of_system: systems.iter().map(|plan| plan.region).collect(),
|
||||||
|
unowned,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1855,6 +1898,7 @@ fn mark_staff(
|
||||||
/// the pipeline does not know is left empty, never fabricated: a staff with no
|
/// the pipeline does not know is left empty, never fabricated: a staff with no
|
||||||
/// engraved lines yields no staff record, and the final-barline measure (whose
|
/// engraved lines yields no staff record, and the final-barline measure (whose
|
||||||
/// start no column marks) yields no measure record.
|
/// start no column marks) yields no measure record.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_system(
|
fn build_system(
|
||||||
system: usize,
|
system: usize,
|
||||||
plan: &SystemPlan,
|
plan: &SystemPlan,
|
||||||
|
|
@ -1863,6 +1907,7 @@ fn build_system(
|
||||||
extents: &[Extent],
|
extents: &[Extent],
|
||||||
placements: &[Placement],
|
placements: &[Placement],
|
||||||
staff_marks: &BTreeMap<(usize, StaffId), StaffAgg>,
|
staff_marks: &BTreeMap<(usize, StaffId), StaffAgg>,
|
||||||
|
primitives: PrimitiveIndices,
|
||||||
) -> ResolvedSystem {
|
) -> ResolvedSystem {
|
||||||
let region = &input.regions[plan.region];
|
let region = &input.regions[plan.region];
|
||||||
let p = placements[system];
|
let p = placements[system];
|
||||||
|
|
@ -1952,6 +1997,7 @@ fn build_system(
|
||||||
bounding_box,
|
bounding_box,
|
||||||
staves,
|
staves,
|
||||||
measures,
|
measures,
|
||||||
|
primitives,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2334,4 +2380,196 @@ mod tests {
|
||||||
hi - lo
|
hi - lo
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// (m1) For each of the layout's three flat arrays, every system's owned
|
||||||
|
/// index list plus the layout's `unowned` bucket covers `0..len` exactly
|
||||||
|
/// once — pin 4's total, disjoint partition. The load-bearing invariant,
|
||||||
|
/// checked directly rather than assumed from construction.
|
||||||
|
fn assert_total_disjoint_partition(layout: &epiphany_layout_ir::ResolvedLayoutIR) {
|
||||||
|
let check = |label: &str, len: usize, owned: Vec<&Vec<u32>>, unowned: &[u32]| {
|
||||||
|
let mut seen = vec![0u8; len];
|
||||||
|
for &i in owned.iter().flat_map(|v| v.iter()).chain(unowned.iter()) {
|
||||||
|
let idx = i as usize;
|
||||||
|
assert!(idx < len, "{label}: index {i} out of range (len {len})");
|
||||||
|
seen[idx] += 1;
|
||||||
|
}
|
||||||
|
for (i, &count) in seen.iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
count, 1,
|
||||||
|
"{label}: index {i} covered {count} times (want exactly 1)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let glyph_lists: Vec<&Vec<u32>> = layout.systems().map(|s| &s.primitives.glyphs).collect();
|
||||||
|
check(
|
||||||
|
"glyphs",
|
||||||
|
layout.glyphs.len(),
|
||||||
|
glyph_lists,
|
||||||
|
&layout.unowned.glyphs,
|
||||||
|
);
|
||||||
|
let stroke_lists: Vec<&Vec<u32>> =
|
||||||
|
layout.systems().map(|s| &s.primitives.strokes).collect();
|
||||||
|
check(
|
||||||
|
"strokes",
|
||||||
|
layout.strokes.len(),
|
||||||
|
stroke_lists,
|
||||||
|
&layout.unowned.strokes,
|
||||||
|
);
|
||||||
|
let curve_lists: Vec<&Vec<u32>> = layout.systems().map(|s| &s.primitives.curves).collect();
|
||||||
|
check(
|
||||||
|
"curves",
|
||||||
|
layout.curves.len(),
|
||||||
|
curve_lists,
|
||||||
|
&layout.unowned.curves,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn primitive_ownership_partitions_every_flat_array_totally_and_disjointly() {
|
||||||
|
use crate::Engraver;
|
||||||
|
use epiphany_layout_ir::{to_constrained, to_logical, ConstraintSolver, SolverConfig};
|
||||||
|
|
||||||
|
// The wrapping ten-measure fixture: real glyphs and strokes, no curves.
|
||||||
|
let wrapping = Engraver::default().solve(
|
||||||
|
&to_constrained(&to_logical(
|
||||||
|
&epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE),
|
||||||
|
)),
|
||||||
|
&SolverConfig::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
wrapping.layout.pages[0].systems.len(),
|
||||||
|
2,
|
||||||
|
"the fixture wraps into two systems"
|
||||||
|
);
|
||||||
|
assert!(!wrapping.layout.glyphs.is_empty());
|
||||||
|
assert!(!wrapping.layout.strokes.is_empty());
|
||||||
|
assert_total_disjoint_partition(&wrapping.layout);
|
||||||
|
|
||||||
|
// The slurred fixture: also exercises curves, including a
|
||||||
|
// system-spanning split (G4's own construction).
|
||||||
|
let slurred = Engraver::default().solve(
|
||||||
|
&to_constrained(&to_logical(
|
||||||
|
&epiphany_testkit::fixtures::ten_measure_with_slurs(0),
|
||||||
|
)),
|
||||||
|
&SolverConfig::default(),
|
||||||
|
);
|
||||||
|
let slurred_systems: usize = slurred.layout.pages.iter().map(|p| p.systems.len()).sum();
|
||||||
|
assert!(
|
||||||
|
slurred_systems > 1,
|
||||||
|
"casting-off wraps the slurred fixture too"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!slurred.layout.curves.is_empty(),
|
||||||
|
"the slur produces real curves"
|
||||||
|
);
|
||||||
|
assert_total_disjoint_partition(&slurred.layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attribution_correctness_matches_the_real_per_system_counts() {
|
||||||
|
// (m3) The *actual* per-system glyph/stroke counts of the two-system
|
||||||
|
// fixture — real numbers, not `> 0` — value-asserted directly against
|
||||||
|
// what casting-off computed.
|
||||||
|
use crate::Engraver;
|
||||||
|
use epiphany_layout_ir::{to_constrained, to_logical, ConstraintSolver, SolverConfig};
|
||||||
|
|
||||||
|
let report = Engraver::default().solve(
|
||||||
|
&to_constrained(&to_logical(
|
||||||
|
&epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE),
|
||||||
|
)),
|
||||||
|
&SolverConfig::default(),
|
||||||
|
);
|
||||||
|
let systems: Vec<_> = report.layout.systems().collect();
|
||||||
|
assert_eq!(systems.len(), 2, "two systems");
|
||||||
|
let glyph_counts: Vec<usize> = systems.iter().map(|s| s.primitives.glyphs.len()).collect();
|
||||||
|
let stroke_counts: Vec<usize> =
|
||||||
|
systems.iter().map(|s| s.primitives.strokes.len()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
glyph_counts,
|
||||||
|
vec![26, 25],
|
||||||
|
"the six/four widow-rebalanced measure split's real per-system glyph counts"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stroke_counts,
|
||||||
|
vec![51, 45],
|
||||||
|
"the six/four widow-rebalanced measure split's real per-system stroke counts"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
glyph_counts[0] + glyph_counts[1],
|
||||||
|
report.layout.glyphs.len()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stroke_counts[0] + stroke_counts[1],
|
||||||
|
report.layout.strokes.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
report.layout.unowned.glyphs.is_empty(),
|
||||||
|
"the whole score is inside the one region this fixture declares"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn continuation_segments_are_owned_by_the_system_they_split_into() {
|
||||||
|
// (m5) A slur crossing a system break: its synthesized continuation
|
||||||
|
// segment is owned by the system it was split INTO, not the source
|
||||||
|
// segment's system.
|
||||||
|
use crate::Engraver;
|
||||||
|
use epiphany_core::{Slur, SlurId, SlurKind, SpanStyle, TypedObjectId};
|
||||||
|
use epiphany_layout_ir::{to_constrained, to_logical, ConstraintSolver, SolverConfig};
|
||||||
|
|
||||||
|
let mut score = epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE);
|
||||||
|
let events: Vec<_> = score.canvas.regions[0].staff_instances()[0].voices[0]
|
||||||
|
.events
|
||||||
|
.clone();
|
||||||
|
let slur_id: SlurId = score.identity.mint();
|
||||||
|
score.cross_cutting.slurs.push(Slur {
|
||||||
|
id: slur_id,
|
||||||
|
start_event: events[0],
|
||||||
|
end_event: events[events.len() - 1],
|
||||||
|
kind: SlurKind::Legato,
|
||||||
|
curvature_override: None,
|
||||||
|
style: SpanStyle::default(),
|
||||||
|
});
|
||||||
|
let report = Engraver::default().solve(
|
||||||
|
&to_constrained(&to_logical(&score)),
|
||||||
|
&SolverConfig::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(report.layout.pages[0].systems.len(), 2, "two systems");
|
||||||
|
|
||||||
|
let original_index = report
|
||||||
|
.layout
|
||||||
|
.curves
|
||||||
|
.iter()
|
||||||
|
.position(|c| {
|
||||||
|
c.provenance.source == TypedObjectId::Slur(slur_id)
|
||||||
|
&& c.provenance.synthesis.is_none()
|
||||||
|
})
|
||||||
|
.expect("one segment keeps the slur's exact provenance");
|
||||||
|
let continuation_index = report
|
||||||
|
.layout
|
||||||
|
.curves
|
||||||
|
.iter()
|
||||||
|
.position(|c| {
|
||||||
|
c.provenance.source == TypedObjectId::Slur(slur_id)
|
||||||
|
&& c.provenance.synthesis.is_some()
|
||||||
|
})
|
||||||
|
.expect("the break-spanning slur splits and synthesizes a continuation");
|
||||||
|
|
||||||
|
let owner_of = |index: usize| -> Option<usize> {
|
||||||
|
report
|
||||||
|
.layout
|
||||||
|
.systems()
|
||||||
|
.position(|s| s.primitives.curves.contains(&(index as u32)))
|
||||||
|
};
|
||||||
|
let owner_first = owner_of(original_index).expect("the original segment is owned");
|
||||||
|
let owner_continuation = owner_of(continuation_index).expect("the continuation is owned");
|
||||||
|
assert_eq!(
|
||||||
|
owner_first, 0,
|
||||||
|
"the original segment starts in the first system"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
owner_continuation, 1,
|
||||||
|
"the continuation is owned by the system it was split INTO, not the source's"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,9 @@ use epiphany_core::TypedObjectId;
|
||||||
use epiphany_layout_ir::{
|
use epiphany_layout_ir::{
|
||||||
all_available, profile_thresholds, Axis, BravuraCatalog, ConstrainedLayoutIR, ConstraintId,
|
all_available, profile_thresholds, Axis, BravuraCatalog, ConstrainedLayoutIR, ConstraintId,
|
||||||
ConstraintSolver, ConstraintStrength, Curve, GlyphCatalog, GlyphObject, GlyphObjectId,
|
ConstraintSolver, ConstraintStrength, Curve, GlyphCatalog, GlyphObject, GlyphObjectId,
|
||||||
InvalidationSet, LayoutConstraint, Point, QualityMetricVector, Rect, ResolvedGlyph,
|
InvalidationSet, LayoutConstraint, Point, PrimitiveIndices, QualityMetricVector, Rect,
|
||||||
ResolvedLayoutIR, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig, SolverState,
|
ResolvedGlyph, ResolvedLayoutIR, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig,
|
||||||
SolverTier, SolverVersion, SolverWarning, SolverWarningKind, SpringSlotId, Stroke,
|
SolverState, SolverTier, SolverVersion, SolverWarning, SolverWarningKind, SpringSlotId, Stroke,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use casting::{PageGeometry, INTER_PAGE_GAP, SYSTEM_CONTINUATION_SYNTHESIS};
|
pub use casting::{PageGeometry, INTER_PAGE_GAP, SYSTEM_CONTINUATION_SYNTHESIS};
|
||||||
|
|
@ -380,7 +380,7 @@ impl Engraver {
|
||||||
// glyph/stroke positions baked, the engraver's break decisions appended
|
// glyph/stroke positions baked, the engraver's break decisions appended
|
||||||
// to the pipeline's (Chapter 7 §"ResolvedLayoutIR": decisions "including
|
// to the pipeline's (Chapter 7 §"ResolvedLayoutIR": decisions "including
|
||||||
// any the solver itself made").
|
// any the solver itself made").
|
||||||
let (glyphs, strokes, curves, pages, engraving_decisions) = match cast {
|
let (glyphs, strokes, curves, pages, engraving_decisions, unowned) = match cast {
|
||||||
Some(cast) => {
|
Some(cast) => {
|
||||||
let mut decisions = input.engraving_decisions.clone();
|
let mut decisions = input.engraving_decisions.clone();
|
||||||
decisions.extend(cast.decisions);
|
decisions.extend(cast.decisions);
|
||||||
|
|
@ -390,6 +390,7 @@ impl Engraver {
|
||||||
cast.curves,
|
cast.curves,
|
||||||
cast.pages,
|
cast.pages,
|
||||||
decisions,
|
decisions,
|
||||||
|
cast.unowned,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
None => (
|
None => (
|
||||||
|
|
@ -398,6 +399,7 @@ impl Engraver {
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
input.engraving_decisions.clone(),
|
input.engraving_decisions.clone(),
|
||||||
|
PrimitiveIndices::default(),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -412,6 +414,7 @@ impl Engraver {
|
||||||
curves,
|
curves,
|
||||||
engraving_decisions,
|
engraving_decisions,
|
||||||
catalog: input.catalog.clone(),
|
catalog: input.catalog.clone(),
|
||||||
|
unowned,
|
||||||
},
|
},
|
||||||
unsatisfied_constraints,
|
unsatisfied_constraints,
|
||||||
warnings,
|
warnings,
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,9 @@ fn census(input: &ConstrainedLayoutIR, cast: &CastLayout) -> SystemCensus {
|
||||||
// convention the spacing and casting passes use for a slot's reference x.
|
// convention the spacing and casting passes use for a slot's reference x.
|
||||||
let mut columns: Vec<BTreeMap<SpringSlotId, f64>> = vec![BTreeMap::new(); count];
|
let mut columns: Vec<BTreeMap<SpringSlotId, f64>> = vec![BTreeMap::new(); count];
|
||||||
for (index, glyph) in input.glyphs.iter().enumerate() {
|
for (index, glyph) in input.glyphs.iter().enumerate() {
|
||||||
let Some(&system) = cast.system_of_slot.get(&glyph.horizontal_slot) else {
|
// W1 pin 7: the published attribution, not a second derivation from
|
||||||
|
// `system_of_slot` — one glyph→system rule, computed once in casting.
|
||||||
|
let Some(system) = cast.glyph_system[index] else {
|
||||||
// A slot no region claimed: positioned by no system, so its glyphs
|
// A slot no region claimed: positioned by no system, so its glyphs
|
||||||
// join no per-system aggregate (catalog §"The Measurement Domain").
|
// join no per-system aggregate (catalog §"The Measurement Domain").
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -369,11 +371,10 @@ fn vertical_units(
|
||||||
// the BAKED output, so a shift the bake failed to apply to some primitive
|
// the BAKED output, so a shift the bake failed to apply to some primitive
|
||||||
// class surfaces here as a real deviation rather than hiding behind the
|
// class surfaces here as a real deviation rather than hiding behind the
|
||||||
// solver's own intent.
|
// solver's own intent.
|
||||||
let system_of_glyph = |index: usize| -> Option<usize> {
|
// W1 pin 7, same as the census: the published attribution, not a second
|
||||||
cast.system_of_slot
|
// derivation. `glyph_system` is parallel to `input.glyphs`, exactly as the
|
||||||
.get(&input.glyphs[index].horizontal_slot)
|
// `stroke_system`/`curve_system` reads below are to their arrays.
|
||||||
.copied()
|
let system_of_glyph = |index: usize| -> Option<usize> { cast.glyph_system[index] };
|
||||||
};
|
|
||||||
let mut content: BTreeMap<(usize, VerticalBandId), (f64, f64)> = BTreeMap::new();
|
let mut content: BTreeMap<(usize, VerticalBandId), (f64, f64)> = BTreeMap::new();
|
||||||
{
|
{
|
||||||
let mut add = |system: usize, band: VerticalBandId, lo: f64, hi: f64| {
|
let mut add = |system: usize, band: VerticalBandId, lo: f64, hi: f64| {
|
||||||
|
|
|
||||||
|
|
@ -930,3 +930,95 @@ table as a non-normative note) and no new `\label{req:...}` (counts stay
|
||||||
212/282/282). Both Chapter 9 usages (`GlyphCatalog::smufl_version()`,
|
212/282/282). Both Chapter 9 usages (`GlyphCatalog::smufl_version()`,
|
||||||
`GlyphCatalogIdentity.smufl_version`) gain a cross-reference sentence back to
|
`GlyphCatalogIdentity.smufl_version`) gain a cross-reference sentence back to
|
||||||
this single definition.
|
this single definition.
|
||||||
|
|
||||||
|
## W1: per-system primitive ownership — a published partition, not a new computation (Editor T4-pre, `CONTRACT_EDITOR_T4PRE_IR.md`, 2026-07-24)
|
||||||
|
|
||||||
|
`ResolvedSystem` gains one field, `primitives: PrimitiveIndices` (index lists
|
||||||
|
— `u32`, one per flat array — into the layout's `glyphs`/`strokes`/`curves`);
|
||||||
|
`ResolvedLayoutIR` gains one field of the same shape, `unowned`, for a
|
||||||
|
primitive no system claims. Nothing is split, merged, reordered, or
|
||||||
|
renumbered to populate either: casting-off (`epiphany-engrave`) already
|
||||||
|
computed this partition (`system_of_slot`/`stroke_system`/`curve_system` on
|
||||||
|
its private `CastLayout`) and discarded it at the fold into `ResolvedLayoutIR`;
|
||||||
|
this tranche stops discarding it. `ResolvedLayoutIR` also gains a `systems()`
|
||||||
|
accessor — every system across every page, in page order — which the
|
||||||
|
partition's own tests use; `epiphany-editor-core` keeps its hand-rolled
|
||||||
|
equivalent (`lib.rs`, `containing_system`) until it adopts the accessor at
|
||||||
|
T4, not before (Ruling A's stated prerequisite boundary: this tranche is IR
|
||||||
|
work, not editor-crate consumption).
|
||||||
|
|
||||||
|
**Verified caution for T4b, recorded here because the aliasing is easy to miss
|
||||||
|
and the bug it invites is specific.** A system's `provenance.stable_id` is
|
||||||
|
**not** always distinct from its page's or its region's: a region's first
|
||||||
|
system reuses the region's own provenance verbatim (`casting.rs`, `build_system`),
|
||||||
|
and page 1 reuses the first region's provenance too (`casting.rs`, the page-tree
|
||||||
|
construction) — so on the real engraver's path, a system's `stable_id` can equal
|
||||||
|
a page's and a region's for the *first* system of the *first* region. On the
|
||||||
|
**stub solver's path the aliasing is total, not just first-system**: every stub
|
||||||
|
system reuses its region's provenance verbatim (`solver.rs`), because the stub
|
||||||
|
resolves no per-system geometry and does not synthesize per-system identity
|
||||||
|
either. Uniqueness *among systems* is structural regardless — synthesized ids
|
||||||
|
are domain-tagged hashes over `(source, kind discriminant, namespaced instance
|
||||||
|
key)`, and `KEY_NS_SYSTEM`/`KEY_NS_PAGE` occupy distinct namespaces — which is
|
||||||
|
all W1 needs (position is primary; `stable_id` is available identity, not the
|
||||||
|
addressing scheme). But **any future cross-kind map keyed on a raw
|
||||||
|
`LayoutObjectId`/`SystemId` `u128`** (a T4b incremental-relayout cache
|
||||||
|
keying systems, pages, and regions into one structure, say) **must
|
||||||
|
disambiguate by kind** — collapsing them into one untyped key space will
|
||||||
|
silently conflate a system with its page or region on exactly the inputs
|
||||||
|
above, and the collision will not show up on any fixture that gives every
|
||||||
|
region more than one system.
|
||||||
|
|
||||||
|
**Byte-neutral by construction, and deliberately not revisited here.**
|
||||||
|
`encode_canonical` is unchanged; the module's canonical-serialization note
|
||||||
|
(`ResolvedLayoutIR::canonical_bytes`) now names `primitives`/`unowned`
|
||||||
|
alongside `vertical_band` in its stated exclusions, same reasoning: the
|
||||||
|
partition draws nothing a renderer paints, so two layouts differing only in
|
||||||
|
it render identically and hash alike. Two consequences follow, stated
|
||||||
|
outright so a later reader does not "fix" this by wiring the fields into the
|
||||||
|
encoder:
|
||||||
|
|
||||||
|
1. Encoding ownership would make the fingerprint **more fragile than the
|
||||||
|
rendering it fingerprints** — a casting-off refactor that re-partitions
|
||||||
|
systems without moving a single pixel (a different but equally valid
|
||||||
|
greedy-vs-optimal break search, say) would become a byte-level break and
|
||||||
|
force a schema major for a change no renderer and no conformance claim can
|
||||||
|
observe.
|
||||||
|
2. Two conformant implementations may legitimately partition a layout
|
||||||
|
differently (the spec pins no partitioning algorithm, only that resolved
|
||||||
|
primitives exist). Pinning ownership on the wire would manufacture a false
|
||||||
|
disagreement between two implementations that render pixel-identical
|
||||||
|
output. **Any future cross-implementation test of incremental relayout
|
||||||
|
therefore compares final bytes, never intermediate partitions** — this is
|
||||||
|
the test-design consequence of (1), not a restatement of it.
|
||||||
|
|
||||||
|
If a future normative requirement wants per-system ownership pinned on the
|
||||||
|
wire regardless (a determinism claim about the partitioning algorithm itself,
|
||||||
|
not just its output), that is a spec-side schema-major decision — not a call
|
||||||
|
this packet's byte-neutrality budget was scoped to make.
|
||||||
|
|
||||||
|
**Stub solver:** publishes every primitive unowned rather than fabricating an
|
||||||
|
attribution — it resolves no per-system geometry (every region becomes one
|
||||||
|
degenerate default-rect system), so any per-system claim would be a lie about
|
||||||
|
what that path computed. Locked by
|
||||||
|
`the_stub_solver_publishes_every_primitive_unowned` (`solver.rs`), which
|
||||||
|
value-asserts the real per-system-zero / all-unowned shape against a
|
||||||
|
non-trivial generated score (`epiphany_core::generators::valid_score_rich`:
|
||||||
|
11 glyphs, 38 strokes, 3 regions, plus a hand-added curve exercising all
|
||||||
|
three arrays) rather than a synthetic one-glyph fixture.
|
||||||
|
|
||||||
|
**Locked by** (`epiphany-engrave`'s `casting.rs` test module and this crate's
|
||||||
|
`solver.rs`/`resolved.rs`): the partition's totality/disjointness on a real
|
||||||
|
multi-system engrave (`ten_measure_single_staff`, `ten_measure_with_slurs`);
|
||||||
|
the stub-solver's all-unowned publication; the real per-system glyph/stroke
|
||||||
|
counts of the two-system fixture (`[26, 25]` glyphs, `[51, 45]` strokes —
|
||||||
|
value-asserted, not `> 0`); a system-spanning slur's synthesized continuation
|
||||||
|
owned by the system it splits *into*, not the source segment's; and
|
||||||
|
byte-identical `canonical_bytes()` before/after perturbing only the ownership
|
||||||
|
fields, while `PartialEq` still sees the difference. Six mutations verified
|
||||||
|
by hand (drop an index from a system's list; coerce the stub's unowned bucket
|
||||||
|
onto system 0; off-by-one the system index; publish a wrong `glyph_system`
|
||||||
|
while positioning stays correct; attribute a continuation to its source
|
||||||
|
segment's system instead of the system it splits into; encode the ownership
|
||||||
|
lists in `encode_canonical`) — each reverted by hand after observing the
|
||||||
|
real failure, never `git checkout`.
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,8 @@ pub use render::{
|
||||||
RenderTarget,
|
RenderTarget,
|
||||||
};
|
};
|
||||||
pub use resolved::{
|
pub use resolved::{
|
||||||
ResolvedGlyph, ResolvedLayoutIR, ResolvedMeasure, ResolvedPage, ResolvedStaff, ResolvedSystem,
|
PrimitiveIndices, ResolvedGlyph, ResolvedLayoutIR, ResolvedMeasure, ResolvedPage,
|
||||||
|
ResolvedStaff, ResolvedSystem,
|
||||||
};
|
};
|
||||||
pub use roundtrip::{laid_out_object_ids, round_trip, round_trip_with, RoundTripReport};
|
pub use roundtrip::{laid_out_object_ids, round_trip, round_trip_with, RoundTripReport};
|
||||||
pub use solver::{
|
pub use solver::{
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,37 @@ pub struct ResolvedSystem {
|
||||||
pub bounding_box: Rect,
|
pub bounding_box: Rect,
|
||||||
pub staves: Vec<ResolvedStaff>,
|
pub staves: Vec<ResolvedStaff>,
|
||||||
pub measures: Vec<ResolvedMeasure>,
|
pub measures: Vec<ResolvedMeasure>,
|
||||||
|
/// Which of the layout's flat `glyphs`/`strokes`/`curves` this system
|
||||||
|
/// owns — index lists, not copies (see [`PrimitiveIndices`]). A primitive
|
||||||
|
/// no system claims is not omitted; it is in [`ResolvedLayoutIR::unowned`]
|
||||||
|
/// instead, so the partition stays total (Chapter 7's flat arrays are
|
||||||
|
/// otherwise untouched by this field's existence — no primitive is split,
|
||||||
|
/// merged, reordered, or renumbered to populate it).
|
||||||
|
pub primitives: PrimitiveIndices,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index lists into a [`ResolvedLayoutIR`]'s flat `glyphs`/`strokes`/`curves`
|
||||||
|
/// arrays — never the primitives themselves. `u32`: matches the flat arrays'
|
||||||
|
/// own canonical count-prefix width and comfortably exceeds any real layout
|
||||||
|
/// (no resolved layout nears 4 billion primitives).
|
||||||
|
///
|
||||||
|
/// One of these lives on every [`ResolvedSystem`] (what it owns) and one on
|
||||||
|
/// [`ResolvedLayoutIR`] itself (`unowned`: claimed by no system — e.g. every
|
||||||
|
/// primitive under the stub solver, which resolves no per-system geometry
|
||||||
|
/// and so publishes everything unowned rather than fabricating an
|
||||||
|
/// attribution). For each of the three flat arrays, the union of every
|
||||||
|
/// system's list plus the unowned bucket is exactly `0..len`, each index
|
||||||
|
/// appearing exactly once — a tested invariant (`epiphany-engrave`'s casting
|
||||||
|
/// module), not merely a convention.
|
||||||
|
///
|
||||||
|
/// **Not part of the canonical encoding** (see the exclusion note on
|
||||||
|
/// [`ResolvedLayoutIR::canonical_bytes`]): this partition draws nothing, so
|
||||||
|
/// two layouts differing only in it render identically and hash alike.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||||||
|
pub struct PrimitiveIndices {
|
||||||
|
pub glyphs: Vec<u32>,
|
||||||
|
pub strokes: Vec<u32>,
|
||||||
|
pub curves: Vec<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
|
@ -97,9 +128,22 @@ pub struct ResolvedLayoutIR {
|
||||||
/// The catalog identity under which this layout was produced — required for
|
/// The catalog identity under which this layout was produced — required for
|
||||||
/// any byte-equal conformance claim (Chapter 7 §7.3.2).
|
/// any byte-equal conformance claim (Chapter 7 §7.3.2).
|
||||||
pub catalog: GlyphCatalogIdentity,
|
pub catalog: GlyphCatalogIdentity,
|
||||||
|
/// The primitives no system claims — the other half of the total,
|
||||||
|
/// disjoint partition [`ResolvedSystem::primitives`] describes. See
|
||||||
|
/// [`PrimitiveIndices`].
|
||||||
|
pub unowned: PrimitiveIndices,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResolvedLayoutIR {
|
impl ResolvedLayoutIR {
|
||||||
|
/// Every system across every page, in page order (a page's systems in
|
||||||
|
/// their own stored order, pages visited in `pages` order) — top to
|
||||||
|
/// bottom, reading order. `epiphany-editor-core` hand-rolls this same
|
||||||
|
/// flatten today (`editor-core/src/lib.rs`); it adopts this accessor at
|
||||||
|
/// T4, not before.
|
||||||
|
pub fn systems(&self) -> impl Iterator<Item = &ResolvedSystem> {
|
||||||
|
self.pages.iter().flat_map(|page| page.systems.iter())
|
||||||
|
}
|
||||||
|
|
||||||
/// The canonical serialized output (Appendix D §"Quantized Layout
|
/// The canonical serialized output (Appendix D §"Quantized Layout
|
||||||
/// Coordinates"): the layout's *rendering fingerprint*, with glyph positions
|
/// Coordinates"): the layout's *rendering fingerprint*, with glyph positions
|
||||||
/// quantized to the `1/1024` grid. Equivalent to
|
/// quantized to the `1/1024` grid. Equivalent to
|
||||||
|
|
@ -113,6 +157,13 @@ impl ResolvedLayoutIR {
|
||||||
/// carry `vertical_band` through but do not encode it. Band ownership tells a
|
/// carry `vertical_band` through but do not encode it. Band ownership tells a
|
||||||
/// vertical solver which staff owns a primitive; it draws nothing, so two
|
/// vertical solver which staff owns a primitive; it draws nothing, so two
|
||||||
/// layouts differing only in it are the same rendered layout and hash alike.
|
/// layouts differing only in it are the same rendered layout and hash alike.
|
||||||
|
/// **Per-system primitive ownership is excluded for the identical reason**:
|
||||||
|
/// [`ResolvedSystem::primitives`] and [`ResolvedLayoutIR::unowned`] name
|
||||||
|
/// which system (or no system) a primitive belongs to, and draw nothing —
|
||||||
|
/// two layouts differing only in that partition are the same rendered
|
||||||
|
/// layout and hash alike. Pinning it on the wire (if a future normative
|
||||||
|
/// requirement wants that) is a spec-side schema-major decision, not one
|
||||||
|
/// this type makes silently; see `DECISIONS.md`.
|
||||||
///
|
///
|
||||||
/// Two solves whose internal f32 computations agree to better than `1/2048`
|
/// Two solves whose internal f32 computations agree to better than `1/2048`
|
||||||
/// staff space at every coordinate produce identical bytes; two layouts that
|
/// staff space at every coordinate produce identical bytes; two layouts that
|
||||||
|
|
@ -410,6 +461,7 @@ mod tests {
|
||||||
curves: vec![],
|
curves: vec![],
|
||||||
engraving_decisions: decisions,
|
engraving_decisions: decisions,
|
||||||
catalog: GlyphCatalogIdentity::default(),
|
catalog: GlyphCatalogIdentity::default(),
|
||||||
|
unowned: PrimitiveIndices::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -541,4 +593,53 @@ mod tests {
|
||||||
let bad = ir(vec![glyph(1, f32::NAN)], vec![]);
|
let bad = ir(vec![glyph(1, f32::NAN)], vec![]);
|
||||||
let _ = bad.canonical_bytes();
|
let _ = bad.canonical_bytes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn primitive_ownership_is_excluded_from_canonical_bytes() {
|
||||||
|
// (m6) Perturbing ONLY the ownership lists — a system's `primitives`
|
||||||
|
// and the layout's `unowned` bucket — must not change
|
||||||
|
// `canonical_bytes()`, even though `PartialEq` sees the difference:
|
||||||
|
// the same exclusion `vertical_band` gets (Chapter 7's ownership
|
||||||
|
// partition draws nothing).
|
||||||
|
let system = ResolvedSystem {
|
||||||
|
provenance: Provenance::projected(TypedObjectId::Event(EventId::from_raw(9)), vec![]),
|
||||||
|
bounding_box: Rect::default(),
|
||||||
|
staves: vec![],
|
||||||
|
measures: vec![],
|
||||||
|
primitives: PrimitiveIndices::default(),
|
||||||
|
};
|
||||||
|
let page = ResolvedPage {
|
||||||
|
provenance: Provenance::projected(TypedObjectId::Event(EventId::from_raw(10)), vec![]),
|
||||||
|
number: 1,
|
||||||
|
size: Size2D::default(),
|
||||||
|
margins: Margins::default(),
|
||||||
|
systems: vec![system],
|
||||||
|
free_objects: vec![],
|
||||||
|
};
|
||||||
|
let mut base = ir(vec![glyph(1, 1.0)], vec![]);
|
||||||
|
base.pages = vec![page];
|
||||||
|
let base_bytes = base.canonical_bytes();
|
||||||
|
|
||||||
|
let mut perturbed = base.clone();
|
||||||
|
perturbed.pages[0].systems[0].primitives = PrimitiveIndices {
|
||||||
|
glyphs: vec![0],
|
||||||
|
strokes: vec![],
|
||||||
|
curves: vec![],
|
||||||
|
};
|
||||||
|
perturbed.unowned = PrimitiveIndices {
|
||||||
|
glyphs: vec![],
|
||||||
|
strokes: vec![7],
|
||||||
|
curves: vec![3, 4],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
base, perturbed,
|
||||||
|
"PartialEq must see the ownership difference"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
base_bytes,
|
||||||
|
perturbed.canonical_bytes(),
|
||||||
|
"canonical bytes must not see it"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ use epiphany_core::TypedObjectId;
|
||||||
|
|
||||||
use crate::constrained::{ConstrainedLayoutIR, GlyphObjectId};
|
use crate::constrained::{ConstrainedLayoutIR, GlyphObjectId};
|
||||||
use crate::glyph::{all_available, BravuraCatalog, GlyphCatalog};
|
use crate::glyph::{all_available, BravuraCatalog, GlyphCatalog};
|
||||||
use crate::resolved::{ResolvedGlyph, ResolvedLayoutIR, ResolvedPage, ResolvedSystem};
|
use crate::resolved::{
|
||||||
|
PrimitiveIndices, ResolvedGlyph, ResolvedLayoutIR, ResolvedPage, ResolvedSystem,
|
||||||
|
};
|
||||||
use crate::spatial::{Margins, Rect, Size2D};
|
use crate::spatial::{Margins, Rect, Size2D};
|
||||||
use crate::vertical_band::VerticalBandId;
|
use crate::vertical_band::VerticalBandId;
|
||||||
|
|
||||||
|
|
@ -487,6 +489,15 @@ impl StubSolver {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
let resolved_glyphs = glyphs.len();
|
let resolved_glyphs = glyphs.len();
|
||||||
|
// The stub resolves no per-system geometry (every region becomes one
|
||||||
|
// degenerate default-rect system, per below), so it has no honest
|
||||||
|
// per-system attribution to publish: every primitive is unowned
|
||||||
|
// rather than a fabricated claim (W1 pin 3).
|
||||||
|
let unowned = PrimitiveIndices {
|
||||||
|
glyphs: (0..glyphs.len() as u32).collect(),
|
||||||
|
strokes: (0..strokes.len() as u32).collect(),
|
||||||
|
curves: (0..curves.len() as u32).collect(),
|
||||||
|
};
|
||||||
let pages = input
|
let pages = input
|
||||||
.regions
|
.regions
|
||||||
.first()
|
.first()
|
||||||
|
|
@ -503,6 +514,7 @@ impl StubSolver {
|
||||||
bounding_box: Rect::default(),
|
bounding_box: Rect::default(),
|
||||||
staves: Vec::new(),
|
staves: Vec::new(),
|
||||||
measures: Vec::new(),
|
measures: Vec::new(),
|
||||||
|
primitives: PrimitiveIndices::default(),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
free_objects: Vec::new(),
|
free_objects: Vec::new(),
|
||||||
|
|
@ -523,6 +535,7 @@ impl StubSolver {
|
||||||
curves,
|
curves,
|
||||||
engraving_decisions: input.engraving_decisions.clone(),
|
engraving_decisions: input.engraving_decisions.clone(),
|
||||||
catalog: input.catalog.clone(),
|
catalog: input.catalog.clone(),
|
||||||
|
unowned,
|
||||||
},
|
},
|
||||||
unsatisfied_constraints: Vec::new(),
|
unsatisfied_constraints: Vec::new(),
|
||||||
warnings,
|
warnings,
|
||||||
|
|
@ -728,6 +741,76 @@ mod tests {
|
||||||
assert!(report.warnings.is_empty());
|
assert!(report.warnings.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_stub_solver_publishes_every_primitive_unowned() {
|
||||||
|
// (m2) The stub resolves no per-system geometry (every region becomes
|
||||||
|
// one degenerate default-rect `ResolvedSystem`, `resolve` above), so
|
||||||
|
// it must not coerce an unattributed primitive onto system 0 — real,
|
||||||
|
// non-trivial counts, via the real `to_logical`/`to_constrained`
|
||||||
|
// pipeline over a rich generated score (RS-2's own construction).
|
||||||
|
use crate::{to_constrained, to_logical};
|
||||||
|
use epiphany_core::generators::valid_score_rich;
|
||||||
|
|
||||||
|
let score = valid_score_rich(0xF302);
|
||||||
|
let mut input = to_constrained(&to_logical(&score));
|
||||||
|
assert_eq!(
|
||||||
|
input.regions.len(),
|
||||||
|
3,
|
||||||
|
"the rich fixture's real region count"
|
||||||
|
);
|
||||||
|
// `valid_score_rich` carries no slur, so hand-add a curve (mirrors
|
||||||
|
// `strokes_survive_the_solve_and_enter_the_canonical_bytes` below) to
|
||||||
|
// exercise all three flat arrays, not just glyphs and strokes.
|
||||||
|
input.curves.push(crate::Curve {
|
||||||
|
provenance: input.glyphs[0].provenance.clone(),
|
||||||
|
p0: crate::Point::new(0.0, 0.0),
|
||||||
|
p1: crate::Point::new(1.0, 1.0),
|
||||||
|
p2: crate::Point::new(2.0, 1.0),
|
||||||
|
p3: crate::Point::new(3.0, 0.0),
|
||||||
|
thickness: crate::StaffSpace(0.1),
|
||||||
|
layer: 0,
|
||||||
|
style: crate::GlyphStyle::default(),
|
||||||
|
line: epiphany_core::LineStyle::Solid,
|
||||||
|
vertical_band: input.glyphs[0].vertical_band,
|
||||||
|
});
|
||||||
|
|
||||||
|
let report = StubSolver.solve(&input, &SolverConfig::default());
|
||||||
|
let layout = &report.layout;
|
||||||
|
// Real counts (not `> 0`): the rich fixture's own glyph/stroke tally.
|
||||||
|
assert_eq!(layout.glyphs.len(), input.glyphs.len());
|
||||||
|
assert_eq!(layout.strokes.len(), input.strokes.len());
|
||||||
|
assert_eq!(
|
||||||
|
layout.glyphs.len(),
|
||||||
|
11,
|
||||||
|
"the rich fixture's real glyph count"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.strokes.len(),
|
||||||
|
38,
|
||||||
|
"the rich fixture's real stroke count"
|
||||||
|
);
|
||||||
|
assert_eq!(layout.curves.len(), 1);
|
||||||
|
|
||||||
|
// Every system's own bucket is empty — the stub attributes nothing.
|
||||||
|
let system_count = layout.systems().count();
|
||||||
|
assert_eq!(system_count, input.regions.len());
|
||||||
|
for system in layout.systems() {
|
||||||
|
assert!(system.primitives.glyphs.is_empty());
|
||||||
|
assert!(system.primitives.strokes.is_empty());
|
||||||
|
assert!(system.primitives.curves.is_empty());
|
||||||
|
}
|
||||||
|
// Everything unowned: the exact index range, in order.
|
||||||
|
assert_eq!(
|
||||||
|
layout.unowned.glyphs,
|
||||||
|
(0..layout.glyphs.len() as u32).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.unowned.strokes,
|
||||||
|
(0..layout.strokes.len() as u32).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert_eq!(layout.unowned.curves, vec![0]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn strokes_survive_the_solve_and_enter_the_canonical_bytes() {
|
fn strokes_survive_the_solve_and_enter_the_canonical_bytes() {
|
||||||
let mut input = constrained(vec![glyph("noteheadBlack")]);
|
let mut input = constrained(vec![glyph("noteheadBlack")]);
|
||||||
|
|
|
||||||
|
|
@ -1054,6 +1054,7 @@ mod tests {
|
||||||
curves: vec![],
|
curves: vec![],
|
||||||
engraving_decisions: vec![],
|
engraving_decisions: vec![],
|
||||||
catalog: Default::default(),
|
catalog: Default::default(),
|
||||||
|
unowned: Default::default(),
|
||||||
};
|
};
|
||||||
let out = render(&layout, &RenderOptions::default());
|
let out = render(&layout, &RenderOptions::default());
|
||||||
assert!(out.is_well_formed());
|
assert!(out.is_well_formed());
|
||||||
|
|
|
||||||
|
|
@ -467,6 +467,11 @@ pub fn gen_resolved_system(rng: &mut Rng) -> ResolvedSystem {
|
||||||
measures: (0..rng.range_usize(0, 2))
|
measures: (0..rng.range_usize(0, 2))
|
||||||
.map(|_| gen_resolved_measure(rng))
|
.map(|_| gen_resolved_measure(rng))
|
||||||
.collect(),
|
.collect(),
|
||||||
|
// Not tied to any real flat array here (this generator builds an
|
||||||
|
// isolated `ResolvedSystem`, not a whole `ResolvedLayoutIR`), so a
|
||||||
|
// random index list would be equally fake; left empty rather than
|
||||||
|
// fabricated.
|
||||||
|
primitives: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue