From 06cf8cc1b6dfe9ffd70d1d3f2bd494523a21ea6f Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:59:33 +0000 Subject: [PATCH 1/2] weave_to_episode: refuse a skeleton member missing from the graph instead of persisting a shifted timeline (#98) The persisted timeline was built over the members found in the graph while ordered_member_ids kept the full list; consumers pair them by position, so one missing member shifted every later one. execute now raises, sharing one helper with the reconstruction path. Co-Authored-By: Claude Sonnet 5.5 --- braidio/transforms/_episode.py | 40 +++++++++---- tests/test_transforms_episode_members.py | 75 ++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 tests/test_transforms_episode_members.py diff --git a/braidio/transforms/_episode.py b/braidio/transforms/_episode.py index 94ab8aa9..e130f198 100644 --- a/braidio/transforms/_episode.py +++ b/braidio/transforms/_episode.py @@ -285,14 +285,12 @@ def episode_timeline(project, episode: Annotation) -> TimelineBreakdown: structure, _bed = _structure_and_bed( next((p for p in parents if p.tier == TIER_PRODUCTION_STRUCTURE), None) ) - member_ids = [uuid.UUID(s) for s in episode.body.get("ordered_member_ids", ())] - members = [index[mid] for mid in member_ids if mid in index] - if len(members) != len(member_ids): - missing = [str(m) for m in member_ids if m not in index] - raise ValueError( - f"episode {episode.id}: cannot reconstruct its timeline — members " - f"{missing} are no longer in the graph. Re-weave the project instead." - ) + members = _resolve_members( + episode, + index, + what="cannot reconstruct its timeline", + fix="Re-weave the project instead.", + ) durations = [ _member_duration(project, m, config=config, structure=structure) for m in members @@ -541,6 +539,24 @@ def _clip_parents(members, index): yield require_tier(parents, TIER_AUDIO_CLIP) +def _resolve_members(episode: Annotation, index: dict, *, what: str, fix: str) -> list: + """The members named by ``episode.body["ordered_member_ids"]``, in order. + + Raises if any is absent from ``index``. A timeline is paired to its members + by POSITION (``member_spans``, ``episode_script`` + ``captions.cues_for``, + the picture track), so a member silently skipped would shift every member + after it (braidio#98). ``what`` / ``fix`` word the error for the caller. + """ + member_ids = [uuid.UUID(s) for s in episode.body.get("ordered_member_ids", ())] + missing = [str(m) for m in member_ids if m not in index] + if missing: + raise ValueError( + f"episode {episode.id}: {what} — members {missing} are no longer in the " + f"graph. {fix}" + ) + return [index[m] for m in member_ids] + + @register_transform(NAME) class WeaveToEpisode(BaseTransform): """All members — renders and scene breaks — (+ weave-config, + the @@ -619,8 +635,12 @@ def execute( cache_hit_savings_usd=0.0, ) index = graph_index(project) - member_ids = [uuid.UUID(s) for s in skel.body.get("ordered_member_ids", ())] - members = [index[mid] for mid in member_ids if mid in index] + members = _resolve_members( + skel, + index, + what="cannot render", + fix="Re-run the weave so the skeleton is planned over the current graph.", + ) parents = resolve_parents(skel, index) cfg = require_tier(parents, TIER_WEAVE_CONFIG) diff --git a/tests/test_transforms_episode_members.py b/tests/test_transforms_episode_members.py new file mode 100644 index 00000000..04a60308 --- /dev/null +++ b/tests/test_transforms_episode_members.py @@ -0,0 +1,75 @@ +"""``weave_to_episode.execute`` must not persist a member list its timeline does not match. + +The persisted ``timeline`` is built over the members that are *in the graph*, +but ``ordered_member_ids`` names the whole skeleton list. ``member_spans``, +``episode_script`` + ``captions.cues_for`` and the picture track all pair a +member to a timeline beat by POSITION, so one missing member silently shifts +every member after it (braidio#98). The reconstruction path +(``episode_timeline``) already refuses that state; ``execute`` must too. +""" + +from __future__ import annotations + +import uuid +from pathlib import Path + +import pytest + +import braidio + +from _audio import FixedSource, needs_ffmpeg, tone + +pytestmark = [ + pytest.mark.skipif(not braidio.HAS_NW, reason="nw (and lacing) not available"), + needs_ffmpeg, +] + + +@pytest.fixture +def woven(tmp_path, monkeypatch): + """A woven project: ``(project, episode)`` over three narration beats.""" + + def fake_narrate(text, out, *, return_cache_status=False, **kw): + p = tone(Path(out), 220, 1.0) + return (p, False) if return_cache_status else p + + monkeypatch.setattr(braidio, "narrate", fake_narrate) + project = braidio.Project.init(tmp_path / "proj", title="members") + source = FixedSource(tone(tmp_path / "song.wav", 440, 8.0), start_s=1.0, end_s=3.0) + script = braidio.Script( + title="M", + id_slug="m", + beats=[braidio.Narration(text=t) for t in ("one", "two", "three")], + ) + return project, braidio.weave_project(project, script, source=source) + + +def _execute(project, skeleton): + from falaw import Plan + + from braidio.transforms._episode import WeaveToEpisode + + return WeaveToEpisode().execute( + project, Plan(calls=()), (skeleton,), use_cache=False + ) + + +def test_a_member_missing_at_execute_time_is_refused_not_persisted(woven): + project, episode = woven + ids = list(episode.body["ordered_member_ids"]) + ghost = str(uuid.uuid4()) + skeleton = episode.model_copy( + update={ + "id": uuid.uuid4(), + "body": {**episode.body, "ordered_member_ids": [ids[0], ghost, *ids[1:]]}, + } + ) + with pytest.raises(ValueError, match=ghost): + _execute(project, skeleton) + + +def test_a_complete_member_list_keeps_ids_and_timeline_in_step(woven): + project, episode = woven + skeleton = episode.model_copy(update={"id": uuid.uuid4()}) + (done,) = _execute(project, skeleton).annotations + assert len(done.body["timeline"]["beats"]) == len(done.body["ordered_member_ids"]) From a0b4f7810edfa4e03db85a1a3e98397f63134f30 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:03:52 +0000 Subject: [PATCH 2/2] Test the episode_timeline refusal and that a refused execute writes nothing (#98) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_transforms_episode_members.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_transforms_episode_members.py b/tests/test_transforms_episode_members.py index 04a60308..ce215e58 100644 --- a/tests/test_transforms_episode_members.py +++ b/tests/test_transforms_episode_members.py @@ -54,6 +54,12 @@ def _execute(project, skeleton): ) +def _node_count(project) -> int: + import nw + + return sum(1 for _ in nw.iter_all_annotations(project.root)) + + def test_a_member_missing_at_execute_time_is_refused_not_persisted(woven): project, episode = woven ids = list(episode.body["ordered_member_ids"]) @@ -64,8 +70,10 @@ def test_a_member_missing_at_execute_time_is_refused_not_persisted(woven): "body": {**episode.body, "ordered_member_ids": [ids[0], ghost, *ids[1:]]}, } ) + before = _node_count(project) with pytest.raises(ValueError, match=ghost): _execute(project, skeleton) + assert _node_count(project) == before # nothing was persisted def test_a_complete_member_list_keeps_ids_and_timeline_in_step(woven): @@ -73,3 +81,15 @@ def test_a_complete_member_list_keeps_ids_and_timeline_in_step(woven): skeleton = episode.model_copy(update={"id": uuid.uuid4()}) (done,) = _execute(project, skeleton).annotations assert len(done.body["timeline"]["beats"]) == len(done.body["ordered_member_ids"]) + + +def test_reconstructing_a_timeline_refuses_a_missing_member_too(woven): + from braidio.transforms import episode_timeline + + project, episode = woven + ids = list(episode.body["ordered_member_ids"]) + ghost = str(uuid.uuid4()) + body = {k: v for k, v in episode.body.items() if k != "timeline"} + body["ordered_member_ids"] = [ids[0], ghost, *ids[1:]] + with pytest.raises(ValueError, match=ghost): + episode_timeline(project, episode.model_copy(update={"body": body}))