paces 0.0.8__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.8 → paces-0.0.10}/PKG-INFO +24 -6
  2. {paces-0.0.8 → paces-0.0.10}/README.md +23 -5
  3. {paces-0.0.8 → paces-0.0.10}/paces/edits.py +104 -1
  4. {paces-0.0.8 → paces-0.0.10}/paces/model.py +86 -2
  5. {paces-0.0.8 → paces-0.0.10}/paces/pose.py +11 -2
  6. {paces-0.0.8 → paces-0.0.10}/pyproject.toml +7 -2
  7. paces-0.0.10/tests/test_edit_protection_residuals.py +254 -0
  8. {paces-0.0.8 → paces-0.0.10}/tests/test_pose.py +30 -0
  9. {paces-0.0.8 → paces-0.0.10}/.gitignore +0 -0
  10. {paces-0.0.8 → paces-0.0.10}/LICENSE +0 -0
  11. {paces-0.0.8 → paces-0.0.10}/docs/README.md +0 -0
  12. {paces-0.0.8 → paces-0.0.10}/docs/alignment/README.md +0 -0
  13. {paces-0.0.8 → paces-0.0.10}/docs/poc-reference/README.md +0 -0
  14. {paces-0.0.8 → paces-0.0.10}/docs/poc-reference/artifacts/clips.json +0 -0
  15. {paces-0.0.8 → paces-0.0.10}/paces/__init__.py +0 -0
  16. {paces-0.0.8 → paces-0.0.10}/paces/__main__.py +0 -0
  17. {paces-0.0.8 → paces-0.0.10}/paces/derivation.py +0 -0
  18. {paces-0.0.8 → paces-0.0.10}/paces/excerpts.py +0 -0
  19. {paces-0.0.8 → paces-0.0.10}/paces/measure.py +0 -0
  20. {paces-0.0.8 → paces-0.0.10}/paces/projection.py +0 -0
  21. {paces-0.0.8 → paces-0.0.10}/paces/render.py +0 -0
  22. {paces-0.0.8 → paces-0.0.10}/paces/segmenters.py +0 -0
  23. {paces-0.0.8 → paces-0.0.10}/paces/tools.py +0 -0
  24. {paces-0.0.8 → paces-0.0.10}/tests/audio_synth.py +0 -0
  25. {paces-0.0.8 → paces-0.0.10}/tests/data/routine.json +0 -0
  26. {paces-0.0.8 → paces-0.0.10}/tests/poc_fixture.py +0 -0
  27. {paces-0.0.8 → paces-0.0.10}/tests/test_chapters.py +0 -0
  28. {paces-0.0.8 → paces-0.0.10}/tests/test_cli_surface.py +0 -0
  29. {paces-0.0.8 → paces-0.0.10}/tests/test_derivation.py +0 -0
  30. {paces-0.0.8 → paces-0.0.10}/tests/test_edits.py +0 -0
  31. {paces-0.0.8 → paces-0.0.10}/tests/test_excerpts.py +0 -0
  32. {paces-0.0.8 → paces-0.0.10}/tests/test_measure.py +0 -0
  33. {paces-0.0.8 → paces-0.0.10}/tests/test_render_media.py +0 -0
  34. {paces-0.0.8 → paces-0.0.10}/tests/test_roundtrip_poc.py +0 -0
  35. {paces-0.0.8 → paces-0.0.10}/tests/test_segment.py +0 -0
  36. {paces-0.0.8 → paces-0.0.10}/tests/test_smoke.py +0 -0
  37. {paces-0.0.8 → paces-0.0.10}/tests/test_vertical_slice.py +0 -0
  38. {paces-0.0.8 → 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.8
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
@@ -109,11 +109,29 @@ quarantined into one — is the **ultralytics** distribution, which is AGPL-3.0.
109
109
 
110
110
  What it pulls in is a different question, and worth stating plainly: rtmlib
111
111
  requires opencv, and opencv's *bundled FFmpeg* is **GPL-3.0-or-later on macOS
112
- wheels** (built `--enable-gpl` with libx264/libx265) though LGPL-2.1-or-later
113
- on manylinux and Windows. This is measured from the shipped binaries the
114
- wheels' own `LICENSE-3RD-PARTY.txt` never mentions x264. `paces[media]`
115
- already brings such a wheel, so `[pose]` adds a second copy rather than a
116
- higher tier. `paces.pose.check_pose_requirements()` reports what you have.
112
+ wheels of the versions measured (4.12.0.88 / 4.13.0.92)** (built `--enable-gpl`
113
+ with libx264/libx265) though LGPL-2.1-or-later on manylinux and Windowsa
114
+ per-version fact, not a per-platform one: the 5.0.0.93 macOS x86_64 wheel ships
115
+ no FFmpeg at all. This is measured from the shipped binaries the wheels' own
116
+ `LICENSE-3RD-PARTY.txt` never mentions x264. `paces[media]` already brings such
117
+ a wheel, so `[pose]` adds a second copy rather than a higher tier. Worse:
118
+ rtmlib's own metadata requires *both* `opencv-python` *and*
119
+ `opencv-contrib-python`, unpinned, so a plain `pip install paces[media,pose]`
120
+ ends up with two distributions owning one `cv2` — harmless until either is
121
+ uninstalled, at which point the survivor's `cv2` can be left with files
122
+ missing (issue #20). rtmlib only calls plain `cv2` APIs (`VideoCapture`,
123
+ `dnn.readNetFromONNX`, drawing helpers — nothing contrib-only), so `[media]`'s
124
+ `opencv-contrib-python` already covers it; to keep the closure
125
+ single-provider, install in two steps instead of one:
126
+
127
+ ```bash
128
+ pip install "paces[media]" # opencv-contrib-python, the fleet's one cv2
129
+ pip install --no-deps "rtmlib>=0.0.16,<0.1" # skip rtmlib's own opencv-* re-declaration
130
+ pip install onnxruntime tqdm # rtmlib's other real deps (numpy already arrives via opencv)
131
+ ```
132
+
133
+ `paces.pose.check_pose_requirements()` reports what you have, and names the
134
+ repair command if both providers are already present.
117
135
 
118
136
  ## How it thinks
119
137
 
@@ -71,11 +71,29 @@ quarantined into one — is the **ultralytics** distribution, which is AGPL-3.0.
71
71
 
72
72
  What it pulls in is a different question, and worth stating plainly: rtmlib
73
73
  requires opencv, and opencv's *bundled FFmpeg* is **GPL-3.0-or-later on macOS
74
- wheels** (built `--enable-gpl` with libx264/libx265) though LGPL-2.1-or-later
75
- on manylinux and Windows. This is measured from the shipped binaries the
76
- wheels' own `LICENSE-3RD-PARTY.txt` never mentions x264. `paces[media]`
77
- already brings such a wheel, so `[pose]` adds a second copy rather than a
78
- higher tier. `paces.pose.check_pose_requirements()` reports what you have.
74
+ wheels of the versions measured (4.12.0.88 / 4.13.0.92)** (built `--enable-gpl`
75
+ with libx264/libx265) though LGPL-2.1-or-later on manylinux and Windowsa
76
+ per-version fact, not a per-platform one: the 5.0.0.93 macOS x86_64 wheel ships
77
+ no FFmpeg at all. This is measured from the shipped binaries the wheels' own
78
+ `LICENSE-3RD-PARTY.txt` never mentions x264. `paces[media]` already brings such
79
+ a wheel, so `[pose]` adds a second copy rather than a higher tier. Worse:
80
+ rtmlib's own metadata requires *both* `opencv-python` *and*
81
+ `opencv-contrib-python`, unpinned, so a plain `pip install paces[media,pose]`
82
+ ends up with two distributions owning one `cv2` — harmless until either is
83
+ uninstalled, at which point the survivor's `cv2` can be left with files
84
+ missing (issue #20). rtmlib only calls plain `cv2` APIs (`VideoCapture`,
85
+ `dnn.readNetFromONNX`, drawing helpers — nothing contrib-only), so `[media]`'s
86
+ `opencv-contrib-python` already covers it; to keep the closure
87
+ single-provider, install in two steps instead of one:
88
+
89
+ ```bash
90
+ pip install "paces[media]" # opencv-contrib-python, the fleet's one cv2
91
+ pip install --no-deps "rtmlib>=0.0.16,<0.1" # skip rtmlib's own opencv-* re-declaration
92
+ pip install onnxruntime tqdm # rtmlib's other real deps (numpy already arrives via opencv)
93
+ ```
94
+
95
+ `paces.pose.check_pose_requirements()` reports what you have, and names the
96
+ repair command if both providers are already present.
79
97
 
80
98
  ## How it thinks
81
99
 
@@ -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
 
@@ -412,7 +412,12 @@ def check_pose_requirements() -> dict:
412
412
  ``opencv-contrib-python``, while the fleet standardises on the contrib
413
413
  superset (mixing's single cv2 provider). An install can therefore end up
414
414
  with two distributions owning one ``cv2`` package. Reported, not fatal:
415
- it works until one of them is uninstalled, and a reader deserves to know.
415
+ it works until one of them is uninstalled, and a reader deserves to know
416
+ — and, since neither package's uninstall is safe once both are present
417
+ (each claims files the other also claims), the note names the repair
418
+ command rather than only the collision. See README's "Auto-crop" section
419
+ for the install recipe that avoids the pair in the first place
420
+ (issue #20).
416
421
 
417
422
  Downloads nothing: weights arrive on first inference, not on this check.
418
423
  """
@@ -449,7 +454,11 @@ def check_pose_requirements() -> dict:
449
454
  notes.append(
450
455
  "two distributions provide cv2 "
451
456
  f"({', '.join(cv2_providers)}) — rtmlib declares both; uninstalling "
452
- "either can leave the other's cv2 broken"
457
+ "either can leave the other's cv2 broken. Repair: "
458
+ "pip uninstall -y opencv-python opencv-contrib-python && "
459
+ "pip install --force-reinstall opencv-contrib-python — not sticky: "
460
+ "the next plain `pip install paces[pose]` re-adds opencv-python, "
461
+ "so use README's Auto-crop install recipe to avoid it recurring"
453
462
  )
454
463
  return {
455
464
  "ok": rtmlib_version is not None and onnxruntime_version is not None,
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "paces"
9
- version = "0.0.8"
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"
@@ -89,7 +89,12 @@ media = [
89
89
  # unaudited wheel in silently.
90
90
  # Not mirrored into [dev] on purpose: the tests are offline and stay on fake
91
91
  # locators (no weights, no network), and CI has no reason to take on the second
92
- # cv2 provider. `check_pose_requirements()` reports that pair when it happens.
92
+ # cv2 provider. `check_pose_requirements()` reports that pair when it happens
93
+ # and names the repair command; README's "Auto-crop" section has the two-step
94
+ # install recipe that avoids the pair in the first place (issue #20). Not
95
+ # fixable from here: rtmlib declares both wheels unconditionally, with no
96
+ # marker paces can override, so a single-provider closure needs either that
97
+ # recipe or an upstream rtmlib change — not a pin in this file.
93
98
  pose = [
94
99
  "rtmlib>=0.0.16,<0.1",
95
100
  "onnxruntime",
@@ -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) == []
@@ -439,6 +439,36 @@ def test_check_pose_requirements_reports_channels_without_downloading():
439
439
  assert any("paces[pose]" in note for note in report["notes"])
440
440
 
441
441
 
442
+ def test_check_pose_requirements_names_the_repair_when_both_cv2_providers_present(
443
+ monkeypatch,
444
+ ):
445
+ # issue #20: rtmlib declares both opencv-python and opencv-contrib-python.
446
+ # Naming the collision isn't enough — a reader who hits it needs the
447
+ # repair command, not just the two package names, since neither package's
448
+ # uninstall is safe once both are present (each claims the other's files).
449
+ import importlib.metadata
450
+
451
+ installed = {
452
+ "rtmlib": "0.0.16",
453
+ "onnxruntime": "1.2.3",
454
+ "opencv-python": "4.12.0.88",
455
+ "opencv-contrib-python": "4.13.0.92",
456
+ }
457
+
458
+ def fake_version(distribution, *args, **kwargs):
459
+ try:
460
+ return installed[distribution]
461
+ except KeyError:
462
+ raise importlib.metadata.PackageNotFoundError(distribution)
463
+
464
+ monkeypatch.setattr(importlib.metadata, "version", fake_version)
465
+ report = check_pose_requirements()
466
+ assert report["cv2_providers"] == ["opencv-python", "opencv-contrib-python"]
467
+ assert any(
468
+ "pip install --force-reinstall" in note for note in report["notes"]
469
+ )
470
+
471
+
442
472
  # ── the licence perimeter (packaging facts, guarded because nothing else is) ─
443
473
 
444
474
 
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