paces 0.0.9__tar.gz → 0.0.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {paces-0.0.9 → paces-0.0.10}/PKG-INFO +1 -1
  2. {paces-0.0.9 → paces-0.0.10}/paces/edits.py +104 -1
  3. {paces-0.0.9 → paces-0.0.10}/paces/model.py +86 -2
  4. {paces-0.0.9 → paces-0.0.10}/pyproject.toml +1 -1
  5. paces-0.0.10/tests/test_edit_protection_residuals.py +254 -0
  6. {paces-0.0.9 → paces-0.0.10}/.gitignore +0 -0
  7. {paces-0.0.9 → paces-0.0.10}/LICENSE +0 -0
  8. {paces-0.0.9 → paces-0.0.10}/README.md +0 -0
  9. {paces-0.0.9 → paces-0.0.10}/docs/README.md +0 -0
  10. {paces-0.0.9 → paces-0.0.10}/docs/alignment/README.md +0 -0
  11. {paces-0.0.9 → paces-0.0.10}/docs/poc-reference/README.md +0 -0
  12. {paces-0.0.9 → paces-0.0.10}/docs/poc-reference/artifacts/clips.json +0 -0
  13. {paces-0.0.9 → paces-0.0.10}/paces/__init__.py +0 -0
  14. {paces-0.0.9 → paces-0.0.10}/paces/__main__.py +0 -0
  15. {paces-0.0.9 → paces-0.0.10}/paces/derivation.py +0 -0
  16. {paces-0.0.9 → paces-0.0.10}/paces/excerpts.py +0 -0
  17. {paces-0.0.9 → paces-0.0.10}/paces/measure.py +0 -0
  18. {paces-0.0.9 → paces-0.0.10}/paces/pose.py +0 -0
  19. {paces-0.0.9 → paces-0.0.10}/paces/projection.py +0 -0
  20. {paces-0.0.9 → paces-0.0.10}/paces/render.py +0 -0
  21. {paces-0.0.9 → paces-0.0.10}/paces/segmenters.py +0 -0
  22. {paces-0.0.9 → paces-0.0.10}/paces/tools.py +0 -0
  23. {paces-0.0.9 → paces-0.0.10}/tests/audio_synth.py +0 -0
  24. {paces-0.0.9 → paces-0.0.10}/tests/data/routine.json +0 -0
  25. {paces-0.0.9 → paces-0.0.10}/tests/poc_fixture.py +0 -0
  26. {paces-0.0.9 → paces-0.0.10}/tests/test_chapters.py +0 -0
  27. {paces-0.0.9 → paces-0.0.10}/tests/test_cli_surface.py +0 -0
  28. {paces-0.0.9 → paces-0.0.10}/tests/test_derivation.py +0 -0
  29. {paces-0.0.9 → paces-0.0.10}/tests/test_edits.py +0 -0
  30. {paces-0.0.9 → paces-0.0.10}/tests/test_excerpts.py +0 -0
  31. {paces-0.0.9 → paces-0.0.10}/tests/test_measure.py +0 -0
  32. {paces-0.0.9 → paces-0.0.10}/tests/test_pose.py +0 -0
  33. {paces-0.0.9 → paces-0.0.10}/tests/test_render_media.py +0 -0
  34. {paces-0.0.9 → paces-0.0.10}/tests/test_roundtrip_poc.py +0 -0
  35. {paces-0.0.9 → paces-0.0.10}/tests/test_segment.py +0 -0
  36. {paces-0.0.9 → paces-0.0.10}/tests/test_smoke.py +0 -0
  37. {paces-0.0.9 → paces-0.0.10}/tests/test_vertical_slice.py +0 -0
  38. {paces-0.0.9 → paces-0.0.10}/tests/video_synth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paces
3
- Version: 0.0.9
3
+ Version: 0.0.10
4
4
  Summary: Turn instructional media into structured, interactive learning material
5
5
  Project-URL: Homepage, https://github.com/thorwhalen/paces
