Item 6 (part 1): Agent E honesty/correctness + Agent D extension preservation

E-D (layout-ir, honest solver tier): add SolverTier::Stub (a non-conformance rung
below Minimal) and have StubSolver report it instead of falsely claiming the
Minimal conformance tier; the passthrough evaluates no constraints and computes
no quality metrics.

E-C (layout-ir, constraint/reference validation): ConstrainedLayoutIR::validate()
now also checks the LayoutConstraint vector — NoCollision/Align/PositionWithin
must name glyphs in the set, SystemBreakAt/PageBreakAt must name existing slots,
PositionWithin regions must be finite/non-negative — rejecting dangling
references instead of silently accepting them.

E-B (layout-ir, content-sensitive ScoreVersion): derive ScoreVersion from the
whole score's canonical bytes (Agent B's whole-score codec) rather than the
layout projection's object identities, so a pure content edit that changes no
identifier still changes the version — required for correct incremental-layout
cache invalidation.

D-A (bundle, extension-root preservation): Bundle::commit now enforces
preservation — after the builder closure runs, every prior extension declaration
it did not re-declare (by extension_id) is carried forward verbatim, so an
extension-unaware writer cannot silently orphan an unknown extension's
preserved_chunk_roots. An extension-aware writer that re-declares its id keeps
control.

