The decode vector corpus reaches the core score wire

The cross-implementation corpus (req:binfmt:decode-vectors) covered ops and
bundle but nothing from epiphany-core — the oldest and most load-bearing wire
in the repo had no vectors at all. 65 -> 93 vectors, 5 -> 19 surfaces.

This delivers a revision the companion had already ordered. binary_format.tex
asked for the corpus to extend to the representative layouts "which remain
round-trip locked rather than literal-byte locked" — a description, written in
advance, of the exact defect found in tranche 3b-i: swapping smufl and
overrides in BOTH halves of ScoreTuningContext's codec silently moved a
permanently frozen layout and passed all 1283 tests and 8/8 conformance. An
encoder and decoder that agree on the wrong order round-trip perfectly. Only
literal bytes can see it.

That mutation is now the mandatory regression vector
(core.score_tuning_context/overrides_before_smufl), and re-applying the swap
was verified to fail three ways: the corpus reports "declared reject, but was
ACCEPTED", the committed corpus goes stale, and the conformance suite fails.
Both the full workspace suite and conformance now catch what previously passed.

Almost no new machinery was needed: CanonicalValue was already public and
already did decode + finish() + re-encode + reject-on-mismatch, and already
covered four of the five representative layouts. RationalTime joins it (the
fifth, and the exemplar of the spec's own warning that an outer guard can mask
a lenient inner codec — it reduces to lowest terms on decode, so only
hand-written bytes reach that path), along with the five schema-major-3 types.
check() therefore calls real production API, not a harness-side wrapper: a
leaf reject vector exercises the code an external implementer must match.
Codec and Reader stay pub(crate); the only new public item is `pub mod
vectors`.

Two things deliberately shaped: core is appended LAST in the testkit chain, so
the regenerated corpus diff is purely additive — 56 insertions, 0 deletions,
which PROVES no existing vector's bytes moved and therefore that no existing
wire form changed. And the per-major Score vectors assert canonical_bytes()
equality only at major 3: majors 0-2 migrate by design, rewriting the bytes,
and their canonicality at their own major is already established by the frozen
per-major encoder's re-encode guard. Comparing there would fail on every
vector and invite "fixing" it by gutting the vector's meaning.

Also rebuilds binary_format.pdf, which commit 68b08ad should have carried and
did not — every other binary_format.tex commit includes it. The rebuild picks
up both that tranche's Schema Major 3 section and this one. latexmk -xelatex
twice: 0 errors, 0 undefined references, 60 pages.

Gate: fmt clean, clippy 0, 1321 passed / 0 failed, doc 0, conformance 8/8 with
gate [7d] reporting 93 vectors, requirement labels 6/6 at 212/282/282 (no new
req: labels).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-24 11:38:15 -04:00
parent 62117d3426
commit d96d79b8ba
8 changed files with 914 additions and 6 deletions

View File

