Skip to content

Evidence layer: persist analysis into a lacing store (#4) - #23

Merged
thorwhalen merged 3 commits into
mainfrom
worktree-vg-4
Sep 9, 2026
Merged

thorwhalen merged 3 commits into
mainfrom
worktree-vg-4

Conversation

@thorwhalen

@thorwhalen thorwhalen commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #4.

The two-layer design of docs/07 §6.0, built: the committed StepDocument stays 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 a lacing store, with the document as its projection.

from lacing import MemoryStore
from paces import segment, from_store, to_store
from paces.model import dumps_document

write = to_store(seg, store=MemoryStore(), asset_id=sha256, doc_id="que-calor", ...)
dumps_document(from_store(store, asset_id=sha256)) == dumps_document(write.document)  # True
to_store(seg, store=store, ...).written                                               # 0

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's word/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) and tests/test_evidence_schemas.py — offline, dict-backed MemoryStore throughout, 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_schema is last-write-wins and silent, on a namespace that is one flat, fleet-wide dict populated by import side effects. §6.3's proposed annot://schema/beat/v1 is already reelee/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. braidio had already hit this and named its own narrative-beat/v1. media-pass/v1 and derivation-recipe/v1 are 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 against tests/data/body_schemas.json, so a rename or a field change fails the build with a message saying to bump to /v2 rather than re-snapshot.

2. Annotation ids are uuid5 over a stable evidence key, not uuid4 + a procedural adoption pass. reelee.edits._adopt_output_identity re-imposes a superseded id after the fact; deriving the id from (tier, evidence_key) — the span address paces/derivation.py already uses — makes §6.4 steps 1–3 hold by construction, and makes to_store reproducible 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 defined Origin.annotation_id as a store UUID. The rule's reason is churn; a uuid5 over a stable key never churns, so Origin.annotationId / Origin.valueDigest are 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_digest leaves the row completely alone — provenance included — so generated_at_time does not churn and freshness does not fire. A second to_store of 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_store would 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's was_derived_from would 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=False opts out of the rest.

5. beat is 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 a was_derived_from edge to the grid.

6. Optional SourceSpan.id is declined. docs/07 §7 pencilled 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, which validate_document already 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_digest excludes 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, keeping generated_at_time so freshness still does not fire on an unchanged value. Counted as StoreWrite.relinked, separate from updated, so idempotence stays a clean zero.

Constraints held

  • Time is RationalTime, never floats. Every span crosses as ticks at an injectable rate= (default lacing.DEFAULT_RATE); from_seconds raises rather than rounding, so a document finer than the rate can hold is a loud error with rate= as the answer. A test asserts no float reaches any body outside a confidence field, and another asserts every interval endpoint is integer ticks.
  • Standoff, always. Intervals live on Annotation.reference; no body carries a time. Bodies are frozen + extra="forbid".
  • Provenance carries lineage. 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, so nw.freshness-style walks work. A non-64-hex asset_id is usable and simply contributes no edge, rather than raising.
  • The store is injected, never constructed. MemoryStore() in tests, SqliteStore for a project sidecar, any dol mapping that conforms. register_tiers degrades gracefully on a store that does not keep tier metadata (the Protocol makes it optional).
  • Heavy deps stay lazy. import paces imports only pydantic — asserted by a subprocess test. paces.to_store resolves 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 raise ValidationError and from_wire refuse 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

  • No CLI/MCP subcommand. The store's on-disk shape (project.annot vs 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.
  • Document-side merge of regenerated evidence stays with the edit-protection work (Edits polish: residuals from PR #11's adversarial review #12); the note in paces/edits.py is 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 in paces/model.py and paces/edits.py point 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.

  1. Cross-guide pruning. Correct and my mistake: source.pass / beat / transcript.word bodies carry no doc_id, so the old body.get("doc_id") not in (None, doc_id) filter matched them and one guide's re-run deleted evidence a sibling's was_derived_from cited. 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 the doc_id test 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 reports removed=0 with no dangling edges and guide A's projection byte-identical.

  2. 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, with generated_at_time preserved exactly as suggested. Counted as StoreWrite.relinked rather than folded into updated, so written/updated == 0/0 stays 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.

  3. README reformatted by CI. Verified: uvx ruff format . is exactly what i2mint/wads's uv-ci.yml runs (unpinned, and the publish job commits the result), and ruff 0.16 — my local version too — does reflow the fence. My earlier "ruff clean" was ruff format --check paces tests, which never looked at Markdown; that was the actual gap. *.md is 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:

  • 4 — StoreWrite.document now normalises a caller-supplied cue at_s through the tick round trip, so "byte for byte" is literal. (at_s was the only caller-supplied time reaching the returned document; spans arrive canonical from to_document.)
  • 5 — concurrency stated in to_store's docstring: index snapshotted at entry, prune reads-then-removes, no lock, serialise or use separate stores.
  • 6 — renamed to 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 the verifying-trace question.
  • 7 — the doc_id-rename caveat is documented on to_store (it writes a second guide; AmbiguousDocument is then the correct answer). README example defines grid and asset_sha256.
  • Non-blocking note — docs/07 §6.3 now records that a time with no finite decimal form is refused at every rate, since the boundary is decimal strings, and that raising rate= does not help.

272 passed, 1 skipped (the skip is pre-existing and unrelated). uvx ruff format . leaves the tree unchanged and uvx ruff check paces — CI's exact commands — are clean.

https://claude.ai/code/session_01UDdKG2wFtDzsY2fgBGEjVK

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
@thorwhalen
thorwhalen merged commit 8f431b2 into main Sep 9, 2026
12 checks passed
@thorwhalen
thorwhalen deleted the worktree-vg-4 branch September 22, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Evidence layer: persist analysis into a lacing store

1 participant