Each fix has a regression test; per-crate DECISIONS updated. (Item-6 remainder:
D-B operation-block summaries next; E-A real time-axis deferred per request.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-21 19:15:45 -04:00
parent 306871ea29
commit c2e737d684
7 changed files with 268 additions and 50 deletions

View File

@ -178,14 +178,18 @@ yet enforced so a later integration knows where to extend.
to exercise yet. Non-canonical opaque chunks at unknown majors are carried
verbatim (they are never parsed).
- **Extensions: required → read-only; opaque preservation is partial.** An
- **Extensions: required → read-only; opaque preservation is now enforced.** An
unknown *required* extension forces read-only (v0 understands no extensions,
so all are unknown). Optional-extension `preserved_chunk_roots` are carried in
the manifest, but the bundle does not yet *enforce* that a commit's builder
closure preserves them, nor evaluate edit barriers / the unsafe-edit path —
barrier operands (`OperationKindTag`, `ObjectKind`, `EditBarrier`) are owned by
Agents C/E. The commit closure is, however, validated to never publish
dangling or mismatched *canonical* roots.
the manifest, and (M4 follow-up) `commit` now **enforces** preservation:
after the builder closure runs, every prior extension declaration the closure
did not itself re-declare (by `extension_id`) is carried forward verbatim, so
an extension-*unaware* writer cannot silently orphan an unknown extension's
roots; an extension-*aware* writer that re-declares its own id keeps control.
(Edit barriers / the unsafe-edit path are still not evaluated — barrier
operands `OperationKindTag`/`ObjectKind`/`EditBarrier` are owned by Agents
C/E.) The commit closure is also validated to never publish dangling or
mismatched *canonical* roots.
## Pass 11 candidates (ambiguities for the spec, not resolved in code)

View File

@ -542,6 +542,27 @@ impl<S: BlockStore> Bundle<S> {
new_chunks: &new_refs,
generation: next_generation,
});
// Extension-root preservation (Chapter 8 §"Behavior Under Unknown
// Extensions"). The bundle's job is preservation: an extension-unaware
// commit closure must not silently drop unknown extensions and their
// `preserved_chunk_roots` (which would orphan those chunks). Carry
// forward every prior extension declaration the closure did not itself
// re-declare; an extension-*aware* writer that re-declares its own
// `extension_id` keeps full control of that declaration. (The manifest
// encoder sorts/dedups `extension_declarations`, so append order does not
// affect the canonical form.)
let redeclared: std::collections::BTreeSet<crate::ids::ExtensionId> = manifest
.extension_declarations
.iter()
.map(|e| e.extension_id)
.collect();
for prior in &previous.extension_declarations {
if !redeclared.contains(&prior.extension_id) {
manifest.extension_declarations.push(prior.clone());
}
}
manifest.generation = next_generation;
manifest.manifest_id = manifest.derive_id();
@ -1286,6 +1307,75 @@ mod tests {
.contains(&IntegrityAnomaly::UnknownRequiredExtension));
}
#[test]
fn commit_preserves_unknown_extension_roots_when_the_closure_drops_them() {
// The bundle's job is preservation: an extension-*unaware* writer that
// rebuilds the manifest from scratch must not orphan an unknown
// (optional) extension's preserved roots.
let mut bundle = fresh_bundle();
let ext_id = crate::ids::ExtensionId([7; 16]);
let ext_root = ChunkRef {
id: ChunkId(ContentHash([42; 32])),
kind: ChunkKind::ExtensionData,
schema_version: SchemaVersion::V0,
offset: 4096,
compressed_length: 8,
uncompressed_length: 8,
compression: CompressionAlgorithm::None,
hash: ContentHash([42; 32]),
};
// 1) An extension-aware commit declares the optional extension + a root.
bundle
.commit(&[], |ctx| {
let mut m = ctx.previous_manifest.clone();
m.extension_declarations
.push(crate::manifest::ExtensionDeclaration {
extension_id: ext_id,
version: crate::ids::SemVer::new(1, 0, 0),
required: false,
preserved_chunk_roots: vec![ext_root],
affected_object_kinds: Vec::new(),
edit_barriers: Vec::new(),
});
m
})
.unwrap();
assert!(bundle
.manifest()
.extension_declarations
.iter()
.any(|e| e.extension_id == ext_id));
// 2) An extension-unaware commit rebuilds the manifest from empty,
// carrying only what it understands (operation roots). The bundle must
// still carry the extension and its root forward.
let doc = bundle.manifest().document_id;
bundle
.commit(&[], |ctx| {
let mut m = Manifest::empty(doc);
m.operation_roots = ctx.previous_manifest.operation_roots.clone();
m
})
.unwrap();
let survives = |m: &Manifest| {
m.extension_declarations.iter().any(|e| {
e.extension_id == ext_id
&& e.preserved_chunk_roots.iter().any(|r| r.id == ext_root.id)
})
};
assert!(
survives(bundle.manifest()),
"unknown extension + its root must survive an extension-unaware commit"
);
// 3) And it survives a reopen (durably preserved).
let image = bundle.into_store().into_bytes();
let reopened = Bundle::open(MemStore::from_bytes(image)).unwrap();
assert!(survives(reopened.manifest()));
}
#[test]
fn profile_selection_is_stable_across_reload() {
// Finding 9: the superblock's profile_id is the canonical-first profile,

View File

@ -167,10 +167,31 @@ object is covered); the provenance-preservation contract itself is unchanged.
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
exact non-optional interface is preserved: the stub reports the
non-conformance `SolverTier::Stub` rung (M5 follow-up — *not* `Minimal`, since a
passthrough that evaluates no constraints and computes no quality metrics must
not claim the lowest conformance tier; `Stub` orders below `Minimal`), an
all-worst `QualityMetricVector`, and rejects explicit constraints it cannot
evaluate rather than claiming them satisfied.
- **Constraint references are validated (M5 follow-up).**
`ConstrainedLayoutIR::validate()` now also checks the `LayoutConstraint`
vector: `NoCollision`/`Align`/`PositionWithin` must name glyphs in the set,
`SystemBreakAt`/`PageBreakAt` must name existing spring slots, and a
`PositionWithin` region must be finite/non-negative. Dangling constraint
references are rejected (`UnknownConstraintGlyph`/`UnknownConstraintSlot`/
`InvalidConstraintRegion`) rather than silently accepted. `Registered`
(extension) constraints stay opaque/conservative. Score-graph *source*
validation (that a `Provenance::source` names a real graph object) still
belongs at the `to_logical` boundary, which holds the `Score`.
- **`ScoreVersion` is content-sensitive (M5 follow-up).** It is now derived from
the whole score's canonical bytes (Agent B's whole-score codec) rather than the
layout projection's object identities, so a pure content edit (a respelling, a
duration change) that changes no identifier still changes the version —
required for correct incremental-layout cache invalidation (Chapter 7
§"Incremental Layout").
## 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

View File