@ -3430,8 +3430,13 @@ fn decode_v2_score(bytes: &[u8]) -> Result<Score> {
/// inherits core's conventions exactly, so core/ops stay consistent. See
/// `DECISIONS.md` (P11-4 / the K↔J seam).
///
/// Implemented only for the value types operation payloads embed, not for every
/// `Codec` type, to keep the public surface intentional.
/// Implemented for the value types operation payloads embed, and — since
/// `spec/CONTRACT_CORE_DECODE_VECTORS.md` — for the value types the
/// cross-implementation decode-vector corpus (`crate::vectors`) pins: the
/// representative layouts of the Binary Format companion's
/// §"Representative Complete Layouts" and the schema-major-3 tuning-context
/// leaves. Not for every `Codec` type, to keep the public surface intentional;
/// the intent has simply widened to a second reason.
pub trait CanonicalValue: Sized {
/// The canonical bytes of this single value, using the same layout the
/// whole-score codec uses for it.
@ -3508,6 +3513,19 @@ canonical_value! {
// Repeat authoring (schema-major-2 revision) — CreateRepeatStructure
// embeds the full value.
RepeatStructure,
// Decode-vector corpus (`spec/CONTRACT_CORE_DECODE_VECTORS.md`): the fifth
// representative layout (`RationalTime` — the other four, `Pitch`,
// `TimeAnchor`, `Event`, `Slur`, are already above), plus the
// schema-major-3 tuning-context container and its four leaf types, frozen
// by Push 4b tranche 3b-i. None of these gain a byte layout here — every
// one already has a `Codec` impl the whole-score codec uses; this macro
// only makes that existing layout reachable per-value.
RationalTime,
ScoreTuningContext,
TuningOverride,
TuningScope,
SmuflVersionRequirement,
SmuflVersion,
}
#[cfg(test)]

View File

@ -89,6 +89,7 @@ pub mod fuzz;
pub mod generators;
pub mod prepass;
pub mod textvalue;
pub mod vectors;
pub use ids::{
derive_system_id, AnalysisLayerId, AnalyticalAnnotationId, BarlineAlignmentGroupId, BeamId,

View File

@ -0,0 +1,571 @@
//! Decode conformance vectors for the `epiphany-core` score wire
//! (`spec/CONTRACT_CORE_DECODE_VECTORS.md`).
//!
//! See `epiphany_ops::vectors` for the corpus's purpose, its column shape, and
//! why `accept`/`reject` are never collapsed. This module extends the same
//! committed, cross-implementation corpus to the *oldest and most load-bearing*
//! wire format in the repository — the whole-`Score` codec — which had no
//! literal-byte vectors at all before this tranche (only round-trip locking,
//! which cannot see a self-consistent field reordering: see
//! `schema_major_3_tuning_context_wire_bytes_are_frozen` in `codec.rs`, and the
//! contract's account of the tranche-3b-i defect it golden-pins).
//!
//! Two families of surface:
//!
//! * **Leaves** — one per [`CanonicalValue`] type the corpus pins: the five
//! representative layouts of the Binary Format companion
//! (§"Representative Complete Layouts") — [`RationalTime`], [`TimeAnchor`],
//! [`Pitch`], [`Event`], [`Slur`] — plus the four schema-major-3
//! tuning-context leaves ([`SmuflVersion`], [`SmuflVersionRequirement`],
//! [`TuningScope`], [`TuningOverride`]) and their container
//! ([`ScoreTuningContext`]). Every leaf routes through
//! [`CanonicalValue::decode_canonical`] — the same public, production API a
//! value-typed operation payload uses — never a decoder reimplemented here.
//! * **Whole `Score`, one per schema major** — `core.score_v0` through
//! `core.score_v3`, routed through [`Score::decode_canonical_versioned`].
//! Majors 02 are **not** literal-byte-locked at the *current* layout: a
//! migration deliberately rewrites bytes (that is the point of
//! default-filling), so injectivity there means the input was already
//! canonical *at its own major* — `decode_vN_score` re-encodes through the
//! frozen `encode_vN_score` and rejects a mismatch, so a successful decode
//! already proves that. Only `core.score_v3` compares
//! `decoded.canonical_bytes() == bytes`. See the contract's "trap" section;
//! getting this backwards (comparing v0v2 against the *current* encoding)
//! would fail on every vector, and "fixing" it by relaxing the check would
//! destroy what the vector pins.
use epiphany_determinism::CanonicalF64;
use crate::accidental::{SmuflVersion, SmuflVersionRequirement};
use crate::codec::Codec;
use crate::event::{Event, PitchedEvent, StemConfiguration};
use crate::graph::{ScoreTuningContext, Slur, SlurKind, SpanStyle};
use crate::ids::{EventId, PitchId, ReplicaId, SlurId, VoiceId};
use crate::pitch::{
AcousticPitch, AcousticRealization, CmnNominal, IdentifiedPitch, Pitch, PitchSpaceId,
PitchSpacePosition, ScalePosition, TuningReference, TuningSystemId,
};
use crate::time::{
AnchorOffset, EventDuration, EventPosition, MusicalDuration, MusicalPosition, RationalTime,
TimeAnchor,
};
use crate::tuning::{TuningOverride, TuningScope};
use crate::{CanonicalValue, Score};
/// One vector: `(surface, verdict, class, name, bytes)`. See
/// `epiphany_ops::vectors::DecodeVector`.
pub type DecodeVector = (&'static str, &'static str, &'static str, String, Vec<u8>);
fn row(
surface: &'static str,
verdict: &'static str,
class: &'static str,
name: impl Into<String>,
bytes: Vec<u8>,
) -> DecodeVector {
(surface, verdict, class, name.into(), bytes)
}
// ===========================================================================
// Shared fixture values.
// ===========================================================================
/// A `cmn-12` pitch with an explicit cents-offset realization, so its
/// canonical bytes end in a length-prefixed [`CanonicalF64`] leaf (the last
/// 8 bytes are the raw IEEE-754 payload) — what the `core.pitch` reject vector
/// corrupts to a non-finite value.
fn pitch_with_cents(cents: f64) -> Pitch {
Pitch {
scale_position: ScalePosition {
space: PitchSpaceId::new("cmn-12"),
position: PitchSpacePosition::Cmn {
nominal: CmnNominal::C,
alteration: 0,
octave: 4,
},
},
acoustic: AcousticPitch {
tuning: TuningReference::Inherit,
realization: AcousticRealization::CentsOffset(
CanonicalF64::new(cents).expect("finite"),
),
},
}
}
fn simple_event() -> Event {
Event::Pitched(PitchedEvent {
id: EventId::new(ReplicaId(1), 1),
voice: VoiceId::new(ReplicaId(1), 1),
position: EventPosition::Musical(MusicalPosition(RationalTime::zero())),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
pitches: vec![IdentifiedPitch {
id: PitchId::new(ReplicaId(1), 1),
pitch: pitch_with_cents(0.0),
}],
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: StemConfiguration,
grace: None,
})
}
fn simple_slur() -> Slur {
Slur {
id: SlurId::new(ReplicaId(1), 1),
start_event: EventId::new(ReplicaId(1), 1),
end_event: EventId::new(ReplicaId(1), 2),
kind: SlurKind::default(),
curvature_override: None,
style: SpanStyle::default(),
}
}
/// The one `TuningOverride` embedded in [`loaded_tuning_context`]: a per-voice
/// override that sets `tuning_system` only, leaving `pitch_space` and
/// `reference` inherited — mirrors
/// `schema_major_3_tuning_context_wire_bytes_are_frozen`'s fixture exactly
/// (same field values), so these bytes are known-frozen wire content, not a
/// fresh layout.
fn one_override() -> TuningOverride {
TuningOverride {
scope: TuningScope::Voice(VoiceId::new(ReplicaId(1), 7)),
pitch_space: None,
tuning_system: Some(TuningSystemId::new("tet-19")),
reference: None,
}
}
/// A non-default `ScoreTuningContext`: `smufl` at 1.12/1.18 (not the 1.4/1.4
/// default) and one override, so both major-3 fields are real content, not
/// vacuously-default padding. Same fixture as
/// `schema_major_3_tuning_context_wire_bytes_are_frozen`.
fn loaded_tuning_context() -> ScoreTuningContext {
let mut ctx = ScoreTuningContext {
smufl: SmuflVersionRequirement {
minimum: SmuflVersion::from_decimal(1, "12").unwrap(),
authored_against: SmuflVersion::from_decimal(1, "18").unwrap(),
},
..ScoreTuningContext::default()
};
ctx.overrides.push(one_override());
ctx
}
/// Encodes `ctx` with `overrides` written *before* `smufl` — the exact
/// regression this tranche exists to catch (Push 4b tranche 3b-i swapped
/// these two fields in both halves of `impl Codec for ScoreTuningContext`,
/// and the whole workspace suite plus 8/8 conformance still passed). The
/// frozen field order is `default_pitch_space` ⌢ `default_tuning_system` ⌢
/// `reference` ⌢ `smufl` ⌢ `overrides`; this swaps the last two.
fn score_tuning_context_bytes_with_fields_swapped(ctx: &ScoreTuningContext) -> Vec<u8> {
let mut out = Vec::new();
ctx.default_pitch_space.enc(&mut out);
ctx.default_tuning_system.enc(&mut out);
ctx.reference.enc(&mut out);
ctx.overrides.enc(&mut out);
ctx.smufl.enc(&mut out);
out
}
/// Hand-encodes the *unreduced* rational `2/4`: there is no public
/// constructor that skips [`RationalTime`]'s reduce-on-construct invariant
/// (every constructor re-establishes it), so the only way to produce
/// non-canonical bytes for this leaf is to write them by hand, mirroring
/// [`RationalTime`]'s own `CanonicalEncode` (`time.rs`): a sign byte, then a
/// length-prefixed big-endian numerator magnitude, then a length-prefixed
/// big-endian denominator magnitude — wrapped in the outer `u32` leaf-length
/// prefix every embedded leaf carries. Decoding reduces `2/4` to `1/2`, so the
/// re-encoded bytes differ from these: the lenient-leaf-normalization case the
/// fifth representative layout exists to demonstrate (a guard *can* mask a
/// lenient inner codec; here the leaf's own strict check catches it directly).
fn unreduced_two_fourths() -> Vec<u8> {
let mut inner = Vec::new();
inner.push(1); // sign: Plus
inner.extend_from_slice(&1u32.to_le_bytes()); // numerator magnitude length
inner.push(2); // numerator magnitude: 2
inner.extend_from_slice(&1u32.to_le_bytes()); // denominator magnitude length
inner.push(4); // denominator magnitude: 4
let mut out = Vec::new();
out.extend_from_slice(&(inner.len() as u32).to_le_bytes()); // outer leaf length prefix
out.extend_from_slice(&inner);
out
}
/// Corrupts a tagged union's leading discriminant byte to a value one past
/// every assigned tag, so the decoder's `match` falls through to its
/// `InvalidTag` arm regardless of which union this is.
fn with_invalid_leading_tag(bytes: &[u8], tag: u8) -> Vec<u8> {
let mut out = bytes.to_vec();
out[0] = tag;
out
}
/// Overwrites the trailing 8 bytes of an accept vector's bytes — the raw
/// IEEE-754 payload of a trailing [`CanonicalF64`] leaf (its 4-byte length
/// prefix precedes them) — with a non-finite bit pattern.
fn with_trailing_float_replaced(bytes: &[u8], value: f64) -> Vec<u8> {
let mut out = bytes.to_vec();
let n = out.len();
out[n - 8..].copy_from_slice(&value.to_le_bytes());
out
}
fn with_trailing_byte(bytes: &[u8]) -> Vec<u8> {
let mut out = bytes.to_vec();
out.push(0);
out
}
fn truncated(bytes: &[u8]) -> Vec<u8> {
let mut out = bytes.to_vec();
out.pop();
out
}
// ===========================================================================
// The vectors.
// ===========================================================================
/// Every `epiphany-core` decode vector: the leaf layouts, then the per-major
/// whole-`Score` snapshots.
pub fn decode_vectors() -> Vec<DecodeVector> {
let mut v: Vec<DecodeVector> = Vec::new();
// --- RationalTime (the fifth representative layout) --------------------
const RT: &str = "core.rational_time";
let eighth = RationalTime::new(1, 8).unwrap();
v.push(row(
RT,
"accept",
"-",
"one_eighth",
eighth.canonical_bytes(),
));
v.push(row(
RT,
"reject",
"unreduced-rational-time",
"two_fourths_unreduced",
unreduced_two_fourths(),
));
// --- TimeAnchor ----------------------------------------------------------
const TA: &str = "core.time_anchor";
let anchor = TimeAnchor::Event {
id: EventId::new(ReplicaId(1), 1),
offset: AnchorOffset::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
};
let anchor_bytes = anchor.canonical_bytes();
v.push(row(TA, "accept", "-", "event_anchor", anchor_bytes.clone()));
v.push(row(
TA,
"reject",
"out-of-range-discriminant",
"tag_9_one_past_the_vocabulary",
with_invalid_leading_tag(&anchor_bytes, 9),
));
// --- Pitch ---------------------------------------------------------------
const PITCH: &str = "core.pitch";
let pitch_bytes = pitch_with_cents(1.5).canonical_bytes();
v.push(row(
PITCH,
"accept",
"-",
"cents_offset",
pitch_bytes.clone(),
));
v.push(row(
PITCH,
"reject",
"non-finite-float",
"cents_offset_nan",
with_trailing_float_replaced(&pitch_bytes, f64::NAN),
));
// --- Event -----------------------------------------------------------------
const EVENT: &str = "core.event";
let event_bytes = simple_event().canonical_bytes();
v.push(row(
EVENT,
"accept",
"-",
"pitched_event",
event_bytes.clone(),
));
v.push(row(
EVENT,
"reject",
"trailing-bytes",
"pitched_event_trailing",
with_trailing_byte(&event_bytes),
));
// --- Slur --------------------------------------------------------------
const SLUR: &str = "core.slur";
let slur_bytes = simple_slur().canonical_bytes();
v.push(row(SLUR, "accept", "-", "simple_slur", slur_bytes.clone()));
v.push(row(
SLUR,
"reject",
"truncated",
"simple_slur_truncated",
truncated(&slur_bytes),
));
// --- ScoreTuningContext (schema major 3) --------------------------------
const STC: &str = "core.score_tuning_context";
let ctx = loaded_tuning_context();
let ctx_bytes = ctx.canonical_bytes();
v.push(row(STC, "accept", "-", "loaded_context", ctx_bytes));
// THE direct regression vector for the 3b-i defect: bytes with `overrides`
// written before `smufl` must be rejected by the (correctly-ordered)
// decoder, even though a self-consistently-reordered codec would accept
// its own output. This is what a byte-literal corpus catches that
// round-trip locking cannot (see the module doc).
v.push(row(
STC,
"reject",
"swapped-major-3-field-order",
"overrides_before_smufl",
score_tuning_context_bytes_with_fields_swapped(&ctx),
));
// --- TuningOverride ------------------------------------------------------
const TO: &str = "core.tuning_override";
let override_bytes = one_override().canonical_bytes();
v.push(row(
TO,
"accept",
"-",
"voice_scoped",
override_bytes.clone(),
));
v.push(row(
TO,
"reject",
"trailing-bytes",
"voice_scoped_trailing",
with_trailing_byte(&override_bytes),
));
// --- TuningScope ---------------------------------------------------------
const TS: &str = "core.tuning_scope";
let scope_bytes = TuningScope::Voice(VoiceId::new(ReplicaId(1), 7)).canonical_bytes();
v.push(row(TS, "accept", "-", "voice", scope_bytes.clone()));
v.push(row(
TS,
"reject",
"out-of-range-discriminant",
"tag_9_one_past_the_vocabulary",
with_invalid_leading_tag(&scope_bytes, 9),
));
// --- SmuflVersionRequirement -----------------------------------------------
const SVR: &str = "core.smufl_version_requirement";
let svr_bytes = SmuflVersionRequirement {
minimum: SmuflVersion::from_decimal(1, "12").unwrap(),
authored_against: SmuflVersion::from_decimal(1, "18").unwrap(),
}
.canonical_bytes();
v.push(row(
SVR,
"accept",
"-",
"one_twelve_one_eighteen",
svr_bytes.clone(),
));
v.push(row(
SVR,
"reject",
"trailing-bytes",
"one_twelve_one_eighteen_trailing",
with_trailing_byte(&svr_bytes),
));
// --- SmuflVersion --------------------------------------------------------
const SV: &str = "core.smufl_version";
let sv_bytes = SmuflVersion::from_decimal(1, "4")
.unwrap()
.canonical_bytes();
v.push(row(SV, "accept", "-", "one_four", sv_bytes.clone()));
v.push(row(
SV,
"reject",
"truncated",
"one_four_truncated",
truncated(&sv_bytes),
));
// --- Whole Score, one per schema major -----------------------------------
//
// A single, real, well-formed `Score` (the positive generator's output —
// migration-safe: its schema-major-1/2/3 fields all sit at their canonical
// defaults, exactly what `valid_score`'s existing migration tests already
// rely on), encoded through each frozen per-major encoder. Majors 0-2 are
// genuinely *older* wire forms of the same value, synthesized via the
// pub(crate) `encode_vN_score` mirrors (never a fresh hand-rolled layout);
// major 3 is the live `canonical_bytes()`.
let score = crate::generators::valid_score(7);
let v0 = crate::codec::encode_v0_score(&score);
let v1 = crate::codec::encode_v1_score(&score);
let v2 = crate::codec::encode_v2_score(&score);
let v3 = score.canonical_bytes();
const SV0: &str = "core.score_v0";
v.push(row(SV0, "accept", "-", "valid_score_seed_7", v0.clone()));
v.push(row(
SV0,
"reject",
"trailing-bytes",
"valid_score_seed_7_trailing",
with_trailing_byte(&v0),
));
const SV1: &str = "core.score_v1";
v.push(row(SV1, "accept", "-", "valid_score_seed_7", v1.clone()));
v.push(row(
SV1,
"reject",
"trailing-bytes",
"valid_score_seed_7_trailing",
with_trailing_byte(&v1),
));
const SV2: &str = "core.score_v2";
v.push(row(SV2, "accept", "-", "valid_score_seed_7", v2.clone()));
v.push(row(
SV2,
"reject",
"trailing-bytes",
"valid_score_seed_7_trailing",
with_trailing_byte(&v2),
));
const SV3: &str = "core.score_v3";
v.push(row(SV3, "accept", "-", "valid_score_seed_7", v3.clone()));
v.push(row(
SV3,
"reject",
"trailing-bytes",
"valid_score_seed_7_trailing",
with_trailing_byte(&v3),
));
v
}
// ===========================================================================
// Verification.
// ===========================================================================
/// Runs `T`'s [`CanonicalValue::decode_canonical`] — the same public,
/// production API a value-typed operation payload decodes through — never a
/// decoder reimplemented in this module.
fn leaf_check<T: CanonicalValue>(bytes: &[u8]) -> Result<bool, String> {
match T::decode_canonical(bytes) {
Ok(v) => Ok(v.canonical_bytes() == bytes),
Err(e) => Err(format!("{e}")),
}
}
/// Runs [`Score::decode_canonical_versioned`] at `major`. Majors 0-2 report
/// injectivity as `true` unconditionally on a successful decode: migration
/// deliberately rewrites the bytes (default-filling new fields), so comparing
/// against the *current* `canonical_bytes()` would fail on every vector, and
/// `decode_vN_score`'s own re-encode-through-`encode_vN_score` guard already
/// proved the input canonical at *its own* major before returning `Ok` at all
/// (see the module doc's account of the contract's "trap"). Only major 3
/// compares `decoded.canonical_bytes() == bytes` — the live layout, where that
/// comparison is exactly what injectivity means.
fn score_check(bytes: &[u8], major: u16) -> Result<bool, String> {
match Score::decode_canonical_versioned(bytes, major) {
Ok(decoded) => {
if major == 3 {
Ok(decoded.canonical_bytes() == bytes)
} else {
Ok(true)
}
}
Err(e) => Err(format!("{e}")),
}
}
/// Applies `surface`'s decoder to `bytes`. See `epiphany_ops::vectors::check`
/// for the exact `Ok`/`Err` semantics. `None` when the surface is not owned by
/// this crate.
pub fn check(surface: &str, bytes: &[u8]) -> Option<Result<bool, String>> {
match surface {
"core.rational_time" => Some(leaf_check::<RationalTime>(bytes)),
"core.time_anchor" => Some(leaf_check::<TimeAnchor>(bytes)),
"core.pitch" => Some(leaf_check::<Pitch>(bytes)),
"core.event" => Some(leaf_check::<Event>(bytes)),
"core.slur" => Some(leaf_check::<Slur>(bytes)),
"core.score_tuning_context" => Some(leaf_check::<ScoreTuningContext>(bytes)),
"core.tuning_override" => Some(leaf_check::<TuningOverride>(bytes)),
"core.tuning_scope" => Some(leaf_check::<TuningScope>(bytes)),
"core.smufl_version_requirement" => Some(leaf_check::<SmuflVersionRequirement>(bytes)),
"core.smufl_version" => Some(leaf_check::<SmuflVersion>(bytes)),
"core.score_v0" => Some(score_check(bytes, 0)),
"core.score_v1" => Some(score_check(bytes, 1)),
"core.score_v2" => Some(score_check(bytes, 2)),
"core.score_v3" => Some(score_check(bytes, 3)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Each vector must get the verdict it declares — see
/// `epiphany_ops::vectors::tests::every_vector_gets_its_declared_verdict`.
#[test]
fn every_vector_gets_its_declared_verdict() {
for (surface, verdict, class, name, bytes) in decode_vectors() {
let result = check(surface, &bytes).expect("a surface this crate owns");
match (verdict, &result) {
("accept", Ok(true)) => {}
("accept", Ok(false)) => {
panic!("{surface}/{name}: accepted but does not re-encode to its bytes")
}
("reject", Err(_)) => {}
_ => panic!("{surface}/{name} ({class}): declared {verdict}, got {result:?}"),
}
}
}
/// Every surface carries both verdicts, or the corpus pins half a
/// contract.
#[test]
fn every_surface_carries_both_verdicts() {
use std::collections::BTreeMap;
let mut seen: BTreeMap<&str, (bool, bool)> = BTreeMap::new();
for (surface, verdict, ..) in decode_vectors() {
let e = seen.entry(surface).or_default();
match verdict {
"accept" => e.0 = true,
"reject" => e.1 = true,
other => panic!("unknown verdict {other}"),
}
}
assert_eq!(seen.len(), 14, "surfaces: {:?}", seen.keys());
for (surface, (accept, reject)) in seen {
assert!(accept, "{surface} has no accept vector");
assert!(reject, "{surface} has no reject vector");
}
}
/// The mandatory regression vector for the 3b-i defect is present: it is
/// what makes this tranche's existence justified (see the module doc).
#[test]
fn the_3b_i_regression_vector_is_present() {
assert!(decode_vectors()
.iter()
.any(|(s, v, c, ..)| *s == "core.score_tuning_context"
&& *v == "reject"
&& *c == "swapped-major-3-field-order"));
}
}

View File

@ -74,7 +74,13 @@ fn from_hex(s: &str) -> Option<Vec<u8>> {
.collect()
}
/// Every vector, in a stable order: the operation layer, then the bundle wire.
/// Every vector, in a stable order: the operation layer, then the bundle wire,
/// then (last) the core score wire. Core is appended last deliberately: it
/// keeps the regenerated corpus diff purely additive when this surface first
/// lands, which is itself the proof that no existing vector's bytes moved —
/// i.e. that adding the core surfaces changed no existing wire form. A
/// nicer-looking dependency order (core before ops) would move all the
/// pre-existing lines and destroy that property.
fn all() -> Vec<(String, String, String, String, Vec<u8>)> {
let ops = epiphany_ops::vectors::decode_vectors()
.into_iter()
@ -98,7 +104,18 @@ fn all() -> Vec<(String, String, String, String, Vec<u8>)> {
b,
)
});
ops.chain(bundle).collect()
let core = epiphany_core::vectors::decode_vectors()
.into_iter()
.map(|(s, v, c, n, b)| {
(
s.to_string(),
v.to_string(),
c.to_string(),
n.to_string(),
b,
)
});
ops.chain(bundle).chain(core).collect()
}
/// Renders the corpus file.
@ -163,7 +180,8 @@ pub fn verify(text: &str) -> Result<usize, Vec<String>> {
let mut failures = Vec::new();
for row in &rows {
let result = epiphany_ops::vectors::check(&row.surface, &row.bytes)
.or_else(|| epiphany_bundle::vectors::check(&row.surface, &row.bytes));
.or_else(|| epiphany_bundle::vectors::check(&row.surface, &row.bytes))
.or_else(|| epiphany_core::vectors::check(&row.surface, &row.bytes));
let Some(result) = result else {
failures.push(format!("{}: no decoder owns this surface", row.surface));
continue;

View File

@ -0,0 +1,208 @@
# CONTRACT — the core decode-vector surface
**Status:** dispatch-ready. Delivers the future revision the Binary Format
companion already prescribes (`binary_format.tex:3294-3296`) and closes the gap
found empirically in Push 4b tranche 3b-i.
**Ratified by the user 2026-07-23:**
1. **Coverage** — the spec's five representative layouts, **plus** the
schema-major-3 tuning-context types, **plus** one whole-`Score` vector at each
schema major 0/1/2/3.
2. **Strictness**`check()` calls **genuine public core API**, not a
harness-side wrapper, so a leaf reject vector exercises production code.
---
## Why this exists
`spec/vectors/decode_vectors.txt` is normative under `req:binfmt:decode-vectors`:
it is what a *second implementation* is checked against. Today it holds 65
vectors across 5 surfaces (`ops.operation_kind_tag`, `ops.materialized_state`,
`bundle.block`, `bundle.manifest`, `bundle.operation_index`) and **nothing from
`epiphany-core`** — the score wire, the oldest and most load-bearing format in
the repo, has no cross-implementation vectors at all.
The companion already asked for this (`binary_format.tex:3294`): "A future
revision should extend the corpus to the representative struct layouts of
Section~\ref{sec:values:representative}, which remain **round-trip locked rather
than literal-byte locked**."
That is not theoretical. In tranche 3b-i the reviewer swapped `smufl` and
`overrides` in **both** halves of `ScoreTuningContext`'s codec — a
self-consistent reordering of a layout `req:binfmt:frozen-layout` freezes
permanently — and **the entire workspace suite (1283 tests) and all 8/8
conformance rows passed.** Round-trip locking cannot see that. Only literal bytes
can.
## The mechanism already exists — do not invent a new one
- **`CanonicalValue`** (`codec.rs:3435`) is **public** and already implements
exactly the required discipline: decode, `finish()` (reject trailing bytes),
re-encode, and reject on mismatch. It already covers **four of the spec's
five** representative layouts — `Pitch`, `TimeAnchor`, `Event`, `Slur`.
- `Codec` and `Reader` are `pub(crate)` and **MUST stay that way.** A module
inside `epiphany-core` can use them directly; nothing new goes public except
the `CanonicalValue` impls below and the two `vectors` functions.
- `encode_v0_score` / `encode_v1_score` / `encode_v2_score` are `pub(crate)`
an in-core `vectors` module can call them to synthesize genuine old-major bytes.
## 1. Extend `CanonicalValue`
Add to the `canonical_value!` list (`codec.rs:3476`), each because the corpus
pins it:
- **`RationalTime`** — the fifth representative layout, and the only one
missing. It **normalizes on decode** (an unreduced fraction reduces to lowest
terms, `codec.rs:2570`), which makes it the exemplar of the spec's own warning
that "a guard on an outer value can *mask* a lenient inner codec".
- **`ScoreTuningContext`** — the schema-major-3 container whose silent
reorderability is the reason this tranche exists.
- **`TuningOverride`**, **`TuningScope`**, **`SmuflVersionRequirement`**,
**`SmuflVersion`** — the four leaf layouts frozen in 3b-i.
Update the macro's doc rationale (`codec.rs:3433`, "Implemented only for the
value types operation payloads embed... to keep the public surface intentional")
to record the second reason: **and the types the decode-vector corpus pins**.
The surface stays intentional; the intent widened.
If any of these lacks a `Codec` impl, stop and report rather than inventing a
byte layout — every one of them is already encoded inside `Score`, so the layout
exists and `CanonicalValue` must introduce **no new bytes** (that is the trait's
stated contract).
## 2. New `crates/epiphany-core/src/vectors.rs`
Mirror `epiphany-ops/src/vectors.rs` in shape and doc style:
```rust
pub type DecodeVector = (&'static str, &'static str, &'static str, String, Vec<u8>);
pub fn decode_vectors() -> Vec<DecodeVector>;
pub fn check(surface: &str, bytes: &[u8]) -> Option<Result<bool, String>>;
```
`check` semantics, exactly as the other two crates: `Err` = rejected,
`Ok(injective)` = accepted with whether it re-encodes to its own bytes. Route
every leaf surface through **`CanonicalValue::decode_canonical`** — the public
production API — never a locally re-implemented strictness check.
### Surfaces
Leaves (one per `CanonicalValue` type above): `core.rational_time`,
`core.time_anchor`, `core.pitch`, `core.event`, `core.slur`,
`core.score_tuning_context`, `core.tuning_override`, `core.tuning_scope`,
`core.smufl_version_requirement`, `core.smufl_version`.
Whole `Score`, one per schema major: `core.score_v0`, `core.score_v1`,
`core.score_v2`, `core.score_v3`.
**Every surface MUST carry both an `accept` and a `reject` vector** —
`every_surface_carries_both_verdicts` enforces it. If a surface cannot be given a
meaningful reject vector, fold it into its container rather than inventing a
contrived one, and say so in your report.
### The trap in the per-major Score vectors
For `core.score_v3`, injectivity is `decoded.canonical_bytes() == bytes`.
For `core.score_v0/v1/v2` it is **NOT**. Migration deliberately produces a value
whose *current* encoding differs from the input bytes — that is the whole point
of a default-filling migration. `decode_vN_score` is already strictly canonical
**over its own wire form** (it re-encodes through `encode_vN_score` and rejects a
mismatch), so a successful `Score::decode_canonical_versioned(bytes, N)` already
proves the input was canonical at major N. Return `Ok(true)` there. **Do not
compare against `canonical_bytes()` for majors 02** — it will fail, and
"fixing" it by relaxing the vector would destroy the vector's meaning.
### Reject classes — principled, not arbitrary
The ops module's rule applies (`vectors.rs:10`): each class is one this
repository actually shipped a bug in, or one an injectivity fuzzer cannot see.
Draw from:
- **unreduced `RationalTime`** — the lenient-leaf normalization case;
- **non-finite float** (NaN / ±inf bits) in a `CanonicalF64` leaf
(`NonFiniteFloat`);
- **unsorted or duplicated** `BTreeSet`/`BTreeMap` entries (order-preserving
sequence checks are invisible to a whole-value re-encode guard);
- **out-of-range discriminant** on a tagged union (`TimeAnchor`, `TuningScope`,
`PitchSpacePosition`);
- **trailing bytes** and **truncation**;
- **the swapped major-3 field order** — bytes encoding `ScoreTuningContext` with
`overrides` before `smufl`. This is the direct regression vector for the 3b-i
defect and MUST be present.
## 3. Wire it into the testkit
`crates/epiphany-testkit/src/vectors.rs`:
- `all()` (:78) — chain `epiphany_core::vectors::decode_vectors()`.
- the `check` dispatch (:165) — add `.or_else(|| epiphany_core::vectors::check(..))`.
**Append core LAST**, after ops and bundle. This is deliberate: it keeps the
regenerated corpus diff **purely additive**, which *proves* no existing vector's
bytes moved — i.e. that this tranche changed no existing wire form. A
nicer-looking dependency order would move all 65 existing lines and destroy that
property. Update `all()`'s doc comment ("the operation layer, then the bundle
wire") accordingly.
## 4. Regenerate
`cargo run -q -p epiphany-testkit --example generate_vectors`
`the_committed_corpus_matches_the_generator` fails on drift, so the regenerated
file must be committed. Confirm in your report that **the first 65 vectors are
byte-identical** to before.
## 5. Spec
Update the rationale at `binary_format.tex:3294-3296`: the "future revision"
it anticipates is now delivered for the representative layouts, and record that
the corpus additionally pins the schema-major-3 tuning-context layouts and the
per-major whole-`Score` forms. If §"The Decode Vector Corpus" enumerates
surfaces, extend that list.
**No new `\label{req:...}`** — `req:binfmt:decode-vectors` already governs this
and needs no change. Requirement counts MUST stay **212 / 282 / 282**.
## Do NOT
- Make `Codec` or `Reader` public.
- Introduce any new byte layout. `CanonicalValue` must emit exactly the bytes the
whole-score codec already emits (its stated contract). If a value's per-type
bytes differ from its embedded bytes, that is a bug — stop and report it.
- Change any existing vector, or any wire form.
- Touch the editor track (`spec/PLAN_EDITOR_APP.md`, `CONTRACT_EDITOR_*`,
`crates/epiphany-editor-gui/goldens/`) or `.claude/worktrees/`.
## The gate (report exact numbers)
- `cargo fmt --all --check`
- `cargo clippy --workspace --all-targets` → 0 warnings
- `cargo test --workspace` → 0 failed (HEAD is at 1318; report the count)
- `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` → 0
- `cargo run -q -p epiphany-testkit --example conformance_suite` → 8/8, and
report the vector count gate [7d] prints (65 today)
- `cargo test -p epiphany-testkit --test requirement_labels` → 6/6, counts
**212 / 282 / 282**
## Prove the hole is closed — the deliverable that matters
A corpus that does not catch the original defect is decoration. **Re-apply the
3b-i mutation** — swap `smufl` and `overrides` in *both* the `enc` and `dec`
halves of `impl Codec for ScoreTuningContext` (`codec.rs:1960`) — and confirm the
**corpus check now fails** (`every_vector_gets_its_declared_verdict` /
`the_committed_corpus_matches_the_generator`). Restore, and assert the anchor
text is back. Report the exact failure message.
That mutation previously passed 1283 tests and 8/8 conformance. If it still
passes after this tranche, the tranche did not do its job.
## What the reviewer will verify independently
- The first 65 corpus vectors are byte-identical; the diff is purely additive.
- The 3b-i swap mutation is caught by the corpus.
- The per-major `Score` vectors genuinely decode at their stamped major, and
`core.score_v0/v1/v2` are *not* asserting `canonical_bytes()` equality.
- Every new `accept` vector's bytes equal the bytes the whole-score codec embeds
for that value (no new layout).
- No new public API beyond the `CanonicalValue` impls and the two `vectors`
functions; `Codec`/`Reader` still `pub(crate)`.

Binary file not shown.

View File

@ -240,7 +240,7 @@
{\Large\scshape\color{epiphanyslate}Binary Format}\\[6pt]
{\large\itshape\color{epiphanyslate}A companion to the Core Specification}\\[14pt]
{\color{epiphanygold}\rule{3in}{0.8pt}}\\[24pt]
{\normalsize\color{epiphanyink}Version 0.10.0 --- Schema major 3: the tuning context's SMuFL requirement and per-scope overrides reach the wire}\\[4pt]
{\normalsize\color{epiphanyink}Version 0.11.0 --- The decode vector corpus extends to the core score wire}\\[4pt]
{\small\color{epiphanyslate}Normative for the byte layouts it defines}
\vfill
\end{titlepage}
@ -3294,6 +3294,29 @@ only}: implementations need not agree on an error taxonomy.
A future revision should extend the corpus to the representative struct
layouts of Section~\ref{sec:values:representative}, which remain round-trip
locked rather than literal-byte locked.
\textbf{Delivered (\texttt{spec/CONTRACT\_CORE\_DECODE\_VECTORS.md}).} The
corpus now additionally pins all five representative layouts of
Section~\ref{sec:values:representative} (\texttt{RationalTime},
\texttt{TimeAnchor}, \texttt{Pitch}, \texttt{Event}, \texttt{Slur}), the
schema-major-3 tuning-context leaves (\texttt{SmuflVersion},
\texttt{SmuflVersionRequirement}, \texttt{TuningScope},
\texttt{TuningOverride}) and their container (\texttt{ScoreTuningContext}),
and one whole-\texttt{Score} snapshot at each schema major
\texttt{0}--\texttt{3}. The mandatory regression vector is the one that
motivated this extension: bytes encoding \texttt{ScoreTuningContext} with
\texttt{overrides} before \texttt{smufl} --- the exact self-consistent field
swap that previously passed the entire reference-implementation suite and
8/8 conformance, because round-trip locking cannot see a decoder and
encoder agreeing on the wrong order. Each per-major whole-\texttt{Score}
vector genuinely decodes at its stamped major through
\texttt{Score::decode\ub canonical\ub versioned}; majors \texttt{0}--\texttt{2}
assert only that decoding succeeds (a migration deliberately rewrites bytes
to the current layout, so the injectivity check there is that the input was
already canonical \emph{at its own major}, which the frozen per-major
encoder's own re-encode guard already established), while major \texttt{3}
additionally asserts the decoded value re-encodes to exactly the input
bytes.
\end{rationale}
% ===========================================================================
@ -3439,6 +3462,19 @@ only}: implementations need not agree on an error taxonomy.
\tablenums{2}. Semantics: core specification Chapter~4, \sectionsc{Score
Tuning Context} / \sectionsc{SMuFL Versioning}; the staging ruling itself
is Push~4b's own (\texttt{spec/CONTRACT\_PUSH4B\_3BI\_WIRE.md}). \\
\today & Golden anchors & 0.11.0 --- Extends the decode vector corpus
(Section~\ref{sec:goldens:decode-vectors}) to \texttt{epiphany-core}'s score
wire, closing the gap \S0.9.0 left open and delivering the ``future
revision'' this section's own rationale anticipated: all five
representative layouts of Section~\ref{sec:values:representative}
(\texttt{RationalTime}, \texttt{TimeAnchor}, \texttt{Pitch}, \texttt{Event},
\texttt{Slur}), the schema-major-3 tuning-context leaves and their
container, and one whole-\texttt{Score} vector per schema major
\texttt{0}--\texttt{3}. Includes the mandatory regression vector for the
tranche-3b-i defect (a swapped \texttt{smufl}/\texttt{overrides} field
order, which previously passed round-trip locking and 8/8 conformance).
No wire layout changed; no existing vector's bytes moved
(\texttt{spec/CONTRACT\_CORE\_DECODE\_VECTORS.md}). \\
\bottomrule
\end{longtable}

File diff suppressed because one or more lines are too long