Repository navigation
Evidence layer: persist analysis into a lacing store (#4) - #23
Merged
Merged
Conversation
The committed StepDocument is the contract; the machine evidence behind it — the speech/music split, the metric grid, the beats, the transcript, the step candidates, the derivation recipes and the lineage between them — now lives in a lacing store, and the document is a projection of it (docs/07 §6.0). - paces/bodies/: eight body schemas, every URI paces owns prefixed `guide-`. register_body_schema is last-write-wins and silent on a flat, fleet-wide namespace, and annot://schema/beat/v1 is already reelee's narrative beat — so one rule rather than a per-name audit. word/v1 is lacing's and reused. - paces/evidence.py: to_store / from_store / recipes_from_store. Annotation ids are uuid5 over a stable evidence key, so docs/07 §6.4 steps 1-3 hold by construction and Origin.annotationId is reproducible; an unchanged value digest leaves the row completely alone; a row a re-run no longer produces is pruned rather than left to be resurrected. - Times cross as RationalTime ticks at an injectable rate=, raising rather than rounding. Intervals live on the reference, never in a body. - locks, questions, artifacts and span excerpt windows are document-layer and never flow into the store — §6.0's one-way rule. - [lacing] extra, mirrored into [dev]. `import paces` still imports only pydantic; paces.to_store resolves through a module __getattr__. - URI set and each JSON Schema pinned against a committed snapshot. Claude-Session: https://claude.ai/code/session_01UDdKG2wFtDzsY2fgBGEjVK
1. Cross-guide pruning. `source.pass` / `beat` / `transcript.word` bodies carry no doc_id (they describe the asset, and two guides over one video share one transcript), so the old `doc_id not in (None, doc_id)` filter matched them and one guide's re-run deleted evidence a sibling guide's was_derived_from pointed at. Tiers are now split into ASSET_SCOPED_TIERS and DOC_SCOPED_TIERS; only doc-scoped rows are prunable, on a strict doc_id match, and a test asserts the two sets partition every tier written so a new tier must be classified. 2. Stale lineage behind the digest gate. annotation_value_digest excludes provenance, so a row whose value held while its upstream moved was skipped and kept citing the previous run's inputs — re-measuring a pass left the grid and its steps pointing at the old one. Provenance is now compared on every digest-gated skip and the row is rewritten when lineage differs, keeping generated_at_time so freshness still does not fire on an unchanged value. Counted separately as StoreWrite.relinked, so idempotence stays a clean zero. 3. README reformatted by CI. wads runs an unpinned `uvx ruff format .`, ruff >=0.16 formats Python fences in Markdown, and the publish job commits the result — which would have broken the README-drift assertion on main. `*.md` is excluded in [tool.ruff], and the assertions are on format-stable fragments. Also from the review: StoreWrite.document now normalises a caller-supplied cue at_s through the tick round trip, so "byte for byte" is literal rather than approximate; the lineage test says what it actually proves (edges resolve, not nw-verified-fresh — #24); the concurrency and doc_id-rename caveats are documented on to_store; the README example defines the names it uses; and docs/07 §6 records that a time with no finite decimal form is refused at every rate, not only at 24000. Claude-Session: https://claude.ai/code/session_01UDdKG2wFtDzsY2fgBGEjVK
ASSET_SCOPED_TIERS are never pruned, which is what keeps one guide from deleting evidence a sibling's was_derived_from cites. The cost is that superseded passes/beats/words never retire, so a consumer reading one of those tiers directly sees every generation superimposed. from_store does not read them, so the projection is unaffected — but the gap should be written down where someone will meet it rather than discovered later. Refs #25, filed with the three options (GC verb, run marker, or a ruling that these tiers are append-only by design). Claude-Session: https://claude.ai/code/session_01UDdKG2wFtDzsY2fgBGEjVK
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4.
The two-layer design of
docs/07 §6.0, built: the committedStepDocumentstays the contract renderers read, and the machine evidence behind it — the speech/music split, the metric grid, the beats, the transcript, the step candidates, the derivation recipes, and the lineage between them — now lives in alacingstore, with the document as its projection.What ships
paces/bodies/— eight body schemas (guide-doc,guide-source,guide-pass,guide-step,guide-cue,guide-grid,guide-beat,guide-recipe) plus lacing'sword/v1, reused unchanged.paces/evidence.py—to_store/from_store/recipes_from_store/register_tiers, behind the[lacing]extra.tests/test_evidence.py(34 tests) andtests/test_evidence_schemas.py— offline, dict-backedMemoryStorethroughout, never a file and never the user data dir.docs/07-annotation-model.md§6.3–§6.5 and §7 updated; README gains an "The evidence layer" section whose example is executed by a test that also fails if the README drifts from it.The decisions, and why
Recorded in full on #4's decision comment before any code was written. The load-bearing ones:
1.
guide-prefixed URIs, not §6.3's bare names — the one deviation from the design doc.lacing.schema.register_body_schemais last-write-wins and silent, on a namespace that is one flat, fleet-wide dict populated by import side effects. §6.3's proposedannot://schema/beat/v1is alreadyreelee/bodies/beat.py's narrative beat (beat_id,characters,semantic_tag) — paces claiming it would have silently replaced reelee's validator in any process importing both.braidiohad already hit this and named its ownnarrative-beat/v1.media-pass/v1andderivation-recipe/v1are free, and get the prefix anyway so there is one rule rather than a list of exceptions to re-audit. Tier names are unprefixed — tiers are store-local. The URI set and each schema's JSON Schema are pinned againsttests/data/body_schemas.json, so a rename or a field change fails the build with a message saying to bump to/v2rather than re-snapshot.2. Annotation ids are
uuid5over a stable evidence key, notuuid4+ a procedural adoption pass.reelee.edits._adopt_output_identityre-imposes a superseded id after the fact; deriving the id from(tier, evidence_key)— the span addresspaces/derivation.pyalready uses — makes §6.4 steps 1–3 hold by construction, and makesto_storereproducible from a fresh store. This also settles a contradiction in the design doc: §6.5 said "no uuid4s in the committed document" while §6.1 definedOrigin.annotation_idas a store UUID. The rule's reason is churn; a uuid5 over a stable key never churns, soOrigin.annotationId/Origin.valueDigestare committed and §6.5's wording is fixed in the same pass.3. Digest-gated idempotence, with lineage as its one exception. An unchanged
annotation_value_digestleaves the row completely alone — provenance included — sogenerated_at_timedoes not churn and freshness does not fire. A secondto_storeof the same input writes zero annotations (tested).4. Re-derivation, not accumulation — for the guide's own rows only. A doc-scoped row a re-run no longer produces is removed; otherwise
from_storewould resurrect a step nobody analysed.ASSET_SCOPED_TIERS(source.pass,beat,transcript.word) describe the asset, are shared by every guide over it, and are never pruned — deleting one out from under a sibling guide'swas_derived_fromwould leave that guide pointing at nothing. A re-measure adds a row under a new content-derived key and leaves the old one standing.prune=Falseopts out of the rest.5.
beatis written only from measured times; the grid is one annotation. paces' grid is closed form (tempo_bpm+origin+subdivisions); materialising N beats from it would be a denormalised cache with no invalidation story — §7's own argument against materialising Allen relations.beats=writes real measured times, each with awas_derived_fromedge to the grid.6. Optional
SourceSpan.idis declined.docs/07 §7pencilled it into this issue on the grounds that "store-side identity adoption wants ids anyway". It does not: store-side identity is the span address, whichvalidate_documentalready refuses to let collide. It stays a #12 document-layer fix for the case it was actually diagnosed for.docs/07 §7's row is updated to say so.7. Lineage is checked even when the value digest says "skip".
annotation_value_digestexcludes provenance by design, so a row can keep its exact value while its upstream moves underneath it. Provenance is compared on every digest-gated skip and the row rewritten when it differs, keepinggenerated_at_timeso freshness still does not fire on an unchanged value. Counted asStoreWrite.relinked, separate fromupdated, so idempotence stays a clean zero.Constraints held
RationalTime, never floats. Every span crosses as ticks at an injectablerate=(defaultlacing.DEFAULT_RATE);from_secondsraises rather than rounding, so a document finer than the rate can hold is a loud error withrate=as the answer. A test asserts nofloatreaches any body outside aconfidencefield, and another asserts every interval endpoint is integer ticks.Annotation.reference; no body carries a time. Bodies arefrozen+extra="forbid".grid ← passes + asset,step ← grid + the pass that frames it + asset,step.sub ← its parent step,beat ← grid,cue ← its anchor step,derivation ← its step + asset— tested, sonw.freshness-style walks work. A non-64-hexasset_idis usable and simply contributes no edge, rather than raising.MemoryStore()in tests,SqliteStorefor a project sidecar, anydolmapping that conforms.register_tiersdegrades gracefully on a store that does not keep tier metadata (the Protocol makes it optional).import pacesimports only pydantic — asserted by a subprocess test.paces.to_storeresolves through a module__getattr__(PEP 562) and, without the extra, fails with the install line.[lacing]extra, floor>=0.0.40(that release made wire-form time validation raiseValidationErrorandfrom_wirerefuse unknown keys — both of which this round trip relies on to fail loudly rather than silently normalise). Mirrored into[dev]so the tests run rather than skip, and added to[tool.wads.licence] include-extras.Not in scope, deliberately
project.annotvs a deploy-managed root) is a storage decision this issue does not settle; the surface needs no core change and is noted as a follow-up in.claude/CLAUDE.md.paces/edits.pyis updated to say the back-references it wanted now exist.Breaking changes
None. Everything here is additive: a new optional extra, a new module, new URIs (additive by lacing's own rule, no migration needed), and new lazily-resolved names on
paces. Two docstring/comment corrections inpaces/model.pyandpaces/edits.pypoint at the layer that now exists. No existing signature, schema, or on-disk format changed.Review round 1 — three blocking findings, all fixed (
29b83f4)Thanks — two were real data-loss/correctness bugs my own tests did not reach.
Cross-guide pruning. Correct and my mistake:
source.pass/beat/transcript.wordbodies carry nodoc_id, so the oldbody.get("doc_id") not in (None, doc_id)filter matched them and one guide's re-run deleted evidence a sibling'swas_derived_fromcited. Fixed by taking the reviewer's second option — those tiers are declared asset-level (ASSET_SCOPED_TIERS) and exempt from a per-guide prune, and thedoc_idtest is now a strict match so a row without one is never pruned. Rather than duplicating one transcript per guide, the sharing is kept and the authority narrowed. A test asserts the two scopes partition every tier written, so a new tier has to be classified rather than defaulting into the prunable set; a second asserts the body-field invariant the split rests on. The reviewer's exact scenario now reportsremoved=0with no dangling edges and guide A's projection byte-identical.Stale lineage behind the digest gate. Also correct, and the sharper version of the bug: with (1) fixed the old pass row survives, so nothing dangles — but the grid and its steps still cited the previous run's pass, which is the one question the edge exists to answer. Provenance is now compared on every digest-gated skip (
was_generated_by,was_attributed_to,was_derived_from,activity) and the row rewritten when it differs, withgenerated_at_timepreserved exactly as suggested. Counted asStoreWrite.relinkedrather than folded intoupdated, sowritten/updated == 0/0stays the honest idempotence signal. The 400→401 scenario now relinks 3 rows: grid and both steps cite the new pass, neither cites the old, timestamp unchanged, value digest unchanged.README reformatted by CI. Verified:
uvx ruff format .is exactly whati2mint/wads'suv-ci.ymlruns (unpinned, and the publish job commits the result), and ruff 0.16 — my local version too — does reflow the fence. My earlier "ruff clean" wasruff format --check paces tests, which never looked at Markdown; that was the actual gap.*.mdis now excluded in[tool.ruff]with the reason in a comment, and the README-drift assertions moved to format-stable fragments as a second line of defence.uvx ruff format .now reports the tree unchanged.Nits, all taken:
StoreWrite.documentnow normalises a caller-supplied cueat_sthrough the tick round trip, so "byte for byte" is literal. (at_swas the only caller-supplied time reaching the returned document; spans arrive canonical fromto_document.)to_store's docstring: index snapshotted at entry, prune reads-then-removes, no lock, serialise or use separate stores.test_lineage_edges_resolve, docstring now separates "the graph is connected and no edge dangles" from nw-verified-fresh, and points at Freshness: decide who writes verifying-trace rows for paces' evidence #24, filed for theverifying-tracequestion.doc_id-rename caveat is documented onto_store(it writes a second guide;AmbiguousDocumentis then the correct answer). README example definesgridandasset_sha256.docs/07 §6.3now records that a time with no finite decimal form is refused at every rate, since the boundary is decimal strings, and that raisingrate=does not help.272 passed, 1 skipped(the skip is pre-existing and unrelated).uvx ruff format .leaves the tree unchanged anduvx ruff check paces— CI's exact commands — are clean.https://claude.ai/code/session_01UDdKG2wFtDzsY2fgBGEjVK