@ -159,6 +159,12 @@ pub enum ConstrainedValidationError {
SlotMismatch(GlyphObjectId),
InvalidSlotGeometry(SpringSlotId),
InvalidGlyphBounds(GlyphObjectId),
/// A constraint references a glyph that is not in the glyph set.
UnknownConstraintGlyph(GlyphObjectId),
/// A break constraint references a spring slot that does not exist.
UnknownConstraintSlot(SpringSlotId),
/// A `PositionWithin` constraint carries a non-finite or inverted region.
InvalidConstraintRegion(GlyphObjectId),
}
/// A malformed logical-stage value that cannot be transformed without losing
@ -285,6 +291,49 @@ impl ConstrainedLayoutIR {
return Err(ConstrainedValidationError::BandMismatch(glyph.id()));
}
}
// Constraints must reference objects that exist: a dangling glyph or
// slot reference is a malformed problem, not a silently-accepted one.
let glyph_exists = |id: GlyphObjectId| -> bool { glyphs_by_id.contains_key(&id) };
for constraint in &self.constraints {
match constraint {
LayoutConstraint::NoCollision { a, b } | LayoutConstraint::Align { a, b, .. } => {
if !glyph_exists(*a) {
return Err(ConstrainedValidationError::UnknownConstraintGlyph(*a));
}
if !glyph_exists(*b) {
return Err(ConstrainedValidationError::UnknownConstraintGlyph(*b));
}
}
LayoutConstraint::PositionWithin { glyph, region } => {
if !glyph_exists(*glyph) {
return Err(ConstrainedValidationError::UnknownConstraintGlyph(*glyph));
}
let r = [
region.origin.x.0,
region.origin.y.0,
region.size.width.0,
region.size.height.0,
];
let region_ok = r.iter().all(|v| v.is_finite())
&& region.size.width.0 >= 0.0
&& region.size.height.0 >= 0.0;
if !region_ok {
return Err(ConstrainedValidationError::InvalidConstraintRegion(*glyph));
}
}
LayoutConstraint::SystemBreakAt { slot, .. }
| LayoutConstraint::PageBreakAt { slot, .. } => {
if !slot_ids.contains(slot) {
return Err(ConstrainedValidationError::UnknownConstraintSlot(*slot));
}
}
// A Registered (extension) constraint is opaque; treated
// conservatively (not rejected) per "Behavior Under Unknown
// Extensions".
LayoutConstraint::Registered(_, _) => {}
}
}
Ok(())
}
}

View File

@ -16,7 +16,7 @@
use std::collections::BTreeSet;
use epiphany_core::{AnnotationAnchor, RegionId, Score, StaffId, TimeAnchor, TypedObjectId};
use epiphany_determinism::{CanonicalEncode, DomainTag, Preimage};
use epiphany_determinism::{DomainTag, Preimage};
use crate::engraving::{EngravingDecision, EngravingDecisionKind, EngravingOverride};
use crate::provenance::{LayoutObjectId, Provenance};
@ -368,7 +368,7 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR {
}
}
let source = derive_score_version(&regions, &cross_region);
let source = derive_score_version(score);
LogicalLayoutIR {
source,
regions,
@ -378,42 +378,18 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR {
}
}
fn derive_score_version(
regions: &[LayoutRegion],
cross_region: &[CrossRegionObject],
) -> ScoreVersion {
/// Derives the [`ScoreVersion`] from the **whole score's canonical content**
/// (Agent B's whole-score codec), not merely the layout projection's object
/// identities. Any score edit — including one that changes an event's content
/// without changing any identifier (e.g. a respelling or a duration change) —
/// therefore yields a different version, which is what incremental-layout cache
/// invalidation depends on (Chapter 7 §"Incremental Layout"). The former
/// derivation keyed on layout-object `stable_id`s alone, so a pure content edit
/// left the version unchanged.
fn derive_score_version(score: &Score) -> ScoreVersion {
let mut preimage = Preimage::new(DomainTag::CONFLICT);
preimage.push_bytes(b"layout-score-version");
for region in regions {
preimage.push_bytes(&region.provenance.source.to_canonical_bytes());
match &region.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 &region.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);
}
preimage.push_bytes(&score.canonical_bytes());
ScoreVersion(*preimage.finish().as_bytes())
}
@ -557,9 +533,30 @@ pub(crate) fn cross_cutting_objects(score: &Score) -> Vec<(TypedObjectId, Vec<Ty
#[cfg(test)]
mod tests {
use super::*;
use epiphany_core::generators::valid_score_rich;
use epiphany_core::generators::{valid_score, valid_score_rich};
use epiphany_core::{AnchorOffset, RegionEdge, Spanner, SpannerId, TimeAnchor};
#[test]
fn score_version_tracks_content_not_just_identifiers() {
let score = valid_score(7);
// Deterministic: the same score yields the same version.
assert_eq!(to_logical(&score).source, to_logical(&score).source);
// Distinct scores yield distinct versions.
assert_ne!(
to_logical(&valid_score(7)).source,
to_logical(&valid_score(8)).source
);
// A pure content edit that changes NO identifier still changes the
// version (the old identity-only derivation missed this).
let mut edited = score.clone();
edited.metadata.title = Some("a different title".to_owned());
assert_ne!(
to_logical(&score).source,
to_logical(&edited).source,
"a content edit with unchanged ids must change the score version"
);
}
#[test]
fn spanning_object_uses_cross_region_collection() {
let mut score = valid_score_rich(5);

View File

@ -17,9 +17,10 @@
//! [`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.
//! reports the [`SolverTier::Stub`] tier (the honest non-conformance rung, below
//! `Minimal`) and an all-worst metric vector. Those values are deliberately
//! conservative placeholders, not computed quality measurements; the real solver
//! replaces them.
use epiphany_core::TypedObjectId;
@ -61,8 +62,17 @@ impl SolveStatus {
}
/// The conformance tier a solver claims (Chapter 9 §"Conformance Tiers").
///
/// `Stub` is below the spec's three conformance tiers: it is *not* a conformance
/// claim but its honest absence — an interface-only solver that evaluates no
/// constraints and computes no quality metrics reports `Stub`, never `Minimal`,
/// so a caller cannot mistake the passthrough for the lowest conformant tier.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum SolverTier {
/// Not a conformance tier: an interface-only / passthrough solver that
/// evaluates no constraints and computes no quality metrics (e.g.
/// [`StubSolver`]). Ordered below every conformant tier.
Stub,
/// Minimal Layout Solver.
Minimal,
/// Standard Engraving Solver.
@ -459,7 +469,8 @@ impl StubSolver {
impl ConstraintSolver for StubSolver {
fn tier(&self) -> SolverTier {
SolverTier::Minimal
// Honest: a passthrough that evaluates no constraints is below Minimal.
SolverTier::Stub
}
fn version(&self) -> SolverVersion {
@ -541,8 +552,11 @@ mod tests {
}
#[test]
fn stub_uses_the_minimal_interface_tier_and_worst_metrics() {
assert_eq!(StubSolver.tier(), SolverTier::Minimal);
fn stub_reports_the_non_conformant_stub_tier_and_worst_metrics() {
// Honest non-conformance: a passthrough reports Stub, never Minimal, and
// Stub orders below every real conformance tier.
assert_eq!(StubSolver.tier(), SolverTier::Stub);
assert!(SolverTier::Stub < SolverTier::Minimal);
assert_eq!(StubSolver.version(), SolverVersion(0));
let input = constrained(vec![glyph("noteheadBlack")]);
assert_eq!(
@ -553,6 +567,48 @@ mod tests {
);
}
#[test]
fn validate_rejects_dangling_constraint_references() {
use crate::constrained::{
BreakKind, ConstrainedValidationError, GlyphObjectId, LayoutConstraint,
};
let mut input = constrained(vec![glyph("noteheadBlack")]);
assert!(input.validate().is_ok());
let real = input.glyphs[0].id();
// A constraint naming a glyph that is not in the set is rejected, not
// silently accepted.
let ghost = GlyphObjectId(real.0 ^ 0xABCD);
input
.constraints
.push(LayoutConstraint::NoCollision { a: real, b: ghost });
assert_eq!(
input.validate(),
Err(ConstrainedValidationError::UnknownConstraintGlyph(ghost))
);
// A break constraint on a non-existent slot is rejected.
input.constraints = vec![LayoutConstraint::SystemBreakAt {
slot: SpringSlotId(999),
kind: BreakKind::Hard,
}];
assert_eq!(
input.validate(),
Err(ConstrainedValidationError::UnknownConstraintSlot(
SpringSlotId(999)
))
);
// A well-formed constraint reference validates — even though the stub
// solver still refuses to *evaluate* it (it cannot claim it satisfied).
input.constraints = vec![LayoutConstraint::NoCollision { a: real, b: real }];
assert!(input.validate().is_ok());
assert_eq!(
StubSolver.solve(&input, &SolverConfig::default()).status,
SolveStatus::InternalError
);
}
#[test]
fn unknown_glyph_yields_internal_error_not_panic() {
let mut unknown = glyph("noSuchGlyph");

View File

@ -816,6 +816,7 @@ pub fn gen_edit_context(rng: &mut Rng) -> EditContext {
/// A solver tier (every variant).
pub fn gen_solver_tier(rng: &mut Rng) -> SolverTier {
*rng.choose(&[
SolverTier::Stub,
SolverTier::Minimal,
SolverTier::Standard,
SolverTier::Advanced,