Ratified 2026-07-24. S13 is re-pointed rather than resolved: the tuning context
is not a special case, it is field 10 of an eight-field gap that
spec/ANALYSIS_GENESIS_PERSISTENCE.md tables across the whole Score —
canvas.layout_defaults, instruments, staff_groups, parts, tuning_context,
spelling_precedence, analysis_layers, views, plus identity, with
decomposition_attachments prunable-but-never-authored. Each is reachable only
through a base the reducer is handed, and pruning is licensed to replace that
base with a MaterializedState carrying none of them.
Independently re-verified against the working tree before recording: every one
of those fields' sole reduce.rs mention is a read-only base-seed read, and
epiphany-ops has no tuning-context payload at all.
So the entry drops the operation-vs-canonical-base framing it was filed with.
That framing was right about the axis and wrong about the scope: it maps onto
the analysis's dispositions B and C at eight times the size, and a
tuning-specific fix is exactly the shape the analysis argues against — "any
option that enumerates fields must be re-audited against this table every time
a field is added to Score". S13 is now marked blocked-on rather than open, and
whichever disposition the genesis ruling picks resolves it as a side effect.
The accept-set cost this entry surfaced is kept, repositioned as evidence
against enumerated per-field fixes rather than as a tuning-only tradeoff:
blocks stamp minimally, and bundle.rs caps OperationEnvelopeBlock at major 2
precisely because no operation payload embeds the tuning context, so a
SetTuningContext-style operation would drag a role accept-set raise along with
the kind append — for one field of eight.
The wire layouts stay frozen and correct under every disposition. This was
never about how the tuning context encodes, only about which carrier embeds it,
so epiphany-core's DECISIONS entry now records the matching prohibition: no
SetTuningContext operation, and no widening of ScoreTuningContext's layout to
compensate.
Docs only; no code path touched. Gate unchanged: fmt clean, 1336 passed /
0 failed, requirement labels 6/6 at 212/282/282.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QjsEnYhm1gPpf6ii2iFxFV
Schema major 3 put `smufl` and `overrides` on the binary wire and left the text
surface alone. This closes that: `TextValue for ScoreTuningContext` projects
five fields in `fn enc` order, with four new leaf impls (SmuflVersion,
SmuflVersionRequirement, TuningScope, TuningOverride) mirroring the four Codec
impls 3b-i froze.
The rule that moved them is the one this projection always followed — the text
projection is the same canonical surface the binary codec is. What changed was
not the rule but a premise: the old doc comment excluded all three fields
because "no schema major 3 has been opened", and 3b-i opened it.
`accidental_extensions` was staged out of that major and stays in-memory only,
so it is still correctly absent — the staging line now falls in the same place
on both surfaces, which is what the inverted tests assert.
No header-version bump and no document vector moved. This is a value-projection
change, not a document-format one: epiphany-textproj has no reference to a
tuning context anywhere, and the canonical base projects as a single opaque
byte atom (req:textproj:base-snapshot-inline), not structurally. So
req:textproj:header-version's sole accepted version (0 7 0) is untouched, all
13 document vectors are unchanged, and req:textproj:roundtrip was never
violated — the tuning context is not part of the projected document.
Both tests were renamed and inverted, following 3b-i's treatment of the binary
pair rather than rewriting the tranche-2/3a decision entries. Mutation-verified:
making `overrides` project as an always-empty vector — the exact regression this
fixes — fails both.
Two stale-pointer classes fixed, both of the kind this pass was hunting: the
comment in codec.rs naming the old text-test names and claiming the text
surface was unchanged; and ScoreTuningContext's per-field docs in graph.rs,
which still read "In memory only this tranche" for `smufl` and `overrides` —
stale since 3b-i, and contradicting the struct-level wire note they point at.
The struct-level doc was already correct.
Filed P13-S13, found while scoping and deliberately not fixed here: the tuning
context has NO canonical persistence path. Its only *persisted* carrier is the
acceleration snapshot, which Chapter 8 makes non-canonical and regenerable; no
canonical carrier embeds it at all. No operation authors it (the ops
vocabulary's only `tuning` references are the per-pitch TuningReference::Inherit)
and MaterializedState does not carry it. So a per-voice tuning override survives
a snapshot round-trip but cannot be authored, replicated, or merged, which makes
req:tuning:tuning-resolution-order's scopes 2-4 unreachable from any exchangeable
document. Not a regression — nothing ever worked; major 3 made the gap visible
by giving the value a wire form and no way to reach it.
The candidate prices both routes. Ratification is about the carrier, not the
encoding — the layouts are frozen and correct either way — but the operation
route is not quite the free schema-minor it first looks: blocks stamp minimally,
and bundle.rs caps OperationEnvelopeBlock at major 2 precisely because no
operation payload embeds the tuning context. An authoring operation carrying a
TuningOverride would stamp v3, dragging a role accept-set raise along with the
kind append.
Gate: fmt clean, clippy 0, 1336 passed / 0 failed, doc 0, conformance 8/8,
requirement labels 6/6 at 212/282/282.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QjsEnYhm1gPpf6ii2iFxFV
The input-method-agnostic entry seam: session-local caret, naturals-only
letters with nearest-octave inference (downward tie-break), the pencil's
make-room semantics, the x_at_position forward map, and the testable core
of MIDI entry — the device listener stays T4.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
The last fail-closed entry in the built-in tuning catalog now resolves, leaving
only the compatibility-mapping registry open in Push 4b.
The recorded blocker was wrong. Five places in tuning.rs claimed adaptive
"needs HarmonicContext, which does not exist in Rust". But
req:tuning:adaptive-default-version makes version 1 a pure function of
(position, anchor pitch class) — it ignores concurrent, recent, hints,
parameters, and mode. And two of HarmonicContext's four specified fields are
UNIMPLEMENTABLE: key_context and hints are typed on KeyContext / ContextHint,
which core_spec.tex:4111 leaves undefined deliberately, "so defining them now
would freeze a type surface on a chapter with no consumer". The minimal
one-field shape was not a preference; it was the only implementable one. Each
remaining field arrives with the first function that consumes it.
The real blocker was structural and small: locate_voice returned
(RegionId, StaffId) while key_sequence lives on StaffInstance, so the resolver
threw away the only object that could answer "what key is prevailing here".
No new pitch math. ji_static_5limit_ratios already took a runtime anchor — the
three ji-static-5limit-{C,G,D} built-ins are that one function at anchors
0, 7, 2 — so adaptive v1 is that same call with a derived anchor. Verified as
an identity: adaptive at anchor 0 is bit-identical to ji-static-5limit-C and at
anchor 7 to ji-static-5limit-G, across all seven naturals.
A missing tonal centre is NOT an error. core_spec.tex:3452 mandates C
(chromatic position 0) when none is supplied, so that is a defined default, not
a fail-closed case; a test pins it against a future "fail closed" refactor.
Fail-closed applies to exactly two things: an unregistered
AdaptiveTuningFunctionId (hard error, no fallback) and a TimeAnchor that cannot
be unambiguously ordered against the onset (AnchorNotOrderable, naming the kind
that defeated it). If any KeySignatureChange in a sequence is unorderable the
whole derivation fails, rather than risk skipping the true prevailing signature.
Anchor arithmetic is (7 * fifths).rem_euclid(12), never %: fifths reaches -7 and
% yields a negative pitch class. ChromaticPitchClass is a checked 0..=11 newtype,
so the sign bug cannot degrade into a silently wrong anchor — under mutation it
panics outright. Verified by hand across the whole -7..=7 range, including the
enharmonic collisions (6 and -6 both F#/Gb = 6).
Nothing reaches the wire: TuningResolution is catalog-computed and appears in
neither codec.rs nor textvalue_graph.rs, so TuningResolution::Adaptive and
HarmonicContext are in-memory only and schema major 3 is untouched. Zero Codec
impls added; no vector or golden moved.
Gate: fmt clean, clippy 0, 1311 passed / 0 failed, doc 0, conformance 8/8,
requirement labels 6/6 at 212/282/282. Independently verified: the C default,
static systems ignoring context, statelessness across reordered resolution, and
the 0..=11 invariant. Two mutations killed — rem_euclid -> % and a silent
fallback for an unregistered id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes P13-S12. epiphany-layout-ir defined its own SmuflVersion storing the
minor LITERALLY ({1,4} for SMuFL 1.4), so derived Ord sorted 1.3 and 1.4
BEFORE 1.12 — backwards versus SMuFL's real release order — on a direct field
of GlyphCatalogIdentity, which is layout-conformance identity. That bug was
live. The crate graph fixes the direction: layout-ir depends on core, so
core's fraction-normalized minor_centi type is necessarily the survivor.
layout-ir deletes its own and re-exports core's, keeping
epiphany_layout_ir::SmuflVersion resolving for downstream at zero churn.
No wire change: no schema major, no Codec, no decode vector. LayoutCache is a
regenerable major-0 role. The one line that moves bytes is encode_catalog's
.minor -> .minor_centi (04 00 -> 28 00 at offset 2, verified by encoding a
default identity: 60 bytes, unchanged elsewhere).
The tranche also closes S12's OTHER half, which had gone unnoticed: the
ratified shape reached Rust and the binary companion but core_spec.tex never
defined SmuflVersion at all — the exact "undefined leaf" S12 was filed about.
It is now declared once, with the hundredths rule as normative prose and the
release table as a rationale note, cross-referenced from both Chapter 9 sites
so the glyph catalog's version and the tuning context's are visibly one type.
No new req: label; counts stay 212/282/282.
Two ledger claims corrected, both verified false before dispatch rather than
after: PASS13_CANDIDATES.md's S12 entry and epiphany-core/DECISIONS.md's S12
bullet both promised this move would land "with golden regen". Nothing is
pinned to the catalog identity — every ResolvedLayoutIR::canonical_bytes()
assertion is RELATIVE (stability, determinism, and a sensitivity check that
mutates metrics_hash, never smufl_version), and the committed SVG/PNG goldens
embed no identity, only a fixed comment string. There was nothing to
regenerate, and conformance gate [9] never moved.
The fix is locked by a test that names the bug: layout-ir asserts the real
release order through GlyphCatalogIdentity.smufl_version. Mutation-verified
independently by making from_decimal store one-digit minors literally — it
fails with "minor_centi: 20 did not sort before minor_centi: 3", which is
precisely the deleted type's behaviour.
Gate: fmt clean, clippy 0, 1283 passed / 0 failed (+1, the new test), doc 0,
conformance 8/8, requirement labels 6/6 at 212/282/282. No golden, vector,
baseline, or editor-track file changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ScoreTuningContext gains `smufl` and `overrides` on the canonical wire.
`accidental_extensions` is deliberately STAGED to a later major: under
req:binfmt:frozen-layout a field addition is a major, so freezing a large
subtree whose consumer (the engraver) does not exist yet buys nothing and
risks a major-4 to fix a mis-shaped field. The three shapes 3a ratified
(Cents/CanonicalF64, AnchorPoint, SmuflVersion) stay reversible until
something exercises them.
Scope discovery: no operation payload embeds the tuning context, anywhere.
So the wire reached is the acceleration full-Score snapshot ONLY — the
canonical operation layer is untouched, no payload is born at v3, no frozen
v2 op-payload decoder is needed, and OperationEnvelopeBlock's accept-set
stays at 2 while Snapshot rises to 3. Major 3 is the first data-model bump
under which a chunk role's max does not move in lockstep. The canonical
base embeds no tuning context and stays major 0, byte-identical.
Migration: the live codec becomes v3; the 3-field form is frozen as
enc/dec_tuning_context_v2 and consumed by decode_v2_score (new, with its
byte-exact inverse encode_v2_score) and by the v0/v1 decoders — AND by
encode_v0_score/encode_v1_score, which the contract missed and which would
have silently corrupted the frozen forms through the strict-canonicality
re-encode check.
The frozen bytes are now pinned by a golden, because nothing else pinned
them. The cross-implementation decode corpus covers only the operation and
bundle surfaces — there is no epiphany-core vectors module — so every
existing test round-trips enc against dec and stays green under a
SELF-CONSISTENT reordering: swapping smufl and overrides in both halves
passed the entire workspace suite and 8/8 conformance, silently moving a
permanently frozen layout. schema_major_3_tuning_context_wire_bytes_are_frozen
asserts the exact encoding of the default (48 B) and a loaded (82 B)
context; it kills that swap. The S12 normalization is visible in the
literal: 1.4 stores minor_centi 40 (0x28), 1.12 stores 12.
Gate: fmt clean, clippy 0, 1282 passed / 0 failed, doc 0, conformance 8/8,
requirement labels 6/6 at 212/282/282 (unchanged — no new req: labels).
Mutations verified independently: breaking the v0 reroute kills 6 tests;
the wire-order swap kills the new golden.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four work packets: the selection set with an anchor (W1), GUI rubber-band
select (W2), promotion of the T1a goldens to conformance gate [9/9] behind
a golden-gate feature that keeps resvg out of the MSRV closure (W3), and
copy/paste over the newly granted Ruling E fragment projection (W4) —
values-only, paste-as-minting, fail-closed closure, untrusted-input caps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PLAN_EDITOR_APP.md charters the editor product track: rulings A/C granted,
B blocked behind graph-state-persistence + versioned-decode, D conditional
on the document-bound session API; hardened by three source-level reviews
(14 + 11 + 9 findings, all dispositioned in its ledgers).
CONTRACT_EDITOR_T1A_GOLDENS.md dispatches the first tranche: pixel goldens
over the score raster, subagent work packets, coordinator review, user
deep-dive points.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tranche 3 completes ScoreTuningContext onto the wire -- schema major 3, the only
irreversible tranche. Split reversible-first: 3a builds the accidental / glyph /
engraving type surface in memory with a real consumer (so the shapes are
exercised while free to change), 3b freezes them. 3a adds no Codec, no wire
movement; the hand codec keeps encoding exactly three wire fields while
accidental_extensions and smufl join overrides as in-memory-only.
Scoping the subtree surfaced three types that freeze forever and the spec does
not pin -- filed as S10-S12, ratified before dispatch (the S5/S7-before-tranche-1
pattern):
- S10: PitchSpaceModification::Cents(f64) is a raw f64 in canonical state, which
the byte layer cannot encode -- serialize.rs decodes floats only through
CanonicalF64::from_le_bytes. Ratified Cents(CanonicalF64), the same maneuver
Ruling D applied to the bounding box.
- S11: AnchorPoint is referenced (:3166) and defined nowhere; core cannot depend
on layout-ir. Ratified core-native { x, y: SpaceUnit } plus a normative frame
sentence, since the box it anchors is "relative to the glyph's anchor point".
- S12: the catch. SmuflVersion is NOT undefined -- it exists in layout-ir with
LITERAL minor ({1,4}) and derived Ord, so 1.3 sorts before 1.12 today, and it
is a field of GlyphCatalogIdentity (conformance identity). Ratified
fraction-normalized minor_centi (1.4 -> 40); core defines it in 3a, and 3b
unifies layout-ir onto it and moves GlyphCatalogIdentity deliberately.
Every citation and struct line-ref verified against core_spec.tex before commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes the ten temperaments deferred by tranche 2 resolve, via
TuningResolution::Function with reserved built-in ids, each computed from its
construction (which fifths tempered, by what fraction of which comma) rather
than a pasted cents table -- the constructions are normative in core_spec.tex
as of 5e465a1.
Its own pass, separate from the resolver plumbing, because this is the
verify-heavy part: the S6 draft shipped two arithmetically impossible
temperaments and one false ambiguity, every one properly cited, caught only by
the closure invariant. So the contract's deliverable is the closure recomputed
in code -- the six circulating temperaments' temperings summing to one
Pythagorean comma, the four non-circulating ones' wolves matching the ratified
residue -- and two construction-level mutations required to prove it bites:
dropping Kirnberger's schisma fifth (the classic omission) and swapping
Werckmeister's Pythagorean comma for syntonic (the classic confusion). The comma
type is the load-bearing distinction no test outside the closure check can see.
Verified before dispatch: all three cited labels resolve, every line ref points
where claimed, and the closure arithmetic checked independently -- syntonic +
schisma = Pythagorean exactly, which is why Kirnberger closes and why omitting
the schisma lands one schisma short.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reversible half of the remaining tuning work: make tuning resolvable --
(position, tuning system, reference) -> frequency, walking the five resolution
scopes -- entirely in memory, no Codec, canonical bytes byte-identical. The
schema-major-3 wire bump is a separate later tranche, so the resolution logic is
exercised and proven before anything freezes, the same slice discipline tranche
1 used.
Scope is nine of twenty systems: the six tet-* (EqualTemperament) and the three
ji-static-5limit-* (PerPositionRatios computed from the lattice block, not a
pasted table). The ten historical temperaments are tranche 2b -- their own
verify-heavy pass, because that is where S6's two impossible-but-cited
temperaments hid and the code re-derivation deserves the same closure-invariant
scrutiny. ji-adaptive-5limit and the compatibility-mapping registry are deferred
fail-closed, matching the established honest-gap pattern (user ratified both).
Names the struct_codec! trap concretely: adding an in-memory `overrides` field
to ScoreTuningContext breaks the macro (its dec constructs a literal of exactly
the named fields), so the contract requires a hand-written Codec that encodes
the three wire fields and defaults overrides -- with a round-trip test proving
overrides never reaches the wire.
Citations verified line-by-line before dispatch; two were wrong on first draft
(tuning-resolution-order and -compatibility cited by the numbers of unrelated
requirements, one left hedged) and are fixed -- P13-S9's own discipline applied
to the contract that cites P13-S9.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Types, the catalog data that fills them, and the consumer that reads them --
dispatched as one vertical slice rather than the plan's three steps. A Chapter 4
type surface with no consumer is the shape that produced Staff::default_clef and
NOTEHEAD_ANCHORS, so the acceptance test is behavioural: a cmn-24 pitch
transposes end-to-end.
Six of the thirteen built-in pitch spaces are underdetermined by the
specification -- the three ji-* generators and all three of maqam-base,
gamelan-slendro and gamelan-pelog. The contract requires they be represented as
unresolved and reported, never inferred.
Writing it also caught an incomplete paraphrase of
req:tuning:diatonic-chromatic-mapping in the contract's own text: it has three
clauses, not two, and both built-in mappings satisfy all three -- so a
constructor enforcing only the first two would pass every test built from the
catalog. That is P13-S9's discipline applied to the contract that cites it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
S5 and S6 close, which closes the spec half of Push 4b: the JI basis is
full-register (a JiVector is an absolute position -- without the prime-2
exponent, ji-5limit cannot tell C4 from C5), and all twenty tuning systems now
carry constructions rather than names.
S7 is filed retroactively. Its id was minted in PLAN_PUSH4B_TUNING.md marked
"new; file it", ratified as that plan's Ruling C, and implemented in Wave 1a --
but it never entered this ledger, so an id lived outside the index whose whole
job is to be the index. The candidate itself: the specification required every
score to *define* a pitch space, and the data model gives a score no way to do
it. Both "define"s are now "select".
S8 is new and was measured, not argued. TempoShape::Constant legalizes
end_tempo as None *or* equal to start_tempo -- two spellings of one musical
fact -- and TempoSegment encodes the field positionally into canonical state.
Encoding both forms of an otherwise identical score gives 336 bytes and 363.
Nothing normalizes; invariants.rs merely permits both. Everywhere else this
project looked at redundant encodings it ruled the other way: NFC for text, a
total order for collections. Tempo got no equivalent, so two musically
identical scores hash differently. Found sideways -- the branch is untested,
which is why the map_or rewrite could not be mutation-verified there.
S9 is the pattern behind three failures in one day: the citation checker
proves cited-to-defined and never cited-to-relevant, so a citation that
resolves cleanly while supporting nothing passes a fully green gate. An agent
justified "resolved layout is non-canonical" with a requirement about f32; a
dispatch contract of mine put KeySignature in Chapter 10 when the Score Graph
is Chapter 5. And S4 is itself an instance, which is what turned this from
incident into candidate.
The asymmetry is the dangerous part and is recorded with it: a dangling
citation is loud, a wrong one is silent, so repairing the loud kind without
reading the target converts a caught defect into an uncaught one. That is
exactly how S4 happened.
Every line citation in the new entries was re-read against its target before
filing, S9's own discipline applied to S9.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ten temperaments and the static 5-limit construction were bare names in a
catalog whose own requirement calls the surrounding semantics normative.
They now carry generative rules: which fifths are tempered, by what fraction
of which comma, where the wolf sits when the construction does not force it,
exact ratios, and the closure sum that lets a reader check the whole thing
without leaving the page.
The comma distinction is the load-bearing part and no test in this repo can
see it. Pythagorean for pythagorean, werckmeister-iii and -iv, vallotti and
young-ii; syntonic for the three meantones and both Kirnberger sets, each of
which also carries the schisma-tempered F-sharp--D-flat closing fifth whose
absence made two of these temperaments arithmetically impossible in the first
draft. Verified by recomputation rather than by re-reading: 2 x 10.753 +
1.9537 and 4 x 5.377 + 1.9537 both land on 23.4600 cents exactly.
ji-adaptive-5limit gets version 1 as the key-anchored static scale, identity
"default-v1" with the version inside the machine-visible string, hard error on
anything unregistered. The anchor derivation is pinned to (7 * fifths) mod 12
off the prevailing key signature -- and pinned twice over, because
key_sequence is per-staff and time-anchored, so the staff and the moment both
had to be named or two conforming implementations would disagree on a
modulating score.
The Forward References block stops claiming KeyContext is partially defined
somewhere it is not. It is defined nowhere, stays out of scope, and now
carries the one obligation that matters: whatever completes it must expose a
tonal-centre pitch class the anchor rule can use.
Three new requirements, 209 -> 212. Verified independently of the agent that
did the work: the count by grep, the count constant by mutation, the ten
comma types against the draft, the twelve lattice ratios against a generator,
and the ten deleted lines against the four sites they were supposed to come
from.
Closes P13-S6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All four open ratifications are decided, and the draft is corrected at both
places where it would have misled the agent that reads it next.
The 12-note 5-limit scale is the contiguous lattice block {3^a 5^b | a in
[-1,2], b in [-1,1]}, octave-reduced and assigned in ascending order from the
anchor. The draft called this one "asymmetric" and offered it third; that name
is an artifact of describing it as a 5x3 grid minus three discarded cells.
Generated from its bounds it discards nothing, fills all twelve chromatic
positions exactly once, and states in one sentence -- which is the property
Ruling B exists to get. The name is now banned normatively.
Two of the three candidates fell to computation rather than preference.
"Symmetric scale 2" makes G-D a 40/27 wolf, so a scale the catalog calls
"anchored to C tonic" would have an unusable dominant. And the draft's own
argument for "symmetric scale 1" -- that its D and Bb match C-based
Pythagorean and quarter-comma meantone -- is false: meantone's are 193.157
and 1006.843 cents, each half a syntonic comma away. It can only hold of
circle-of-fifths position, and even then covers 2 notes of 12 while the other
ten shift a comma between systems. Struck at its site and at the head of the
file, with the numbers, so it cannot be quietly reinstated.
The block also wins on merit: 23 pure intervals of 36 against 22 and 21, and
it gains pure Eb major and G minor for the price of Bb minor.
The three ji-static identifiers are one construction at three anchors -- which
the catalog already said, in the same phrase three times, more strongly than
the vallotti/young-ii naming parallel the draft reasoned from.
ji-adaptive-5limit version 1 is the key-anchored static scale, identity
"default-v1" with the version inside the string. Checking the prerequisite
turned up why that is affordable: KeyContext is not defined anywhere and the
spec puts its completion out of scope, but KeySignature { fifths } already
exists, is canonical, and is encoded -- so the anchor is (7 * fifths) mod 12
today, mode-blind exactly as ruled. C, G and D are fifths 0, 1 and 2; the
catalog's three anchors were never arbitrary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`req:tuning:builtin-tuning-catalog` MUST-resolves 20 tuning identifiers while
specifying only the six `tet-*`. Ruling B keeps all 20 and pins them by
**construction** rather than by cents table, because published sources agree on
constructions and differ mainly in rounding. This lands the draft of the other
14 as a **non-normative** artifact for review; nothing enters `core_spec.tex`
until the four open ratifications are decided.
Drafting rather than editing was deliberate, and it earned its keep. An agent
writing Werckmeister III from memory into a normative document is the
`NOTEHEAD_ANCHORS` failure exactly: hand-written, authoritative-looking,
unverifiable in-tree, and load-bearing.
**The first draft was well-sourced and wrong.** Ten entries, ten live citations,
zero recalled, zero unknown -- and three of them could not exist. A twelve-fifth
circle must absorb exactly one Pythagorean comma (23.4600 c). Checked:
kirnberger-ii 2 x 1/2 syntonic, "remaining ten pure" -> 21.5063 c
kirnberger-iii 4 x 1/4 syntonic, "remaining eight pure" -> 21.5063 c
Both short by 1.9537 c -- exactly one schisma, the signature of a dropped
schisma-tempered fifth. The draft said of the first, in terms, "This closes the
circle." It does not. And `werckmeister-iii` carried the syntonic-vs-Pythagorean
comma forward as a "genuinely ambiguous" hedge into its summary table, when
closure settles it: only the Pythagorean reading closes, and a well temperament
that does not close is not one.
The supporting reasoning had been *correct* -- the derivation that C-E, G-B and
D-F# come out pure is right. The error was inferring from it that every
remaining fifth is pure. Making those thirds just does not discharge the full
comma. That is precisely the failure a citation count cannot detect and an
invariant catches in one line.
**Corrected and re-verified by recomputation, not by re-reading.** All six
circulating temperaments now close to exactly 23.4600 c; all four
non-circulating residues reproduce independently (`pythagorean` 678.495 c;
meantone 1/4, 1/5, 1/6 wolves 737.637 / 725.809 / 717.923 c). The schisma fifth
is sourced at F#-Db, and Kirnberger III's four fifths are now a direct quotation
rather than a reconstruction.
**The best result is a disagreement.** Recomputing Kirnberger II's thirds from
its own chain, the agent found its new source's prose claims four pure thirds
where the construction yields three -- F-A comes out at 397.067 c, not 386.314.
It reported the conflict instead of deferring to the source or dropping it.
Independently confirmed to the cent.
The count is still 10 verified / 0 recalled / 0 unknown, but `verified` now
means sourced **and** invariant-checked. The number did not move; its meaning
did. A stable metric hiding a changed reality is this session's recurring lesson
in another costume.
`CONTRACT_P13S6_TEMPERAMENTS.md` gains a permanent section, "Check the
arithmetic, not just the source", stating the general principle first so it
transfers: a citation proves the source said it, not that it is right, that you
read it correctly, or that you transcribed it completely. Five obligations --
closure; closure as a decision procedure for vague sources; non-circulating
temperaments must *not* close, with the wolf computed; twelve fifths each
exactly once; recompute every derived claim and treat disagreement as a finding.
Plus the guard that matters most: never adjust a construction to make the
arithmetic work and present it as sourced -- that manufactures a temperament
nobody published. An unresolved entry is the correct output.
Still open, surfaced not decided: the three `ji-static-5limit-*` scales and
`ji-adaptive-5limit`'s algorithm, the latter recommended to follow
`req:pitch:spelling-algorithm`'s versioned-identifier pattern.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scoping Push 4b turned up three Chapter 4 defects and four rulings; this lands
the spec half of the first three. `PLAN_PUSH4B_TUNING.md` carries the full
scoping and all four rulings.
**P13-S5, Ruling A -- full register.** `req:pitch:ji-vector-basis` says the
built-in JI spaces order primes ascending *starting with 2* and that
`components.len()` MUST equal the basis size; the catalog table called
`ji-5limit` "Two-dimensional (prime axes 3, 5)", `ji-7limit` three- and
`ji-11limit` four-dimensional -- each exactly one short, the table being
octave-reduced and the requirement full-register. The table moves, the
requirement does not: a `JiVector` is an absolute position, and without the
prime-2 exponent `ji-5limit` cannot distinguish C4 from C5. Each row now states
its basis explicitly so two readers cannot derive different ones.
**P13-S7, Ruling C -- a score selects, it does not define.** `ScalePosition`
pointed at "the score's pitch-space registry", which does not exist: `Score` has
thirteen fields and none is one, and `ScoreTuningContext` carries ids and
accidental extensions, never definitions. So `req:pitch:default-pitch-space`'s
"MUST define / MAY define" was unsatisfiable except by reading define as select.
The requirement moves to *select*, the comment names the built-in catalog, and
score-local definition is recorded as a deferred major with its reason: the
Chapter 4 type surface has never had a consumer, and freezing ~20
never-constructed types under `req:binfmt:frozen-layout` is permanent.
**Ruling D -- `AccidentalEngraving` could not be canonical.** It borrowed
Chapter 7's `BoundingBox`, built on `StaffSpace(f32)` -- correct for the
non-canonical resolved-layout cache, but this field hangs off
`ScoreTuningContext`, which *is* canonical, and
`req:determinism:canonical-floating-point` requires canonical stored floats to
be binary64. Chapter 4 now has `EngravingBoundingBox` over `SpaceUnit`, the type
`advance_width` already used. Chapter 7's `BoundingBox` is untouched.
That defect only surfaced because the first scoping was wrong and got checked.
It claimed `GlyphReference` and `BoundingBox` "exist but live in
epiphany-layout-ir" and recommended moving them down. They are **homonyms**:
Chapter 4's `GlyphReference` is `enum { Smufl(u32), Custom, Composite }`;
layout-ir's is `struct GlyphReference(Cow<'static, str>)`, a glyph *name*. A
move would have relocated the wrong types. The plan is corrected and there is no
crate move.
**Review finding.** The rationale as first written read "Resolved layout is
non-canonical (Requirement req:layoutir:staff-space-coordinates)". That
requirement mandates staff-space units and single precision and says nothing
about canonicality -- a true sentence resting on the wrong authority, the P13-S4
pattern again. No labelled requirement asserts resolved-layout non-canonicality;
it is prose in the Binary Format companion. Each claim now rests on its real
source.
`requirement_labels.rs` is untouched: no requirement was added or removed, so
209/279/279 held, which was the contract's invariant against scope creep. The
checker cannot catch a citation that resolves but does not support its sentence
-- it enforces cited-to-defined, not cited-to-relevant. Second instance today.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`epiphany-core/DECISIONS.md` had carried two Push-4a audit claims as
**unverified** through two passes, with the standing note that they "should be
checked, not inherited". Checked. Both are real, and both are Chapter 4 defects
standing in front of Push 4b rather than inside it -- which is the point of
checking before scoping, not after.
**P13-S5 -- the JI prime basis is specified at two lengths.**
`req:pitch:ji-vector-basis` says the built-in JI spaces order primes ascending
*starting with 2*, and that `components.len()` MUST equal the basis size. The
built-in pitch-space table calls `ji-5limit` "Two-dimensional (prime axes 3,
5)", `ji-7limit` three-dimensional, `ji-11limit` four-dimensional -- each
exactly one short, consistently, because the table is octave-reduced and the
requirement is full-register. `req:tuning:builtin-tuning-catalog` makes the
table normative, so a 5-limit vector is required to be both length 2 and length
3. The requirement's own octave-reduction clause does not reconcile them: it
normalizes the first component to a canonical range, it does not remove it.
Same family as P13-S2 and P13-I1, and cheaper -- a one-side edit once ratified.
**P13-S6 -- no built-in tuning system's resolution is pinned to a versioned
definition, and 14 of 20 have no definition at all.** The claim as inherited
("the named historical tunings lack exact deterministic ratio data") understates
it. It is not a missing table: `TuningResolution::Function` delegates the
historical temperaments to a `TuningFunctionId`, which Chapter 10 lists as an
*extension point*, and no built-in is mapped to a function id nor any function
id pinned. Only the six `tet-*` entries are actually specified, by
`EqualTemperament`'s structural rule. The other 14 are names: three meantone
variants, `werckmeister-iii`/`iv`, `vallotti`, `kirnberger-ii`/`iii`,
`young-ii`, `pythagorean` (the 3:2 ratio is named, the fifth-chain construction
and wolf placement are not), three `ji-static-5limit-*`, and
`ji-adaptive-5limit`. Set against `req:tuning:tuning-resolution-determinism`,
which requires determinism *across platforms*, two conforming implementations
may each choose a different published Werckmeister III and both pass -- in a
project that runs a cross-implementation conformance step.
The fix pattern is already in-house and was found by looking for it:
`req:pitch:spelling-algorithm` pins `SpellingAlgorithmId "default"` at version 1
to a named algorithm and errors on any other identifier. Nothing equivalent
exists for tuning. Which published variant each built-in denotes is a
ratification question, not an implementation one.
Both are filed open in the Batch-3 ledger. Neither was needed for 4a; both are
load-bearing for 4b, whose whole job is resolving a scale position to a
frequency.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`cmn-24` was declared in the built-in pitch-space table as "CMN extended with
24-EDO quarter-tone accidentals" while Chapter 2 fixed a `Cmn` position's
absolute semitone at `nominal.chromatic() + alteration + 12*octave` for every
space, unqualified. Two ratified MUSTs, each correct in its own chapter,
jointly unsatisfiable -- P13-I1's two-listings drift in another costume.
The tracker parked this as "a data-model major". It is not.
`req:binfmt:frozen-layout` names `PitchSpacePosition` as one of four open
value-layer vocabularies, so three of the four available options cost no schema
event at all. The one that *is* a major -- regrading `alteration` onto a finer
fixed unit -- is also the only one that rewrites the canonical bytes of every
pitch ever authored, and it buys a single grid while `edo-31`/`53`/`72` stay
unrepresentable. It is explicitly ruled out.
Ratified instead: a `Cmn` alteration and a `CmnChromatic` modification are
denominated in steps of the enclosing pitch space's chromatic layer. One rule,
not a special case for one catalog row -- it settles `cmn-24`, `maqam-base`,
and `PitchSpaceModification::CmnChromatic` together. `cmn-12`'s step is the
semitone, so every score in existence keeps its meaning and **not one canonical
byte moves**; `canonical_pitch_bytes` writes the space id before the position,
so a `cmn-24` E-half-flat and a `cmn-12` E-flat already derive different
`PitchId`s.
The defect was larger than `cmn-24`. Nothing bound a `Cmn` position to a
12-chromatic space: `ScalePosition { space: "edo-31", position: Cmn { .. } }`
constructed, validated, encoded and transposed, and meant nothing. Neither
`Pitch::transposed` nor `twelve_tet_semitone` ever read the space. Until Push
4b resolves `PitchSpaceId` to a `DiatonicOverChromatic` structure, both now
fail closed outside provable built-in `cmn-12` -- a capability check, not a
claim that the identifier defines the structure. False refusal for a
score-defined 12-chromatic space is accepted over silently wrong arithmetic.
No wire change. `TransposeRefusal::PitchSpaceUnavailable` is a new *diagnostic*
mapping to the existing `PreconditionFailureReason::PitchSpaceMismatch` (6);
appending a discriminant for a guard Push 4b must delete would have reserved a
permanent wire value for a temporary mechanism, in exactly the artifact the
fail-closed ruling exists to protect. Assignments 10-15 are untouched.
Operation Catalog 0.9.0 records the broadened case *and* amends the 0.8.0
rationale, which asserted that discriminant 6 never consults a pitch-space
registry -- true of the non-`Cmn` check, false of this one.
Two spec-side fixes found in review, neither visible to any test: the
`TranspositionInterval` listing had lost its `pub chromatic_steps: i32,`
declaration to a comment rewrite, leaving a one-field struct above the sentence
"Both components are load-bearing"; and `pitch.rs` still documented
`alteration` as "in semitones", contradicting the requirement landing beside
it. Both new requirements are now cited from the code that implements them --
the label checker catches cited-but-undefined, not the reverse.
Push 4b keeps the registry work and loses the contradiction. P13-S4 is the last
item open in Batch 3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
168 of core_spec's 207 requirement blocks carried no `\label`, so no conformance
claim could cite them. All 207 are labelled now; the suite is 277/277.
Labelling alone would not have delivered a citable requirement. **No document in
the suite numbered its requirements.** `\newtcolorbox{requirement}` had no
counter, so a `\label` inside one bound to the enclosing sectioning unit and
`\ref` rendered a *section* number: core_spec said "see Requirement 2.5.4" where
2.5.4 is a subsubsection, and 61 of its 207 requirements shared a rendered number
with another -- one number, 5.6.3, was shared by six. Adding 168 labels to that
scheme would have produced 168 citable-but-ambiguous references. All six documents
now carry a real counter, numbered within chapter, and the box title shows it, so
a reader can see which requirement they are looking at. 277 labels, zero
collisions.
The counter is stepped with a `code=` key rather than tcolorbox's own
`auto counter`, and that is not a style choice. `auto counter` steps its counter
for `\label` purposes inside an internal `\sbox`, and `\refstepcounter`'s effect
on `\@currentlabel` is a local assignment discarded when that box closes --
before a `\label` written in the box body ever runs, which is how every
requirement in this suite is labelled. Measured on a three-box test document:
titles rendered `1.1 1.2 1.3` while the three refs resolved to `1.1 1.1 1.2`. The
idiomatic form would have shipped 207 silently wrong cross-references under
correct-looking numbers.
`requirement_labels.rs` locks all of it: every requirement block in every
`spec/*.tex` carries a label; labels match `req:<area>:<slug>`; the area matches
the chapter; labels are unique suite-wide; no `req:*` string cited anywhere in the
repository is undefined; and the counter is stepped where the label can see it --
a regression lock, because reverting to `auto counter` leaves every other check
green while the references break.
The citation check needed an escape. It cannot tell "cite this requirement" from
"name a label that does not exist", and documenting a dangling label is a
legitimate thing to do -- it had already rewritten a scoping plan's prose into a
euphemism to make itself pass. `DISCUSSED_NOT_CITED` carries the one such string
with its reason.
That string was the pass's other finding. `req:layoutir:vertical-bands` was cited
twice in the Pass-12 log and never existed. It should not be repointed at the two
*ownership* requirements: those govern which band a primitive belongs to, while
both entries describe the inter-staff solve realizing a band's declared *height*,
which no requirement governs at all. That is why the log invented a name. Both
citations now say so, and the gap is filed as P13-S4 -- shipped behaviour with no
governing requirement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thirteen drift-locked document vectors -- four accept, nine reject, one per
implemented rejection class -- gated as conformance step [7e] beside [7d]'s 65
decode vectors.
The checker distinguishes three outcomes, and that distinction is the whole
point. `Rejected`, `AcceptedCanonical`, and `AcceptedNonCanonical` are separate
verdicts, and every one of the four wrong pairings is a failure: an accept vector
that merely normalizes fails, and a reject vector that is accepted -- whether as
canonical or after normalization -- fails. The decode corpus learned this the
expensive way, where collapsing "rejected" with "accepted but normalized" let a
corpus pass against the exact decoders it was written to catch.
Accept vectors assert the byte-checkable equation `project(serialize(parse(T)))
== T`, which quantifies over TEXTS. The comments say plainly that it must not be
replaced with bundle identity, and name the three deliberate binary losses --
duplicate blobs collapsing under derived-ordering, regenerated physical layout,
and dropped accelerators -- so the next reader does not "fix" it into a bundle
comparison and then chase a phantom.
Reach is asserted exactly, not merely non-zero: two documents each carrying an
extension, a canonical base, a custom profile, a lineage, and more than one
envelope; and every reject class must have exactly one vector the parser really
refused. A mislabelled reject drops its class to zero rather than collecting
credit from its label.
The corpus was verified by breaking the implementation, not the checker: making
`parse_document` silently tolerate a missing trailing LF is caught three ways,
and the suite reports "declared reject, but was ACCEPTED and normalized".
Review fixed one defect in the semantic harness. It claimed to check
`semantics(parse(project(B))) == semantics(B)` but compared `parse(project(B))`
against `document_from_bundle(B)` -- and `project_bundle` *is*
`project_text_document(document_from_bundle(..))`, so both sides flowed through
one function and any bug in it cancelled out. Dropping an envelope there passed.
It now compares against the envelopes the test itself serialized in, which is
independent of the projection path; the same mutation fails.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`epiphany-textproj` projects a bundle to its canonical text and reads it back:
the header, identity, profile, extension, canonical-base and blob productions,
plus the pipeline that joins them to the operation layer already in place.
Free functions, not `TextValue` impls. `epiphany-bundle` does not depend on
`epiphany-core`, so the trait and the manifest types are both foreign to this
crate and the orphan rule forbids the impls. That looked like it would force a new
dependency edge until the productions were read properly: no document line
contains a `value` position, so none is needed. The bundle stays independent of
the music model, as designed.
Blobs are handled on both sides and neither is the obvious one. The emit side
filters `blob_roots` through a real reachability predicate that today provably
returns empty -- never the field wholesale, which is the plausible wrong answer.
The parse side rejects any blob line outright, while still parsing the production
in isolation so both halves are ready together. A trip-wire source-scans
epiphany-core and epiphany-ops for `BlobId` and names both obligations when it
fires; it asserts it actually scanned files, so it cannot pass vacuously.
Two rejections were added on the agents' own reading of the spec rather than the
brief, and both are right. Envelopes must already be in canonical reduction order
(text_projection.tex:458) -- without that, two texts differing only in envelope
order would denote one document. And a hand-built `TextDocument` carrying blobs is
refused rather than silently dropped, since serializing it would produce a bundle
whose next projection loses those bytes.
Section order is enforced as a sequence, not a set: a repeated or out-of-order
section is a rejection, never something the parser sorts back into place.
`derived-ordering` sorts and de-duplicates blob lines and extension chunk roots by
*rendered* form, proved against fixtures that are unsorted in memory rather than
already canonical -- the blind spot that let five outbound normalizations ship
untested in the operation layer.
Serialization stages payloads and lets `commit` assign every offset, hash and
chunk id, which is exactly what `req:textproj:derive-or-carry` assumes; nothing is
hashed by hand. The emitted bundle deliberately carries no accelerators.
And the three directions compose: `project(serialize(parse(T))) == T` holds
end-to-end over the companion's own worked example. Eight checks re-mutated
independently of the agents' reports, each killed by its own named test; all eight
document productions diffed against the grammar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scoping the document layer found that no blob can be canonical. A blob is
canonical iff referenced by a canonical operation or by canonical reduced state
(`req:textproj:canonical-blobs`; core_spec §"Canonical and Non-Canonical Manifest
Roots"), and nothing in epiphany-core or epiphany-ops references a `BlobId` --
there is no mechanism by which one can be reached. So today every real bundle
projects to zero blob lines.
The first instinct was to let a parser accept blob lines anyway, for forward
compatibility. That is wrong twice over. Forward compatibility is owned by header
gating -- a future writer's text carries a future version, which this parser
rejects at line one -- and a blob line accepted today would be staged into a
bundle that the next projection silently drops, losing data *and* falsifying
`project(serialize(parse(T))) == T` for that text.
`req:textproj:reject-unreferenced-blobs` therefore requires rejection, and the
conformance equation holds unconditionally over parse-accepted texts rather than
only over texts in the image of `project`.
`req:textproj:header-version` pins the other half: a parser accepts exactly one
header version, the companion's own. Multi-version acceptance and text
migrate-on-read are deferred in the same posture as op-payload migrate-on-read
rather than improvised.
Auditing the worked example against its own grammar found three defects in it: it
carried a blob line, which is now by construction an example of an *invalid*
document; its byte strings used literal ellipses, which `bytes ::= "#x" hexdigit*`
cannot derive; and its header still claimed 0.3.0. All three fixed, and the
preamble no longer promises elisions it does not contain -- an example that cannot
be parsed teaches the wrong lesson.
The version now appears in six places here. Two were locked; the two dangerous
ones were not, because they are *normative* -- a bump that updated the title and
missed them would leave the companion requiring parsers to accept a version it no
longer is. `requirements_name_only_this_companion_version` scans every requirement
block and holds any version literal to the title, deliberately exempting the
revision history, where old versions are the point.
Twelve grammar-gate tests, all mutation-verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Projection and strict parse for the whole Chapter-6 operation vocabulary: the 31
kind productions, the envelope with its stamp and causal context, the four
payload variants, and the sub-vocabularies for actions, undo policies, tuplet
compensation, cross-cutting values and position remapping. Written against the
grammar, per `req:textproj:operation-vocabulary`, calling `TextValue` only where a
production says `value`.
Names are generated, never spelled: `OperationKindTag::catalog_name()` comes from
the same vocabulary macro as the wire discriminant and the decoder, and `parse`
dispatches through an exhaustive match, so a kind added to the vocabulary and not
to the projector fails to compile.
Six sequences are order-constrained because their encoders normalize. Both halves
of each are enforced and both halves are tested, which turned out to matter. The
rejecting half was straightforward -- `TransposeOp.targets` mirrors the frozen
multiset exactly, rejecting a strict decrease while accepting a duplicate, and
getting that backwards would silently break a frozen operation's replay. The
*normalizing* half was written correctly by every agent and tested by none: every
fixture was already sorted, so all five outbound sorts survived deletion with the
suite green. The consequence was real -- with one removed, the projector emits
descending targets that its own parser then rejects, and that disagree with the
canonical bytes.
`textproj_conformance.rs` closes that. Each of the five builds a value unsorted in
memory, asserts the fixture pair really is descending before relying on it, then
checks the projection sorts, parses, and matches what encode-then-decode produces
-- pinning text and bytes to one normalization rather than merely to each other.
It also carries a structural injectivity sweep: 3319 mutants of projected
envelopes, of which 259 parse, all re-projecting byte-identically. That is
`req:textproj:roundtrip`'s second equation, and it is the evidence that no
whole-line re-project guard is needed -- every path that could normalize is
pre-empted by a per-site check. The sweep asserts its own reach, because one that
rejected everything would prove nothing. Deleting any per-site order check makes
it fail independently of the dedicated test.
And it locks the companion's worked example byte-for-byte. It was correct and
nothing kept it so, which is how a "machine-checked" claim became true of one run
and of nothing durable at 0.3.0.
All seven checks mutation-verified, each killed by exactly its own named test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scoping the operation layer surfaced a contradiction the companion had never
resolved. `req:textproj:value-projection` is a mechanical rule over structs,
enums and newtypes; the Grammar chapter separately spells out `envelope`,
`stamp`, `causal`, `payload`, `kind` and their sub-vocabularies. The two disagree.
Under the value rule an operation would read
`(insert-event (insert-event-op #x0a <event>))`, because an `*Op` record is a
named-field struct and so not a transparent newtype.
`req:textproj:operation-vocabulary` rules for the grammar, which is what the
companion already implied: the value rule's own preamble scopes itself to the
values an operation *embeds*, and the `value` nonterminal marks where it applies.
If the rule governed the vocabulary too, the grammar's productions would be
redundant and partly wrong.
An operation kind therefore inlines its payload record, and the requirement says
why rather than asserting it: the record exists so each variant can name a type,
and the binary form adds no bytes for it -- `OperationKind`'s encoding writes the
tag and delegates. A wrapper that adds no bytes adds no text. That is the same
argument clause 2 makes for newtype transparency, applied one level out.
Also removes a second name for one type. `transpose-interval` inlined its interval
as `(interval <d> <c>)` while `TranspositionInterval` projects as
`(transposition-interval <d> <c>)` at every `value` position. The production now
delegates to `value` and the special case is gone.
Two new checker tests lock both, bringing that gate to nine.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Found by starting the implementation: the grammar could not derive an ordinary
pitched note.
`value` had no alternative for a sequence at all, though value-projection clause 5
required one. Adding it exposed why it was missing. A sequence whose first element
is a fieldless variant is shape-identical to a struct, and `()` is both the empty
sequence and the absent option. Both collisions are reachable from the first
pitched note in any score: `PitchedEvent` carries `articulations` and `ornaments`,
sequences over the zero-field `ArticulationMark` and `OrnamentMark`, beside an
optional `DynamicMark`. One `insert-event` line holds both.
The collision is irreducible without new syntax, and new syntax buys nothing --
`req:textproj:strict-parse` already obliges a parser to reject a duplicate in a
set-typed field, which it cannot do without knowing the field is set-typed. The
parser consults the schema either way. So `req:textproj:schema-directed` states
what the ratified rules already required, `value` collapses to `"(" value* ")"` or
a leaf -- all shape can honestly say -- and the requirement assigns meaning by the
expected type. The binary form is schema-directed for the same reason and pays the
same price: its bytes do not say what they are either.
Three consequences stated: a struct with no fields is the bare symbol, as a
fieldless variant is; a byte string is not a sequence, so an opaque extension
payload and a `SoundConfiguration` project as byte strings, never as lists of
integers; and the grammar's repetitions now carry a notation rule -- adjacent
elements separated by exactly one space -- without which `"(transpose (" bytes* ")"`
spelled two targets as one undelimited run of hex.
Checker gains a seventh test, mutation-verified by restoring the 0.4.0 `value`
production and by stripping the schema-directed citations. It asserts the
symbol-headed struct alternative is *absent*: a grammar claiming to tell a struct
from a sequence by shape would be lying.
Gate green -- clippy 0, 1038 tests, doc 0, conformance 8/8, no golden churn, three
spec documents build clean with no undefined references.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 0.3.0 audit raised two normative issues and one process issue. All three
land here, plus a fourth defect that reviewing the fix turned up.
The grammar contradicted its own escape requirement. `req:textproj:string-escapes`
obliges a writer to escape the backslash and a parser to reject a bare one, while
`unescaped` admitted it. Escapes are now four two-character sequences and
`unescaped` excludes U+0022, U+005C, U+000A, U+0009 by codepoint.
"Keep the binary order" was not available for every sequence. It holds only where
the binary order reads data the projection preserves, and two sequences fail that
test: `blob_roots` sorts by the full `BlobRef` encoding (offset, compressed
length, compression), and an extension's preserved chunk roots sort by
`ChunkRef`'s order, keyed on kind, then content hash, then *offset*. Under the
blanket rule, relocating a chunk -- which changes no semantics -- would change the
text, and two entries indistinguishable after erasure would produce duplicate
lines. `req:textproj:derived-ordering` orders and de-duplicates exactly those two
by projected form, and states that every other sequence keeps the binary order:
profile and extension declarations sort on semantic `(id, version)` keys, and
envelopes on canonical operation order.
The "machine-checked" grammar was checked by a throwaway script -- true of one run
and of nothing durable, the same evidence gap P2-P4 kept exposing.
`text_projection_grammar.rs` is the committed form: no nonterminal undefined or
unreachable, the escape rule admits exactly its four sequences, and the operation
and chunk productions are *derived* from `OperationKindTag::PAYLOAD_FREE` and
`ChunkKind` through an exhaustive match, so a kind added to the enum and not to
the grammar fails to compile. Every locator finds its production by name; writing
it exposed four bugs in itself, three of them column-anchored checks that a reflow
would have silently switched off.
Reviewing the escape fix found it reintroduced the audited defect in disguise: a
quoted terminal `"\\"` reads as two backslashes, so every escape became three
characters long. Both characters are now codepoints. Relatedly the mono font's
`Ligatures={TeX}` rendered U+0022 as a right curly quote and `--` as an en dash,
so the grammar misprinted the very delimiters it defines; `core_spec.tex` already
omitted it. Both are asserted against.
All six tests mutation-verified: the anchor asserted present before substitution,
then the named test observed to fail. Gate green -- clippy 0, 1037 tests, doc 0,
conformance 8/8, no golden churn, three spec documents build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0.1.0 left kind, action, policy, constraints and barrier derived-but-unwritten
and admitted it. All are now written. The grammar has no undefined nonterminal
(machine-checked) and 31 operation-kind productions in exact discriminant order,
cross-checked against envdecode.rs.
The one real decision was how embedded Chapter-5 values appear. An operation
payload carries an Event, a Pitch, a Region, a TimeSignature, and there were
three ways to write them:
Forty hand-written productions would restate the entire Chapter-5 data model in
a second normative document -- two normative listings of one struct, which is
the exact drift P13-I1 was opened to close.
Opaque canonical-value byte strings would be lossless and zero-drift, but a
pitch would be unreadable without binary tooling, failing the core spec's own
"format inspection and debugging" use case.
One mechanical rule (req:textproj:value-projection, ratified): a struct is
(<type-name> <field>...) with fields positional in the ratified declaration
order; a newtype is transparent, exactly as in the binary form; a tagged union
is (<variant> <field>...); an option is () or (some v); a sequence keeps the
binary form's order. A rule cannot drift from what it reads.
Two leaf decisions follow from canonicality rather than taste. A rational is
(ratio n d), lowest terms, sign on the numerator. A CanonicalF64 is the byte
string of its eight canonical IEEE-754 bytes and never a decimal: decimal float
text is not canonically unique -- shortest-round-trip and 17-significant-digit
forms both round-trip, and -0.0 has two spellings -- so a decimal tempo would
break req:textproj:canonical-text at the first tempo mark.
Operation-kind names follow the Operation Catalog's sections (create-region,
create-staff), not OperationKindTag's (InsertRegion, InsertStaff), which renamed
three pairs for reasons internal to the tag space. The projection follows the
semantics.
One deferral, stated as such rather than left to inference:
affected_object_kinds and edit_barriers have ratified structured shapes AND
canonical byte encodings, and the bundle stores them opaquely. At 0.3.0 the
projection does the same, on the principle that it interprets nothing the bundle
does not. A later revision may project them structurally; their canonical bytes
are unchanged by that, so it changes the text and not the document.
Also corrected: the extension line now writes its fields in the ratified
declaration order, which had preserved_chunk_roots before affected_object_kinds
and edit_barriers.
Gate: clippy 0, 31 targets / 1031 passed / 0 failed, conformance 8/8, zero golden
churn; core_spec, binary_format and text_projection all build with no undefined
references. Grammar closure machine-checked: no undefined nonterminal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A review found the 0.1.0 companion lossy for documents that are valid today. Its
claim to preserve the manifest's canonical roots was false three ways:
A canonical blob had no representation at all. blob_roots referenced by
canonical operations or reduced state ARE canonical roots, and the document
structure had no blob line. An embedded image, font or recording would vanish
from a projection silently -- the operations referencing it still there,
pointing at a blob id the text no longer contained.
An ExtensionDeclaration lost its semantic version and its affected_object_kinds
outright, and left its preserved_chunk_roots undefined.
ProfileId::Custom(ProfileRegistryId) was unrepresentable: a symbol was required
where sixteen registry bytes are carried.
All three share one cause I had not named. A ChunkRef and a BlobRef are PHYSICAL
references -- offset, compressed length, compression -- which the projection may
not preserve; and they carry DERIVABLE identities -- ChunkId, ContentHash, BlobId
-- which it may not duplicate. Having no rule for either, I dropped the
references and took their contents with them.
req:textproj:derive-or-carry states it, and it is the same rule
req:textproj:reduced-state-derived already applied one level up: carry exactly
what the document does not determine, and nothing it does. Physical attributes
never appear; derivable identities never appear; content and semantic attributes
always do. The one non-derivable identity in schema major 0 is SnapshotId, which
the Binary Format companion pins as opaque and forbids readers to derive -- an
exception for a stated reason rather than an oversight. The grammar now visibly
contains no offset, no length, no compression, no chunk id, no hash, no blob id.
So: req:textproj:canonical-blobs (canonical blobs projected, non-canonical ones
not), req:textproj:profile-id ((custom #x...)), req:textproj:extension-declaration
(every field; chunks as kind + schema + payload, never as a ChunkRef -- the
projection has no file to point into), and req:textproj:base-snapshot-inline
extended to say what the inlined payload IS and that the root ChunkRef and the
SnapshotRef hash are re-derived from hash(Snapshot, schema, payload), never read.
The gap started upstream. core_spec's own list of what the projection preserves
omitted canonical blobs while classifying blob_roots as canonical roots -- an
inconsistency inside one document. Corrected there, along with withdrawing the
permission to reference a base snapshot "externally", which the inline
ratification had already made untenable.
Also: the generated PDF metadata and page header still said Operation Catalog,
inherited from the copied preamble.
The four 0.1.0 ratifications stand unchanged. Implementation stays deferred: a
gate that is lossy is not a gate.
Gate: clippy 0, 31 targets / 1031 passed / 0 failed, conformance 8/8, zero golden
churn; core_spec, binary_format, operation_catalog and text_projection all build
with no undefined references.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
core_spec Chapter 8 declares the text projection normative and leaves the form
unwritten; binary_format excludes it as "the Text Projection companion's". That
companion did not exist. spec/text_projection.tex v0.1.0 supplies it. No
implementation -- this is the gate.
It was blocked on P5. The projection's normative requirement is bidirectionality
WITH THE BINARY FORM, which needs bytes -> OperationEnvelope, and that decoder
did not exist until 3baf8d0.
Four ratified calls:
Reduced state is preserved by DETERMINING it, never by a second literal copy. It
is a deterministic function of the operation set and the canonical base; a text
carrying both would hold two sources of truth for one fact and nothing could stop
them disagreeing. core_spec's "all canonical reduced state" now carries that
reading inline rather than being silently reinterpreted.
A canonical base snapshot is INLINED as one opaque byte string. This is the call
with teeth. A base exists precisely so prior operations need not be retained, and
where they are pruned the base is derivable from nothing else -- so a
reference-only projection of a compacted document would be LOSSY, and the text
would not determine the document it claims to project. core_spec permits "encoded
compactly or referenced externally"; inline is the choice that keeps archival
honest, and core_spec now says why.
Lowercase hex everywhere: one rule, no alphabet or padding to canonicalize,
greppable. Base64 would buy a quarter of the bytes of the one body nobody reads,
at the price of a second encoding and a rule for which applies where.
One envelope per line: the stated use case is that merge conflicts surface at the
envelope level, and one line per envelope makes a three-way merge conflict
exactly an envelope conflict -- never a conflict inside one, yielding an operation
neither side wrote. It also removes all indentation, so canonicality has nothing
to hide in. Readability is a pretty-printer's job; what a pretty-printer must not
do is write its output back and call it a projection.
Strict parsing is stated in the terms P2-P5 taught: normalizing non-canonical
text IS accepting it. The rationale names both hazards this repo hit in binary --
a re-encode guard is blind to order-preserving sequences, and a guard on an outer
value can mask a lenient inner codec -- and prescribes the same total defence.
Conformance requires both directions: a projector alone cannot be checked.
One gap is stated in the document rather than left to be discovered: the atom
productions and line shapes are normative, while kind/action/policy/constraints/
barrier are derived from the Operation Catalog and the wire table rather than
spelled out. That is the difference between a design gate and a finished
companion.
Gate: clippy 0, 31 targets / 1031 passed / 0 failed, conformance 8/8, zero golden
churn; core_spec, binary_format and the new companion all build with no undefined
references.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review findings after P4. Both real.
The tag-omission failure could recur. After the P4 fix there were STILL four
hand-maintained lists -- a test-local all_tags(), a barrier test spelling
0u8..=30, a fuzz corpus naming five tags, a vector corpus naming four -- plus a
malformed-bytes test asserting that 31 rejects. A future tag 31 added to
discriminant() and omitted from decode_canonical() would have left every one of
them green, and the malformed test would have LOCKED it, exactly as the 30
version did two commits ago.
operation_kind_tag_vocabulary! is now the single source. It generates
discriminant, from_discriminant, and OperationKindTag::PAYLOAD_FREE from one
list, and the generated discriminant match is exhaustive over the enum -- so a
variant added to the enum and not to the macro fails to COMPILE. Everything
downstream reads PAYLOAD_FREE: the decoder, the fuzz corpus (all 31 tags, not
five), the conformance vectors (an accept vector per tag: 65 vectors, not 37),
and the edit-barrier round-trip. Every "one past the vocabulary" constant is
computed, never spelled; a spelled constant is the trap that springs on whoever
appends the next tag.
Verified end to end with a hypothetical tag 31. Added to the enum alone: compile
error. Added to the enum and the macro: it compiles, decodes, and every derived
check passes because they read PAYLOAD_FREE -- while the committed corpus's
drift lock AND its now-stale "one past the vocabulary" reject vector both fail,
forcing the new vectors into the diff. There is no path where a new tag leaves
everything green.
Second finding: the corpus called itself normative while the spec said it was
deferred. Binary Format's "About This Companion" listed the cross-implementation
decoder test among things the document does not cover, and the Golden Anchor
Registry called it "the deferred conformance harness" whose literal-byte vectors
a future test "should add". Both now ratify it. New req:binfmt:decode-vectors
and a "The Decode Vector Corpus" section: a conforming decoder MUST accept every
accept vector for a surface it implements, MUST reject every reject vector, and
MUST re-encode an accepted value to exactly its bytes -- and accepting a reject
vector and then normalizing it IS accepting it. Binary Format 0.8.0 -> 0.9.0.
The wire-format fuzzer stays an implementation deliverable. The corpus header
now cites the requirement instead of asserting one.
Gate: fmt clean, clippy 0, 30 targets / 1024 passed / 0 failed, docs 0 under
-D warnings, conformance 8/8 with [7d] at 65 vectors, zero golden churn,
binary_format rebuilds with no undefined references.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
spec/vectors/decode_vectors.txt -- 37 committed byte strings across five
surfaces, each with its normative accept/reject verdict. The reference
implementation's fuzzers prove its own decoders self-consistent, which says
nothing about whether a foreign decoder agrees with the format. This is what one
is checked against. Gated in the conformance suite as [7d], and drift-locked:
the committed file must equal vectors::render(), so a wire-format change lands
in the diff.
It found a real defect on its first run. OperationKindTag::TransposeInterval
encoded to [30] and its own decoder REJECTED it -- Push 4a added the variant to
discriminant() and never to decode_canonical. OperationKindTag is what edit
barriers persist, so a barrier prohibiting TransposeInterval could be written and
never read back. Silent data loss on reopen.
Four things should have caught it. None did, and two made it worse:
The round-trip test enumerated DISCRIMINANTS -- (0u8..30).map(decode_canonical)
-- starting from bytes the decoder already knew, so it structurally could not
notice a variant the decoder was missing. It now enumerates VARIANTS from one
all_tags() list, with a completeness check in both directions.
The distinctness test's hand-written variant list omitted it too. Same list now.
operation_kind_tag_decode_rejects_malformed_bytes asserted that tag 30 is
REJECTED, and layout-ir's decode_rejects_unknown_discriminants asserted the
same at the barrier surface. Both were locking the bug in place and made it
look deliberate. Both now name 31, and a new barrier test round-trips a barrier
prohibiting every tag -- the persistence surface where this actually bites.
The P2 decode fuzzer fed valid corpus bytes to the tag decoder and tallied the
failure as a REJECTION, like any garbage input. It never asserted that an
unmutated corpus entry decodes. Both fuzzers now do, as a pre-pass.
The harness had the same disease as the code. `check` collapsed "rejected" with
"accepted but does not re-encode", so a decoder that silently normalizes
non-canonical bytes PASSED the reject vectors it was written to catch. Verified:
removing the whole-state guard, and restoring the lenient compression codec, both
left the corpus green. `check` now returns Ok(injective) for accept and Err for
reject and never conflates them -- silently normalizing non-canonical bytes IS
accepting them. With that fixed, all four defect mutations fail the corpus, each
naming its class.
The corpus pins one vector per class this repo has shipped a bug in:
non-canonical-map-order (a guard catches it; no per-site check exists),
non-canonical-vec-order (only a per-site check catches it; a guard is blind),
lenient-sub-codec (a guard masked it in the manifest; the index had none), plus
trailing-bytes, truncated, unknown-discriminant, count-exceeds-remaining. A test
fails if one goes missing.
Gate: fmt clean, clippy 0, 30 targets / 1024 passed / 0 failed, docs 0 under
-D warnings, conformance 8/8 (now including [7d]), zero golden churn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A wire-decode fuzzer over Bundle::open, Manifest::decode, OperationIndex::decode,
decode_block and envelope_offsets. The existing crash-recovery fuzzer corrupts an
image the way a CRASH does -- torn writes at syscall boundaries. This one
corrupts it the way an attacker or a bit-rotted disk does: arbitrary bytes,
anywhere.
It found a real defect. CompressionAlgorithm::None read its parameter byte and
DISCARDED it, while encode writes zero. So [0, 0xFF] and [0, 0] both decoded to
None, and the first re-encoded to the second: a lenient, non-injective codec,
inherited by every structure embedding a ChunkRef.
Whether that was visible depended entirely on the embedder:
Manifest::decode has a whole-value re-encode guard, and it is TOTAL -- proved
by exhaustive single-byte perturbation, every one rejected. It caught this.
OperationIndex::decode has no guard; it validates per-site. It accepted both
byte strings, while its own doc promised to "reject (never normalizing) any
non-canonical form". That promise was false.
That is the same two-layer lesson P2 recorded one commit ago, from the other
side: a re-encode guard is complete only where the encoder normalizes, and its
completeness can MASK a lenient sub-codec rather than fix it. Fixed at the
source, not papered over at the index. An exhaustive sweep -- every byte, every
value, plus an 8-byte extreme-integer window -- finds no remaining non-injective
site.
The fix contradicted ratified spec text, which said the byte was "present but
zero, and ignored on read". Escalated rather than fixed unilaterally. The user
ratified strict decode: core spec's clause is superseded, Binary Format gains
req:binfmt:compression-none-parameter and moves 0.7.0 -> 0.8.0. No wire layout
changed, and no conforming writer emits a non-zero byte, so this rejects only
corrupt or adversarial input -- no existing file changes meaning.
Coverage was the harness's problem again. The fuzzer's first run reached the
operation index's accept path ZERO times -- random bytes never decode as an
index -- so every assertion under it was vacuous. It found the bug only once the
index corpus was built from real OperationIndex::build output. The smoke tests
now assert on a WireFuzzCoverage so that cannot silently regress. 1.5M inputs
across five seeds, ~1s each, clean after the fix.
Three regressions, each mutation-verified by restoring the leniency: the codec
itself, the index that exposed it, and the manifest guard's totality -- which is
the asymmetry that hid it.
Gate: fmt clean, clippy 0, 30 targets / 1012 passed / 0 failed, docs 0 under
-D warnings, conformance 8/8, zero golden churn, both spec documents rebuild
with no undefined references.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A review of the P13-S3 fix caught the reducer's comment overclaiming. It said
the coupling "cannot mis-fire on an unrelated operation" because
ModifyIdentifiedPitch never writes the spelling set. That is true of the
OPERATION and irrelevant to the UNIT: the coupling is keyed on the pitch, and
on which keys the TRANSACTION wrote. A transaction whose members write the two
halves separately couples exactly the same way.
The editor's "move note" is precisely that -- ModifyIdentifiedPitch for the
value plus RespellPitch for the spelling set, in one transaction. Measured:
no later respell, BestEffort -> Applied, pitch restored to C4
later respell, BestEffort -> Applied, pitch STAYS D4, later E stands
later respell, StrictInverse -> Conflicted, pitch stays D4
The middle row is the coupling firing on a non-transpose pair, and it is
correct: restoring the pitch to C4 while the engraved spelling reads E --
authored against the moved pitch -- is exactly the stale-notehead defect the
coupling exists to prevent. Musically, undoing half of "move this note and
respell it" is not a smaller undo, it is a wrong score.
So the breadth is stated rather than left to be inferred.
req:opcat:spelling-set-chain now says the unit is keyed on the pitch and on the
transaction's writes, names the move-and-respell case, and notes that a
transaction writing only one of the two keys is unaffected -- an unwritten key
yields no supersession, which is the narrow claim the old comment should have
made.
Three regressions: full undo when nothing supersedes (guards against
OVER-coupling), best-effort skipping the pair when a later respell supersedes,
and strict undo conflicting. Mutation-verified by removing the coupling: only
the middle test fails, restoring the pitch to C4 with its spelling still
reading E, while the other two stay green -- they lock different properties.
Gate: fmt clean, clippy 0, 30 targets / 1001 passed / 0 failed, docs 0 under
-D warnings, conformance 8/8, zero golden churn, canonical-base digest unmoved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
My design note was half right, and the audit caught the other half. Keeping
engraved_spelling_chain physically separate from respell_chain was correct --
respell_chain is RespellPitch's LWW working state, read by its
concurrent-differing conflict detection, and folding transposes into it would
make a concurrent respell conflict with a transpose and move the canonical
bytes of every existing history. What that did NOT license was letting one
operation own the key.
RespellPitch mutates the same graph attachments and recorded nowhere on the
attachment chain, so a chain with a single writer was wrong both ways. Both
reproduced:
respell -> [tx: transpose] -> StrictInverse undo
Applied; pitch restored to C4; the UserChosen(C) attachment ERASED.
The respell was an operation, not part of the base, so the transpose's
chain had never seen it and its predecessor was absence.
[tx: transpose] -> respell -> StrictInverse undo
Applied; the newer UserChosen(D) authoring WIPED. The respell was
invisible to the chain, so it never registered as a superseding writer,
contradicting the catalog's rule that a later canonical writer supersedes
a strict undo.
And BestEffort could restore the pre-transpose pitch while leaving a spelling
authored against the transposed one attached to it.
Fix: every writer of the attachments records on the attachment chain
(record_engraved_spellings), and a pitch's value and its engraved spelling set
undo as one unit -- if either half is superseded, neither is restored.
StrictInverse already refuses on any supersession, so the coupling only bites
for BestEffort. Two physical chains, two responsibilities: respell_chain owns
the ledger spelling and the LWW verdict, engraved_spelling_chain owns the graph
attachments.
Recording is gated on graph presence, so base-free reduction is byte-unchanged
and the seeded corpus's canonical-base digest does not move.
Four tests, two mutations verified: removing the respell's record fails all
three undo tests; removing the coupling fails the best-effort one with the
pitch back at C4 and its spelling still at C-sharp. The fourth test locks
convergence -- both permutations of a concurrent respell/transpose reduce to
identical canonical bytes.
Spec: new req:opcat:spelling-set-chain. Batch 3 of the Pass-13 ledger reopens
(P13-S1, S2 open; S3 resolved here).
Also: PitchSpelling::transposed's doc said B-sharp 3 becomes F-double-sharp 3.
The code, spec, and tests all correctly produce F-double-sharp 4 -- B to F
carries the octave. Doc only.
Gate: fmt clean, clippy 0, 30 targets / 998 passed / 0 failed, docs 0 under
-D warnings, conformance 8/8, zero golden churn, canonical-base digest unmoved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit finding 2, reproduced and fixed. The Propagated attachment met the letter
of req:opcat:transpose-interval-spelling and none of its purpose.
The repro: a C4 the author deliberately spelled "C", sharpened to C#4. Both
attachments present -- the stale UserChosen and the new Propagated -- and
resolve_spelling returns Authored(UserChosen) with accidentals []. The notehead
draws a C natural for a pitch sounding C#. The accidental vanishes. Default
precedence ranks UserChosen and Imported above Propagated, so the attachment
this operation writes is always outranked exactly when it is needed.
Per the ratified call, authored spellings are MOVED, not left and not
discarded. A spelling moves by its NOMINAL, because the nominal is what carries
the author's enharmonic decision: someone who wrote B#3 rather than C4 chose
the letter B, so a perfect fifth up is F##4, not G. The accidental is then
whatever the transposed pitch requires at that staff position -- the chromatic
component never touches the spelling except through the pitch. Source,
priority, and layer are preserved: a transposed UserChosen spelling is still
the user's choice. Imported moves too; import fidelity is a property of the
file on disk, which a transposition does not touch.
An authored spelling that cannot be written at the transposed position refuses
the whole operation (TranspositionOutOfRange), resolved before anything is
written, like every other refusal. The two application passes are ordered so
that every index-addressed rewrite lands before the propagated upsert can push
and shift the indices.
The Propagated attachment keeps its purpose: it is the record for pitches with
no authored spelling, where the pre-pass would otherwise re-infer.
Three mutations verified. One of them, S2, SURVIVED the first version of the
enharmonic test and exposed it as a false lock: I had spelled a C#4 pitch as
"C#", so the authored nominal coincided with the pitch's own, and re-inferring
from the pitch gave the same answer. The test proved nothing about keeping the
author's choice. Rewritten around B#3-sounding-C4, where moving the nominal
gives F##4 and re-inference gives G, it now fails under S2 as it must.
Gate: fmt clean, clippy 0, 30 targets / 990 passed / 0 failed, docs 0 under
-D warnings, conformance 8/8, zero golden churn, catalog rebuilds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit finding 1, reproduced and fixed. Pitch::transposed did its arithmetic in
i32 while the interval's own components are i32, so intermediates overflowed:
diatonic_steps = i32::MAX panicked at `12 * new_octave`, chromatic_steps =
i32::MAX at `semitone + c`. TranspositionInterval::inverse negated i32::MIN.
The comment above the arithmetic even said "widen before arithmetic" -- it
widened i8 to i32, which is exactly not wide enough. All of it now widens to
i64, where the largest intermediate is bounded by ~3.7e9.
Refusing is the contract. Panicking on a value the public type admits is not.
I checked whether this was worse than a panic. The workspace sets
overflow-checks = true in release, but epiphany-core is a library and a
consumer's default release profile has them off, where these expressions wrap.
A 10.5M-case sweep of wrapping-vs-exact arithmetic (175 base pitches x 60225
interval pairs, edges plus random) found ZERO inputs where wrapping produced a
wrong Ok rather than a refusal. So this was a panic, not silent corruption, and
the audit's characterisation was exactly right.
inverse() now returns Option: -i32::MIN is not an i32. An interval whose
inverse cannot be written down is a fact about the type, and a caller composing
undo out of inverses must see it. Both regressions mutation-verified by
restoring the i32 arithmetic and the bare negation.
Also in this commit, two documentation corrections:
- The reducer's test-harness comment claimed both old transpose_* tests "now
reduce ONTO a base and assert the pitch value". They do not, and should not;
DECISIONS.md already said so. The comment now matches.
- The catalog's undo semantics for BOTH transpose kinds claimed value-restoring
undo recovers the pre-transpose pitch from the write chain. Neither kind
records into that chain, so this was false. The frozen Transpose reverts to
its honest pre-Push-4a statement -- undo does not negate it (P11-C8) -- and,
per the freeze doctrine, that is now pinned: making it record would change
what a stored {Transpose, UndoTransaction} history replays to. TransposeInterval's
paragraph states the requirement the next commit implements.
Gate: clippy 0, 30 targets / 985 passed / 0 failed, docs 0 under -D warnings,
conformance 8/8, zero golden churn, catalog rebuilds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two open candidates, staged until a third reopens the pass.
P13-S1: 169 of core_spec's 207 requirement blocks carry no \label, so no
conformance claim can cite them. The audit that surfaced this scoped it to
Chapter 4 (9/9 unlabeled). It is not a Chapter 4 problem: Determinism Contract
is 15/15, Semantic Operations 24/27, The Score Graph 22/28, Pitch 10/13. Every
req:* label this repo cites was added ad hoc by whichever pass needed it.
P13-S2: cmn-24 is in the built-in pitch-space table as "CMN extended with
24-EDO quarter-tone accidentals" and cannot be represented -- Cmn.alteration is
an i8 of whole semitones, and a quarter-tone is half of one. Blocks Push 4b.
Also parked: the Push 4b prerequisites. Push 4a proved transposition needs no
tuning catalog; what the catalog is still for -- resolving a scale position to
a frequency, applying an instrument's transposition at the written/sounding
boundary -- remains, and Chapter 4 is not implementable as written.
Two further claims from the audit are recorded as UNVERIFIED rather than
inherited: that the JI dimension convention conflicts with its own prime-2
requirement, and that the named historical tunings lack exact ratio data.
Neither was needed for 4a; neither was confirmed. An audit finding that has not
been executed is a hypothesis.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An audit reopened P12-K2, whose Pass-12 pin promised the repair would be "a
payload schema-major landing with the Chapter 4 tuning catalog". Both halves
were wrong, and the operation was more broken than the pin admitted.
Measured through EditorSession, not inferred. On a C4: +12 yields alteration
12 (six double-sharps, not C5); +128 clamps to 127 and still reports Applied;
targets [p, p] transposes twice; a non-Cmn position is silently untouched.
transpose(1000) then transpose(-1000) lands on -128, so the operation is not
invertible. Nothing downstream is at fault -- prepass::accidental_ids renders
alteration 12 faithfully. The defect is entirely in what Transpose means.
The false coupling is why this looked big. Pitch has orthogonal scale_position
and acoustic fields. Transposition adds an interval to a scale position;
tuning decides what frequency a scale position sounds at. Adding a fifth to C4
needs no tuning catalog. The same weld had spread: PitchSpaceMismatch was
"Reserved: requires the Chapter 4 tuning catalog" (it reads a discriminant),
and TranspositionInterval was "ADVISORY until the Chapter 4 tuning catalog
pins interval algebra". Push 4 splits: 4a is the algebra and needs no catalog;
4b is the catalog, which has its own blockers (cmn-24 is in the pitch-space
table but cannot exist while Cmn.alteration is i8 semitones).
Ratified by the user:
- New kind, freeze the old. An operation is history; a corrected reduction
rule would rewrite every score that used one. Transpose (disc 9) keeps its
exact semantics, now written as normative replay semantics rather than as
apologies. TransposeInterval takes disc 30. This is cheap: appending a kind
at >= 30 is a schema MINOR, and the payload's constituents are all major-0
layouts, so it stamps major 0. No major 3, no migration.
- Diatonic + chromatic interval, reusing TranspositionInterval -- which
already existed in graph.rs at major 2 for Instrument.transposition, already
codec'd, byte-for-byte the required pair. Minting an Interval beside it
would have been a second normative listing of one type, the drift P13-I1
just closed. Declared once now, in Chapter 2; Chapter 5 references it.
- Atomic refusal. Non-Cmn, AbsoluteHz, or an out-of-range result refuses the
whole operation. Never saturate, never partially apply. Tombstoned and
SYSTEM_DERIVED targets are still skipped: a deleted pitch is not an
untransposable pitch, it is one the operation has nothing to say about.
targets becomes CanonicalSet<PitchId> at the type level, not a Vec plus a
dedup() someone can forget (PitchId's Ord is its canonical byte order). This
was never a convergence bug -- every replica replaying [p, p] double-
transposes identically -- but a canonicalization one. It is free today because
no operation-payload decoder exists yet; once one lands in Push 5, dedup
normalization would change the meaning of stored operations. Push 4a blocks
Push 5, and that is why.
Spec: req:pitch:transposition (algebra + the three refusals), and four
req:opcat:transpose-* requirements. Operation Catalog 0.7.0 -> 0.8.0; Binary
Format 0.6.0 -> 0.7.0 (disc 30, and a seq-strictly-increasing notation whose
decoder must reject a duplicate rather than normalize it away).
This commit is the design gate: the spec now declares MUSTs the code does not
yet satisfy -- editor-core still authors Transpose, and TransposeInterval does
not exist. The implementation follows in this push.
Also recorded: the two existing transpose tests are false locks. Gutting
graph_transpose_pitch leaves both green -- they call base-free reduce(), where
graph is None and the function never runs, and assert only OperationEffect.
Only editor-core's undo_and_redo_a_transpose, three crates away, catches it.
Gate: clippy 0, 30 targets / 964 passed / 0 failed, docs 0 under -D warnings,
conformance 8/8, all three spec documents build with no undefined references.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BRAVURA_METRICS' NOTEHEAD_ANCHORS were hand-derived where every neighbouring
number in the table is machine-extracted from the SHA-pinned font -- the same
mistake as inferring band ownership downstream instead of reading it from the
source that had it. And they were wrong under any reading: they named
stemUpNW/stemDownSE, the corners a normal notehead's stems do not attach to and a
pair Bravura's noteheadBlack does not define, with an x of 1180 that reads as 1.18
staff spaces written in thousandths rather than the table's 1/1024 units (1.18 sp
= 1208). Nothing consumed them.
Deleted rather than corrected. The font is not vendored, so the true values cannot
be verified in-tree, and shipping data we cannot stand behind into a hash every
conformance claim declares is worse than shipping none. Verified empirically
before deciding, not reasoned about: changing the anchors breaks nothing in-tree
-- 30/30 targets, zero golden churn, no pinned literal hash -- so
GlyphCatalogIdentity moves once, now, while no claim declares the old one.
extract_bravura_outlines.py gains --anchors, emitting them from the pinned
bravura_metadata.json (anchors live in the SMuFL metadata, not the glyf bounds,
which is why the outline extraction never covered them). SMuFL anchors are points,
so they round to nearest -- unlike a bbox, which rounds outward so the metric box
contains the ink. The metadata's SHA-256 is deliberately left UNPINNED and
verify() now refuses an unpinned source, printing the digest to paste: the script
cannot regenerate anchors until an operator with the font pins it in a reviewable
commit, which is the discipline the other two sources already have.
The test guarding the anchors proved nothing. anchors_participate_in_the_hash
compared noteheadBlack (anchored) against noteheadWhole (not) and asserted they
hash differently -- but their advance and bbox differ too, so it would have passed
with the anchors ignored entirely. It now varies the anchors while holding every
other field fixed (presence, one coordinate, one name), against synthetic metrics
through a factored-out metrics_hash_of.
Pass-13 Batch 2 CLOSED. All three candidates resolved; two grew when examined.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
to_constrained took the active clef from the staff instance's clef_sequence and
fell back to Clef::default() -- treble. A staff that declared its clef only on the
Staff, with no ClefChange, therefore drew a treble clef and placed every note
against it. The field was decorative in the projection.
It is the fallback. StaffContent now carries default_clef -- the clef belongs to
the Staff, the sequence to the StaffInstance, and resolving "the clef at time t"
needs both -- and active_clef_or(clefs, at, default) resolves against it.
active_clef remains as that with the treble default, for callers with no staff to
hand, so the public API is intact.
The part worth pausing on: epiphany-editor-core reads the same function for
hit-test pitch resolution. Fixing only the projection would have left a click on a
bass staff resolving its pitch as treble -- the engraved clef and the editor
disagreeing about what note is where. Both now go through active_clef_or.
Removal was rejected: the field is named for its purpose, is encoded on the wire,
and dropping it would be schema-major.
Zero golden churn: every fixture and generator declares treble, which is also
Clef::default(), so nothing that exists today moves. Locked by
a_staff_declaring_only_a_default_clef_engraves_in_it and mutation-verified by
restoring the Clef::default() fallback. Scoping that test by provenance was
necessary -- valid_score_rich has three staves and only one was re-clefed, so the
first assertion I wrote ("no gClef anywhere") failed against a correct fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three parked candidates accumulated while the Standard-tier solver track closed
and the notation-quality pass landed, which is the threshold at which the house
rule opens a batch pass. PASS13_CANDIDATES.md reopens as Batch 2 with P13-I1
(this), P13-I2 (Staff::default_clef never consulted), P13-I3 (the notehead stem
anchors).
P13-I1 was filed as "two elided fields". Checking before writing turned up a
third, and it is the one that matters: `diagnostics: Vec<LayoutDiagnostic>`
appears NOWHERE in core_spec, though it is how the projection's honesty rule
manifests. So Chapter 7's listing gains break_origins, diagnostics, and catalog;
BreakOrigin and LayoutDiagnostic gain their shapes.
And it gains req:layoutir:coverage-diagnostics, ratified as implemented: an
object the projection cannot engrave faithfully -- a pitch with no resolved
spelling, a glyph the bound catalog does not carry -- MUST be recorded as a
LayoutDiagnostic AND still placed, as a fallback notehead or a zero-extent traced
anchor. It MUST NOT silently substitute a plausible shape, and MUST NOT drop the
object. Both halves matter: dropping it breaks the round-trip surjection, so a
hit-test can no longer find what the author wrote; guessing produces a score that
looks engraved and is wrong, with nothing in the IR to say so.
Locked by an_unengravable_object_is_recorded_and_still_placed, which drives a
percussion clef (no bundled glyph) and asserts all three halves: the diagnostic
names the object, no glyph stands in for it, and a zero-extent anchor keeps its
provenance addressable.
Spec rebuilds clean, no undefined references, new label registered. No code
change beyond the test; zero golden churn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ENGRAVER_VERSION 11 -> 12. The inter-staff solve now closes a slack pair as well
as opening a crowded one, realizing the InterStaffGap band's declared height
exactly. SYSTEM_STAFF_PITCH is demoted from a floor to an initial arrangement the
solve fully renegotiates. This is what vertical_density_penalty was reporting: an
un-pressured multi-staff system sat at 0.739, honest sprawl against the declared
gap, because the axis is symmetric and the solve only ever expanded.
The band's height had no agreed meaning, so pin it: it is an INK CLEARANCE -- the
separation between the two staves' outermost content, exactly the unit
req:qmc:vertical measures. preferred 2.0 -> 5.0, min 1.0 -> 2.0. The old 2.0 was
a placeholder reconciled with nothing: neither the 8.0 staff-box gap the fixed
pitch of 12 produces, nor the ~6.4 ink clearance it leaves for plain content.
Realizing it would have crushed a relaxed system to a pitch of ~7.6. At 5.0 plain
ledgered content settles near a pitch of 10.6.
Making the solve two-sided immediately exposed a CASCADE DEFECT latent since v11.
The recurrence subtracted the upper staff's shift from the measured gap and then
added it back through the accumulator, so every pair below the first was
over-separated by exactly the shift above it. Both staves move; the relation is
shift_lower = shift_upper + target - (upper_lo - lower_hi), the UNSHIFTED gap.
three_staff_close_content's lower pair realized 21.06 against a declared 4.0. It
was invisible on two-staff fixtures (shift_upper = 0) and invisible to
inter_staff_shifts_cascade_down_three_staves, which asserted only s2 > s1 -- true
under both the correct and the double-counting recurrence.
What caught it was the metric measuring realized clearance back from the BAKED
output instead of the solve's own extents. Reading back solver intent would have
reported 0 and shipped the over-separation again. That design choice was made one
commit earlier for exactly this reason; the catalog rationale now recommends it to
any conforming implementation.
Once the solve realizes each declared clearance exactly, every inter-staff unit is
0 on a healthy solve -- the axis becomes a solver self-check, and its MEAN can no
longer distinguish "measured every realization" from "measured one". So
vertical_raw is split into vertical_units and the regressions assert the unit SET.
Four mutations verified: the double-counting recurrence, expand-only, the
glyph-members band filter, and first-system-only measurement each fail a named test.
No normative change, no version move: QMC formula, units, anchors, thresholds all
untouched; only its non-normative rationale is refreshed. Churn is the two
multi-staff engrave goldens: two_staff grew by exactly 3.0 (the target change, no
cascade); three_staff SHRANK by 9.06 -- the same +3 per pair, less the 17.06 of
over-separation the defect was adding. Single-staff and every stub golden are
byte-stable.
The 5.0 was the user's call. 4.0 ("one staff height") was chosen first and
withdrawn once its true consequence -- pitch 9.57, not the 11.04 an arithmetic slip
of mine had projected -- was measured rather than inferred. The slip: deriving
plain-content ink clearance from an aggregate metric by assuming two contributing
units when it had three.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-up on 43215c7. Both findings were right, and the second falsified a
comment I wrote in that very commit.
1. Region staff bands were still identified by glyph `members`. vertical_raw
measured content over all primitives but decided WHICH staff bands belong to a
region by glyph membership -- reintroducing the assumption the change exists to
shed. A staff band is allowed to own no glyphs: to_constrained emits one per
staff of the region regardless, and a percussion-clef staff (no bundled glyph,
so it engraves to a traced anchor stroke) with no notes owns only staff-line
strokes. Membership now comes from content presence in one of the region's
systems, which identifies the band exactly -- a staff band is per-(staff,
region), so its content can land nowhere else.
2. Only the first realizing system was measured, justified by a comment claiming
rigid system translation makes every realization agree. The inter-staff solve
had just falsified that: it sizes each system's gaps from that system's own
content. req:qmc:vertical now counts ONE UNIT PER REALIZATION, matching how
realized inter-system gaps were already counted. That is a contributing-unit
change, so unlike 43215c7's clarification the catalog moves: QMC 0.2.0 -> 0.3.0
(the P12-I12 precedent). Raw formula, anchor, orientation, thresholds unchanged.
Two new fixtures, because an unexercised fix is what I criticised last round:
percussion_placeholder_staff (a valid, invariant-clean score whose lower band owns
zero glyphs and six strokes) and two_staff_wrapping_pressure (one region, two
systems, staff-line gap 15.93 where it collides and 7.87 where it is slack). Both
mutation-verified: the members filter scores 4.8e-7, first-system-only scores
1.3e-7 -- each ~0 where the corrected axis reports real deviation.
What the per-realization count exposes is not comfortable, and is recorded rather
than smoothed over: two_staff_wrapping_pressure now scores 0.739. Its pressured
system solves to the declared gap exactly; its slack system sits at ~5 staff
spaces against a preferred 2.0. The axis is symmetric -- a gap wider than
preferred is sprawl exactly as a narrower one is crowding -- and this solve only
expands, never compresses. The deferral "compressing an OVER-wide fixed gap toward
preferred ... rarely wanted" is promoted to measurably wrong. Named, not fixed:
compression is a layout change (golden churn, ENGRAVER_VERSION move), not a
measurement one.
Adjacent finding, parked: Staff::default_clef is never consulted -- to_constrained
takes the active clef from the instance's clef_sequence and falls back to
Clef::default() (treble), so a staff declaring its clef only on Staff engraves as
treble. Verified (no layout-ir consumer reads the field). Filed in layout-ir
DECISIONS with the ConstrainedLayoutIR listing gap, pending a >=3-candidate batch.
Measurement-only: no layout change, ENGRAVER_VERSION stays 11, zero golden churn.
Gate green; QMC PDF rebuilds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two items the inter-staff solve deferred turned out to be one thing -- and it was
not the "metric-vs-solver tension" I filed it as. The catalog was right; the
engraver was non-conforming.
req:qmc:vertical has always defined the realized inter-staff gap as the separation
"between the adjacent CONTENT EXTENTS the band separates". vertical_raw measured
the separation between the two bands' glyph `members`, because until primitive
band ownership (efaebb9) a band listed no strokes or curves to own. A staff's
outermost ink is usually not a glyph. On two_staff_close_content the solve cleared
the declared 2.0 gap exactly, while the glyph-ink gap was 5.06 -- so the axis
reported |5.06-2|/2 = 1.53, saturated to 1.0, and fired a Standard-tier floor
warning on a correct layout. The metric was charging the solver for the ledger and
slur ink it had made room for. Axis now reads 2.7e-7; the warning is gone.
Two design calls worth naming:
- The geometry is read back from the BAKED output, not from the solve's own
staff_ext. Reading back solver intent would make the axis circular and blind
to exactly the bug class that bit twice this week; now a shift the bake fails
to apply to some primitive class surfaces as a real deviation. CastLayout
gained stroke_system/curve_system for it -- a stroke carries no spring slot,
so system_of_slot cannot answer for it.
- The solve now targets the preferred_height of the InterStaffGap band
to_constrained emitted for that staff pair, not VerticalBand::inter_staff_gap's
default. That is what makes the band a height model rather than a constant:
solve and metric agree by construction, not by both calling one constructor.
NO version move. Formula, contributing units, anchor, and normalization are
unchanged -- only a wrong measurement was. This is the P12-I11 precedent
(engrave-side resolution), not P12-I12 (which redefined spacing_distortion's unit
and did move the catalog). The catalog gains a clarification of what "content
extent" means, since before band ownership that reading was arguably
unimplementable, which is why the defect survived. Its stale rationale (still
claiming the vertical spring solve is deferred) is refreshed, and the axis's
inter-system half is recorded as a genuine trade-off against page_fill_efficiency
rather than a defect, so it is not re-filed as a bug.
Measurement-only: no layout change, ENGRAVER_VERSION stays 11, zero golden churn.
Mutation-verified: reverting vertical_raw to glyph-only measurement scores 1 and
fails the new assertion. Gate green, QMC PDF rebuilds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ced4b72's ratification log said the ConstrainedLayoutIR listing gap was "tracked
as a Pass-13 candidate", but PASS13_CANDIDATES.md declares that batch CLOSED with
no open candidates -- and the house rule opens a pass only at >=3 candidates. The
claim pointed at a ledger that says the opposite.
Filed where the rule wants it instead: layout-ir DECISIONS records the two fields
the listing still elides (break_origins, catalog), why neither blocks an
implementer (both are governed by requirement text elsewhere), and that it is
PARKED awaiting company rather than reopening a pass on its own. The log now says
that too.
No code, no spec text.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-up on fc411ea. Finding 1 was correct and was mine to own: I wrote
"every primitive -- glyph, stroke, and curve alike -- MUST declare a
VerticalBandId" and placed it in the ResolvedLayoutIR section, where the adjacent
ResolvedGlyph listing has no such field. Under a literal read the implementation
was non-conforming against a requirement I had just added.
The rule belongs to the stage that has the obligation. req:layoutir:primitive-
band-ownership moves to Ch7 §ConstrainedLayoutIR §Vertical Bands and is scoped to
"every primitive the projection presents to the solver". A new companion,
req:layoutir:resolved-band-ownership, says what survives: a resolved Stroke/Curve
MUST retain its band (casting-off and the inter-staff solve both relocate them
and must attribute them), while a ResolvedGlyph carries none -- its ownership is
consumed during the solve and already baked into its resolved position, so the
field would be dead weight in a rendering fingerprint. That requirement also
carries the non-canonical clause: canonical_bytes omits vertical_band even from
the primitives that do retain it.
Finding 2: the ConstrainedLayoutIR listing gains strokes/curves. They have been
in the code since staff lines and were never listed -- so an implementation
following Chapter 7 could not have supplied non-glyph ownership to the solver at
all, which makes this the listing gap that actually blocked the new MUST.
Finding 3: the ratification trail now records the tranche -- a revision-history
row and a PASS12 log section naming both requirements, the three listing
corrections, the two commits, and the fact that adopting it churned no golden
(the declared owner agrees with the inferred one across the corpus, which is what
licensed the swap).
The log also records a pre-existing listing gap I found while checking my own
citations: ConstrainedLayoutIR still elides break_origins and catalog. I had
first written that neither is named anywhere in core_spec, then verified and
found both types ARE specified (req:layoutir:break-origin-attribution; Ch7 §Glyph
Catalog Identity) -- only the struct listing is incomplete, and unlike
strokes/curves neither gap blocks an implementation. Left as a Pass-13 candidate
rather than widened into this tranche.
Spec rebuilds clean: no LaTeX errors, no undefined references, both new labels
registered. Workspace 30/30, conformance 8/8; no Rust changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-up. Both findings were about documentation lagging a contract the
code now enforces, and the first is the one that matters: band ownership is the
solver-facing correctness contract, so it belongs in the spec, not only in a
crate's DECISIONS.md.
Chapter 7's ratified Stroke/Curve listings gain `vertical_band`, and a new
requirement (req:layoutir:primitive-band-ownership) states the rule as a MUST:
every primitive declares its owning band, the projection MUST name a band that
exists, and a vertical solver MUST take ownership from that declaration and MUST
NOT infer it from geometry. The rationale is recorded because the requirement
looks like a convenience until you see why it isn't: a stem shares its column
with the staff above, and a slur's endpoints are lifted clear of its own staff
into the zone where the nearest notehead belongs to the neighbour. A solver that
guesses from proximity tears such primitives off their notes when it renegotiates
the staff gaps -- which is exactly what happened, twice. The requirement also
records that only glyphs are band *members*, and that the reference is
non-canonical attribution metadata.
Stroke's stale "the vertical-band model does not contain" gloss is dropped.
canonical_bytes' doc claimed to encode "the full resolved layout". It encodes the
rendering fingerprint and deliberately excludes attribution metadata. Stating
that precisely required checking rather than paraphrasing: ResolvedGlyph drops
its band on the way out of the constrained stage, while Stroke and Curve -- whose
types are shared with that stage -- carry vertical_band through but never encode
it. Two layouts differing only in band ownership draw identically and hash alike.
Spec rebuilds clean: no LaTeX errors, no undefined references, new label
registered. Gate green, no golden churn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
delete_event tombstoned the event in objects AFTER materialize_graph_delete —
but that graph pass cascades a cue among the event's referents, running
reanchor_for_tombstone over the cue's referents while the source event is still
Live. A slur bridging {X, cue-of-X} therefore re-anchored onto X
(Reanchored{to: X}) and then cascade-deleted when X's tombstone landed a line
later: a contradictory same-effect trail (candidate was "plausible by code
trace, unexecuted" — now executed; reverting the fix reproduces exactly that
two-record trail).
Fix: tombstone the event in objects BEFORE the graph delete, matching the
conventions cascade_cue and tombstone_undo_targets already follow (both
tombstone before their graph delete — which is why the undo path never had this
bug). The bridging slur now sees X already dead during the cue cascade and
cascades once.
Regression: deleting_a_cue_source_does_not_leave_a_contradictory_repair_for_a_
bridging_slur_p13_d2 (built on a cue-bearing base with a bridging slur; asserts
exactly one CascadeDeleted, no Reanchored). Verified to fail without the fix.
940 tests, convergence/conformance green.
PASS13_CANDIDATES.md: P13-D2 resolved — batch CLOSED (all four candidates done).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>