6
6
  Project-URL: Repository, https://github.com/thorwhalen/paces
@@ -34,6 +34,16 @@ Path rules (each earned by an adversarial review, PR #11):
34
34
  edit does not survive; only its lock record does.
35
35
  - ``attrs`` bags merge committed-over-fresh per key: they are user/renderer
36
36
  data that analysis does not produce, so regeneration never wins there.
37
+ - Renaming a list item's ``id`` onto an id already used by another item in
38
+ the same list is refused at edit time (previously accepted, and only
39
+ flagged later by :func:`~paces.model.validate_document`) — issue #12.
40
+ - A step rename (its own ``/id`` lock) is remembered by :func:`merge_regenerated`:
41
+ a fresh projection that still emits the pre-rename id (because analysis
42
+ does not know about the rename) is matched to the renamed committed step
43
+ instead of being treated as a new, unrelated step — otherwise the old id
44
+ resurfaces alongside the renamed one. This does not follow renames inside
45
+ a merge across multiple regenerations; only the most recent ``/id`` lock is
46
+ consulted (issue #12).
37
47
 
38
48
  Not yet recorded anywhere: the fresh values a merge *rejects*
39
49
  (``Origin.value_digest`` and the op-log arrive with the evidence layer,
@@ -129,6 +139,36 @@ def _resolve_parent(root: Any, segments: list[str], *, path: str):
129
139
  raise ValueError(f"{path}: cannot set into {type(node).__name__}")
130
140
 
131
141
 
142
+ def _all_steps(steps: list) -> list:
143
+ """Every step in the tree, depth-first — matching the scope of
144
+ :func:`~paces.model.validate_document`'s step-id uniqueness check, which
145
+ is document-wide, not per-sibling-list."""
146
+ out = []
147
+ for step in steps:
148
+ if isinstance(step, Mapping):
149
+ out.append(step)
150
+ out.extend(_all_steps(step.get("steps", [])))
151
+ return out
152
+
153
+
154
+ def _containing_list(dump: dict, segments: list[str]) -> list | None:
155
+ """The list holding the item whose own field the leaf segment addresses
156
+ (e.g. for ``/steps/a/id``, the ``steps`` list a and its siblings live in),
157
+ or ``None`` when the leaf isn't a field on a list item."""
158
+ if len(segments) < 2:
159
+ return None
160
+ node: Any = dump
161
+ for i, segment in enumerate(segments[:-2]):
162
+ at = "/" + "/".join(segments[: i + 1])
163
+ if isinstance(node, list):
164
+ node = node[_index_of(node, segment, at=at)]
165
+ elif isinstance(node, Mapping):
166
+ node = node[_key_of(node, segment, at=at)]
167
+ else:
168
+ return None
169
+ return node if isinstance(node, list) else None
170
+
171
+
132
172
  def _canonical_segments(root: Any, segments: list[str], *, path: str) -> list[str]:
133
173
  """The stablest spelling of a path: snake_case fields; list items by id
134
174
  when one exists unambiguously in that list, by index otherwise."""
@@ -214,6 +254,33 @@ def apply_edits(
214
254
  raw_path = edit.get("path", "")
215
255
  segments = _canonical_segments(dump, _split_path(raw_path), path=raw_path)
216
256
  container, key = _resolve_parent(dump, segments, path=raw_path)
257
+ if key == "id" and isinstance(container, Mapping):
258
+ new_id = edit["value"]
259
+ all_steps = _all_steps(dump.get("steps", []))
260
+ is_step = any(s is container for s in all_steps)
261
+ # A step's id is unique document-wide (validate_document enforces
262
+ # this across the whole tree, not per sibling list) — check the
263
+ # same scope here. Everything else (sources, cues, questions)
264
+ # only needs uniqueness within its own list.
265
+ candidates = (
266
+ all_steps if is_step else (_containing_list(dump, segments) or [])
267
+ )
268
+ collision = next(
269
+ (
270
+ candidate
271
+ for candidate in candidates
272
+ if isinstance(candidate, Mapping)
273
+ and candidate is not container
274
+ and candidate.get("id") == new_id
275
+ ),
276
+ None,
277
+ )
278
+ if collision is not None:
279
+ scope = "the document" if is_step else "the same list"
280
+ raise ValueError(
281
+ f"edits[{i}]: cannot rename id to {new_id!r} — already used "
282
+ f"by another item in {scope}"
283
+ )
217
284
  # The lock site must resolve BEFORE the mutation: an edit may change
218
285
  # the very value a segment addresses (renaming a step's id).
219
286
  site, relative = _lock_site(dump, segments)
@@ -352,14 +419,47 @@ def _merged_attrs(committed: Mapping, fresh: Mapping) -> dict:
352
419
  return {**copy.deepcopy(dict(fresh)), **copy.deepcopy(dict(committed))}
353
420
 
354
421
 
422
+ def _renamed_from(committed: list[dict]) -> dict[str, dict]:
423
+ """old id -> committed step, for steps whose own ``/id`` lock records a
424
+ rename (``apply_edits`` writes exactly this). Analysis that re-runs
425
+ without knowledge of the rename still emits the old id — without this,
426
+ that fresh step would be treated as unrelated and the old id would
427
+ resurface alongside the renamed one (issue #12)."""
428
+ out: dict[str, dict] = {}
429
+ for step in committed:
430
+ for lock in step.get("locks", []):
431
+ if lock.get("path") == "/id" and lock.get("was") is not None:
432
+ out[str(lock["was"])] = step
433
+ return out
434
+
435
+
355
436
  def _merge_steps(committed: list[dict], fresh: list[dict]) -> list[dict]:
356
437
  committed_by_id: dict[str, dict] = {}
357
438
  for step in committed: # first occurrence wins, matching apply_edits
358
439
  committed_by_id.setdefault(step["id"], step)
440
+ renamed_from = _renamed_from(committed)
359
441
  fresh_ids = {step["id"] for step in fresh}
442
+
443
+ # Direct id matches are resolved FIRST and claim their committed step
444
+ # before any rename substitution runs: if fresh already carries the
445
+ # renamed id, that's the real match, and a stray fresh entry still using
446
+ # the old id (analysis emitting both, or simply unrelated) must not also
447
+ # claim the same committed step — that would merge one committed step
448
+ # into two output entries with the same id (issue #12, blocking review).
449
+ matched_committed_ids: set[str] = {
450
+ committed_by_id[fresh_step["id"]]["id"]
451
+ for fresh_step in fresh
452
+ if fresh_step["id"] in committed_by_id
453
+ }
454
+
360
455
  merged: list[dict] = []
361
456
  for fresh_step in fresh:
362
457
  committed_step = committed_by_id.get(fresh_step["id"])
458
+ if committed_step is None:
459
+ candidate = renamed_from.get(fresh_step["id"])
460
+ if candidate is not None and candidate["id"] not in matched_committed_ids:
461
+ committed_step = candidate
462
+ matched_committed_ids.add(candidate["id"])
363
463
  if committed_step is None:
364
464
  merged.append(copy.deepcopy(fresh_step))
365
465
  continue
@@ -378,7 +478,10 @@ def _merge_steps(committed: list[dict], fresh: list[dict]) -> list[dict]:
378
478
  # Committed-only steps survive when they carry protection (locks anywhere
379
479
  # in their subtree, or a user origin); analysis leftovers are superseded.
380
480
  for position, committed_step in enumerate(committed):
381
- if committed_step["id"] in fresh_ids:
481
+ if (
482
+ committed_step["id"] in fresh_ids
483
+ or committed_step["id"] in matched_committed_ids
484
+ ):
382
485
  continue
383
486
  subtree_protected = _is_protected(committed_step) or any(
384
487
  _is_protected(child) for child in _walk_dumps(committed_step)
@@ -24,6 +24,8 @@ Design decisions, each argued in ``docs/07-annotation-model.md``:
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
+ from collections.abc import Mapping
28
+ from datetime import datetime
27
29
  from fractions import Fraction
28
30
  from typing import Annotated, Any, Literal
29
31
 
@@ -169,6 +171,12 @@ class Cue(_Base):
169
171
  # ── provenance & edit protection ────────────────────────────────────────────
170
172
 
171
173
 
174
+ #: ``Lock.at`` is always written by :func:`paces.edits._now_iso`; this is that
175
+ #: same format, enforced on the way in so a hand-authored ``at`` can't smuggle
176
+ #: an unparseable timestamp onto the wire.
177
+ _LOCK_AT_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
178
+
179
+
172
180
  class Lock(_Base):
173
181
  """A human (or approved-AI) decision that regeneration MUST NOT overwrite."""
174
182
 
@@ -178,6 +186,25 @@ class Lock(_Base):
178
186
  was: Any | None = None # the pre-edit value — makes the edit reversible
179
187
  reason: str | None = None
180
188
 
189
+ @field_validator("by")
190
+ @classmethod
191
+ def _by_must_be_attributed(cls, value: str) -> str:
192
+ if not value.strip():
193
+ raise ValueError("Lock.by must not be empty — who made this edit?")
194
+ return value
195
+
196
+ @field_validator("at")
197
+ @classmethod
198
+ def _at_must_be_iso_utc(cls, value: str) -> str:
199
+ try:
200
+ datetime.strptime(value, _LOCK_AT_FORMAT)
201
+ except ValueError as e:
202
+ raise ValueError(
203
+ f"Lock.at must be ISO-8601 UTC, second resolution "
204
+ f"('2026-08-30T00:00:00Z'); got {value!r}"
205
+ ) from e
206
+ return value
207
+
181
208
 
182
209
  class Origin(_Base):
183
210
  """Back-reference into the evidence layer: why this node says what it says,
@@ -346,6 +373,61 @@ def resolve(doc: StepDocument) -> dict:
346
373
  }
347
374
 
348
375
 
376
+ #: Field names whose schema type is deliberately ``float`` (``docs/07 §6.5``
377
+ #: accepts this as a pre-existing hole in the no-floats-on-the-wire rule) —
378
+ #: everywhere else, a Python ``float`` reaching the wire (typically through an
379
+ #: ``attrs`` bag or a ``Lock.was``, both typed ``Any``) is a leak. (A float
380
+ #: under an ``attrs`` key that happens to be literally named ``confidence``
381
+ #: slips through this same allowlist — a narrow, accepted gap: distinguishing
382
+ #: it would mean knowing ``attrs`` is user data everywhere it appears.)
383
+ _FLOAT_TYPED_KEYS = frozenset({"confidence"})
384
+
385
+ #: The keys a ``Lock`` always dumps as (``model_dump(mode="python")``, no
386
+ #: ``exclude_none``) — used to recognise a Lock structurally so ``was`` can be
387
+ #: checked against what ``path`` says it actually holds, not the literal key
388
+ #: name ``"was"`` (a ``Lock`` locking a ``confidence`` field legitimately
389
+ #: carries a float in ``was``).
390
+ _LOCK_DUMP_KEYS = frozenset({"path", "by", "at", "was", "reason"})
391
+
392
+
393
+ def _scan_floats(node: Any, path: str, *, key: str | None = None) -> list[str]:
394
+ """Paths where a raw ``float`` reaches the wire outside a typed field."""
395
+ if isinstance(node, float):
396
+ if key in _FLOAT_TYPED_KEYS:
397
+ return []
398
+ return [
399
+ f"{path}: float value {node!r} on the no-floats wire (use a decimal string)"
400
+ ]
401
+ if isinstance(node, Mapping):
402
+ if set(node) == _LOCK_DUMP_KEYS and isinstance(node.get("path"), str):
403
+ was_key = node["path"].rsplit("/", 1)[-1]
404
+ return [
405
+ issue
406
+ for k, v in node.items()
407
+ for issue in _scan_floats(
408
+ v, f"{path}/{k}", key=was_key if k == "was" else k
409
+ )
410
+ ]
411
+ return [
412
+ issue
413
+ for k, v in node.items()
414
+ for issue in _scan_floats(v, f"{path}/{k}", key=k)
415
+ ]
416
+ if isinstance(node, (list, tuple)):
417
+ return [
418
+ issue
419
+ for i, v in enumerate(node)
420
+ for issue in _scan_floats(
421
+ v,
422
+ f"{path}/{v['id']}"
423
+ if isinstance(v, Mapping) and "id" in v
424
+ else f"{path}/{i}",
425
+ key=key,
426
+ )
427
+ ]
428
+ return []
429
+
430
+
349
431
  def validate_document(doc: StepDocument) -> list[str]:
350
432
  """Semantic checks beyond the schema. Returns human-readable issues
351
433
  (empty list = clean); never raises.
@@ -353,9 +435,11 @@ def validate_document(doc: StepDocument) -> list[str]:
353
435
  Checks: children durations account for the parent's (``repeat`` included),
354
436
  span sources exist, cue anchors point at real steps, step ids are unique
355
437
  (id-addressed edits and the regeneration merge both key on them — a
356
- duplicate makes those silently ambiguous).
438
+ duplicate makes those silently ambiguous), and no raw ``float`` has
439
+ leaked onto the no-floats wire through an ``attrs`` bag or a ``Lock.was``
440
+ (both typed ``Any``, so the schema alone cannot catch this).
357
441
  """
358
- issues: list[str] = []
442
+ issues: list[str] = _scan_floats(doc.model_dump(mode="python", by_alias=False), "")
359
443
  source_ids = {s.id for s in doc.sources}
360
444
  step_ids: set[str] = set()
361
445
 
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "paces"
9
- version = "0.0.9"
9
+ version = "0.0.10"
10
10
  description = "Turn instructional media into structured, interactive learning material"
11
11
  readme = "README.md"
12
12
  license = "MIT"
@@ -0,0 +1,254 @@
1
+ """Residuals from PR #11's adversarial review, tracked as issue #12.
2
+
3
+ Each test below covers one item from the issue. Items declined (documented
4
+ instead of fixed, or explicitly deferred) are not tested here — see the issue
5
+ for the one-line reasons.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+ from pydantic import ValidationError
12
+
13
+ from paces.edits import apply_edits, merge_regenerated
14
+ from paces.model import (
15
+ Lock,
16
+ Measure,
17
+ Source,
18
+ SourceSpan,
19
+ Step,
20
+ StepDocument,
21
+ validate_document,
22
+ )
23
+
24
+
25
+ def _doc(**overrides) -> StepDocument:
26
+ fields = dict(
27
+ id="guide",
28
+ title="A guide",
29
+ steps=[Step(id="a", name="Step A", duration=Measure(value="2", unit="eight"))],
30
+ )
31
+ fields.update(overrides)
32
+ return StepDocument(**fields)
33
+
34
+
35
+ # ── Lock.by / Lock.at validation ─────────────────────────────────────────────
36
+
37
+
38
+ def test_lock_by_rejects_empty_string():
39
+ with pytest.raises(ValidationError):
40
+ Lock(path="/name", by="", at="2026-08-30T00:00:00Z")
41
+
42
+
43
+ def test_lock_by_rejects_whitespace_only():
44
+ with pytest.raises(ValidationError):
45
+ Lock(path="/name", by=" ", at="2026-08-30T00:00:00Z")
46
+
47
+
48
+ def test_lock_at_rejects_junk_timestamp():
49
+ with pytest.raises(ValidationError):
50
+ Lock(path="/name", by="user:thor", at="not-a-timestamp")
51
+
52
+
53
+ def test_lock_at_rejects_non_utc_offset():
54
+ with pytest.raises(ValidationError):
55
+ Lock(path="/name", by="user:thor", at="2026-08-30T00:00:00+02:00")
56
+
57
+
58
+ def test_lock_at_accepts_the_format_edits_writes():
59
+ Lock(path="/name", by="user:thor", at="2026-08-30T00:00:00Z")
60
+
61
+
62
+ # ── Lock.was float leak (validate_document) ──────────────────────────────────
63
+
64
+
65
+ def test_validate_document_flags_float_leaked_into_lock_was():
66
+ doc = _doc(
67
+ steps=[
68
+ Step(
69
+ id="a",
70
+ name="Step A",
71
+ duration=Measure(value="2", unit="eight"),
72
+ locks=[
73
+ Lock(
74
+ path="/name", by="user:thor", at="2026-08-30T00:00:00Z", was=1.5
75
+ )
76
+ ],
77
+ )
78
+ ]
79
+ )
80
+ issues = validate_document(doc)
81
+ assert any("float" in issue for issue in issues)
82
+
83
+
84
+ def test_validate_document_flags_float_leaked_into_attrs():
85
+ doc = _doc(
86
+ steps=[
87
+ Step(
88
+ id="a",
89
+ name="Step A",
90
+ duration=Measure(value="2", unit="eight"),
91
+ attrs={"render.web": {"hue": 0.5}},
92
+ )
93
+ ]
94
+ )
95
+ issues = validate_document(doc)
96
+ assert any("float" in issue for issue in issues)
97
+
98
+
99
+ def test_validate_document_does_not_flag_the_typed_confidence_float():
100
+ doc = _doc(
101
+ steps=[
102
+ Step(
103
+ id="a",
104
+ name="Step A",
105
+ duration=Measure(value="2", unit="eight"),
106
+ spans=[SourceSpan(source="vid", start="10", confidence=0.8)],
107
+ )
108
+ ]
109
+ )
110
+ issues = validate_document(doc)
111
+ assert not any("float" in issue for issue in issues)
112
+
113
+
114
+ def test_validate_document_does_not_flag_a_lock_was_holding_the_old_confidence():
115
+ # apply_edits itself writes exactly this: a Lock.was carrying the
116
+ # pre-edit value of a schema-typed float field (SourceSpan.confidence).
117
+ doc = _doc(
118
+ steps=[
119
+ Step(
120
+ id="a",
121
+ name="Step A",
122
+ duration=Measure(value="2", unit="eight"),
123
+ spans=[SourceSpan(source="vid", start="10", confidence=0.8)],
124
+ )
125
+ ]
126
+ )
127
+ edited = apply_edits(
128
+ doc,
129
+ [{"op": "set", "path": "/steps/a/spans/0/confidence", "value": 0.9}],
130
+ by="user:thor",
131
+ )
132
+ issues = validate_document(edited)
133
+ assert not any("float" in issue for issue in issues)
134
+
135
+
136
+ def test_validate_document_clean_document_has_no_float_issues():
137
+ assert validate_document(_doc()) == []
138
+
139
+
140
+ # ── rename onto an existing id is refused at edit time ───────────────────────
141
+
142
+
143
+ def _two_step_doc(**overrides) -> StepDocument:
144
+ fields = dict(
145
+ id="guide",
146
+ title="A guide",
147
+ sources=[Source(id="vid", kind="video", uri="https://example.com/v")],
148
+ steps=[
149
+ Step(
150
+ id="a",
151
+ name="Step A",
152
+ duration=Measure(value="2", unit="eight"),
153
+ spans=[SourceSpan(source="vid", start="10")],
154
+ ),
155
+ Step(
156
+ id="b",
157
+ name="Step B",
158
+ duration=Measure(value="2", unit="eight"),
159
+ spans=[SourceSpan(source="vid", start="20")],
160
+ ),
161
+ ],
162
+ )
163
+ fields.update(overrides)
164
+ return StepDocument(**fields)
165
+
166
+
167
+ def test_apply_edits_refuses_rename_onto_an_existing_id():
168
+ with pytest.raises(ValueError, match="already used"):
169
+ apply_edits(
170
+ _two_step_doc(),
171
+ [{"op": "set", "path": "/steps/a/id", "value": "b"}],
172
+ by="user:thor",
173
+ )
174
+
175
+
176
+ def test_apply_edits_allows_rename_onto_a_free_id():
177
+ doc = apply_edits(
178
+ _two_step_doc(),
179
+ [{"op": "set", "path": "/steps/a/id", "value": "a2"}],
180
+ by="user:thor",
181
+ )
182
+ assert [s.id for s in doc.steps] == ["a2", "b"]
183
+
184
+
185
+ def test_apply_edits_allows_renaming_id_to_itself():
186
+ doc = apply_edits(
187
+ _two_step_doc(),
188
+ [{"op": "set", "path": "/steps/a/id", "value": "a"}],
189
+ by="user:thor",
190
+ )
191
+ assert [s.id for s in doc.steps] == ["a", "b"]
192
+
193
+
194
+ def test_apply_edits_refuses_a_nested_rename_onto_a_top_level_id():
195
+ doc = _two_step_doc(
196
+ steps=[
197
+ Step(
198
+ id="a",
199
+ name="Step A",
200
+ duration=Measure(value="2", unit="eight"),
201
+ steps=[
202
+ Step(id="c", name="Sub", duration=Measure(value="2", unit="eight"))
203
+ ],
204
+ ),
205
+ Step(id="b", name="Step B", duration=Measure(value="2", unit="eight")),
206
+ ]
207
+ )
208
+ # "b" isn't a sibling of "c" (it lives one level up) — the collision must
209
+ # still be caught document-wide, matching validate_document's own scope.
210
+ with pytest.raises(ValueError, match="already used"):
211
+ apply_edits(
212
+ doc,
213
+ [{"op": "set", "path": "/steps/a/steps/c/id", "value": "b"}],
214
+ by="user:thor",
215
+ )
216
+
217
+
218
+ # ── merge after an id rename does not resurrect the old id ──────────────────
219
+
220
+
221
+ def test_merge_regenerated_does_not_resurrect_the_pre_rename_id():
222
+ committed = apply_edits(
223
+ _two_step_doc(),
224
+ [{"op": "set", "path": "/steps/a/id", "value": "a2"}],
225
+ by="user:thor",
226
+ )
227
+ # Analysis re-runs without knowledge of the rename: it still emits "a".
228
+ fresh = _two_step_doc()
229
+ merged = merge_regenerated(committed, fresh)
230
+ assert [s.id for s in merged.steps] == ["a2", "b"]
231
+ assert merged.steps[0].name == "Step A"
232
+
233
+
234
+ def test_merge_regenerated_does_not_double_match_when_fresh_has_both_ids():
235
+ committed = apply_edits(
236
+ _two_step_doc(),
237
+ [{"op": "set", "path": "/steps/a/id", "value": "a2"}],
238
+ by="user:thor",
239
+ )
240
+ # Fresh carries BOTH the old id and the already-renamed id (e.g. analysis
241
+ # caught up and emitted "a2", but a stray "a" is also present) — the
242
+ # renamed committed step must not be matched twice.
243
+ fresh = _two_step_doc(
244
+ steps=[
245
+ Step(id="a", name="Step A", duration=Measure(value="2", unit="eight")),
246
+ Step(id="a2", name="Step A", duration=Measure(value="2", unit="eight")),
247
+ Step(id="b", name="Step B", duration=Measure(value="2", unit="eight")),
248
+ ]
249
+ )
250
+ merged = merge_regenerated(committed, fresh)
251
+ ids = [s.id for s in merged.steps]
252
+ assert ids == ["a", "a2", "b"]
253
+ assert len(ids) == len(set(ids))
254
+ assert validate_document(merged) == []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes