paces 0.0.22__tar.gz → 0.0.24__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 (60) hide show
  1. {paces-0.0.22 → paces-0.0.24}/PKG-INFO +1 -1
  2. {paces-0.0.22 → paces-0.0.24}/paces/corrections.py +30 -61
  3. paces-0.0.24/paces/segmentation.py +169 -0
  4. {paces-0.0.22 → paces-0.0.24}/paces/segmenters.py +21 -6
  5. {paces-0.0.22 → paces-0.0.24}/pyproject.toml +1 -1
  6. {paces-0.0.22 → paces-0.0.24}/tests/test_corrections.py +76 -9
  7. {paces-0.0.22 → paces-0.0.24}/tests/test_segment.py +50 -1
  8. paces-0.0.22/paces/segmentation.py +0 -74
  9. {paces-0.0.22 → paces-0.0.24}/.gitignore +0 -0
  10. {paces-0.0.22 → paces-0.0.24}/LICENSE +0 -0
  11. {paces-0.0.22 → paces-0.0.24}/README.md +0 -0
  12. {paces-0.0.22 → paces-0.0.24}/docs/README.md +0 -0
  13. {paces-0.0.22 → paces-0.0.24}/docs/alignment/README.md +0 -0
  14. {paces-0.0.22 → paces-0.0.24}/docs/poc-reference/README.md +0 -0
  15. {paces-0.0.22 → paces-0.0.24}/docs/poc-reference/artifacts/clips.json +0 -0
  16. {paces-0.0.22 → paces-0.0.24}/paces/__init__.py +0 -0
  17. {paces-0.0.22 → paces-0.0.24}/paces/__main__.py +0 -0
  18. {paces-0.0.22 → paces-0.0.24}/paces/ask.py +0 -0
  19. {paces-0.0.22 → paces-0.0.24}/paces/bodies/__init__.py +0 -0
  20. {paces-0.0.22 → paces-0.0.24}/paces/bodies/_base.py +0 -0
  21. {paces-0.0.22 → paces-0.0.24}/paces/bodies/derivation.py +0 -0
  22. {paces-0.0.22 → paces-0.0.24}/paces/bodies/document.py +0 -0
  23. {paces-0.0.22 → paces-0.0.24}/paces/bodies/signal.py +0 -0
  24. {paces-0.0.22 → paces-0.0.24}/paces/bodies/step.py +0 -0
  25. {paces-0.0.22 → paces-0.0.24}/paces/cues.py +0 -0
  26. {paces-0.0.22 → paces-0.0.24}/paces/data/ask/ask.css +0 -0
  27. {paces-0.0.22 → paces-0.0.24}/paces/data/ask/ask.html +0 -0
  28. {paces-0.0.22 → paces-0.0.24}/paces/data/ask/ask.js +0 -0
  29. {paces-0.0.22 → paces-0.0.24}/paces/derivation.py +0 -0
  30. {paces-0.0.22 → paces-0.0.24}/paces/edits.py +0 -0
  31. {paces-0.0.22 → paces-0.0.24}/paces/evidence.py +0 -0
  32. {paces-0.0.22 → paces-0.0.24}/paces/excerpts.py +0 -0
  33. {paces-0.0.22 → paces-0.0.24}/paces/measure.py +0 -0
  34. {paces-0.0.22 → paces-0.0.24}/paces/model.py +0 -0
  35. {paces-0.0.22 → paces-0.0.24}/paces/pose.py +0 -0
  36. {paces-0.0.22 → paces-0.0.24}/paces/projection.py +0 -0
  37. {paces-0.0.22 → paces-0.0.24}/paces/render.py +0 -0
  38. {paces-0.0.22 → paces-0.0.24}/paces/timed_text.py +0 -0
  39. {paces-0.0.22 → paces-0.0.24}/paces/tools.py +0 -0
  40. {paces-0.0.22 → paces-0.0.24}/tests/audio_synth.py +0 -0
  41. {paces-0.0.22 → paces-0.0.24}/tests/data/body_schemas.json +0 -0
  42. {paces-0.0.22 → paces-0.0.24}/tests/data/routine.json +0 -0
  43. {paces-0.0.22 → paces-0.0.24}/tests/poc_fixture.py +0 -0
  44. {paces-0.0.22 → paces-0.0.24}/tests/test_ask.py +0 -0
  45. {paces-0.0.22 → paces-0.0.24}/tests/test_chapters.py +0 -0
  46. {paces-0.0.22 → paces-0.0.24}/tests/test_cli_surface.py +0 -0
  47. {paces-0.0.22 → paces-0.0.24}/tests/test_cues.py +0 -0
  48. {paces-0.0.22 → paces-0.0.24}/tests/test_derivation.py +0 -0
  49. {paces-0.0.22 → paces-0.0.24}/tests/test_edit_protection_residuals.py +0 -0
  50. {paces-0.0.22 → paces-0.0.24}/tests/test_edits.py +0 -0
  51. {paces-0.0.22 → paces-0.0.24}/tests/test_evidence.py +0 -0
  52. {paces-0.0.22 → paces-0.0.24}/tests/test_evidence_schemas.py +0 -0
  53. {paces-0.0.22 → paces-0.0.24}/tests/test_excerpts.py +0 -0
  54. {paces-0.0.22 → paces-0.0.24}/tests/test_measure.py +0 -0
  55. {paces-0.0.22 → paces-0.0.24}/tests/test_pose.py +0 -0
  56. {paces-0.0.22 → paces-0.0.24}/tests/test_render_media.py +0 -0
  57. {paces-0.0.22 → paces-0.0.24}/tests/test_roundtrip_poc.py +0 -0
  58. {paces-0.0.22 → paces-0.0.24}/tests/test_smoke.py +0 -0
  59. {paces-0.0.22 → paces-0.0.24}/tests/test_vertical_slice.py +0 -0
  60. {paces-0.0.22 → paces-0.0.24}/tests/video_synth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paces
3
- Version: 0.0.22
3
+ Version: 0.0.24
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
@@ -42,21 +42,20 @@ from dataclasses import dataclass
42
42
  from typing import Literal, get_args
43
43
 
44
44
  from paces.model import decimal_str
45
- from paces.segmentation import (
45
+ from paces.segmentation import ( # noqa: F401 — TIME_PLACES/time_str re-exported
46
46
  DFLT_EXPLICIT_CONFIDENCE,
47
+ TIME_PLACES,
47
48
  SegStep,
48
49
  Segmentation,
49
50
  Span,
51
+ time_str,
50
52
  )
53
+ from paces.segmentation import edges as _edges
54
+ from paces.segmentation import overlaps as _overlaps
51
55
 
52
56
  Verb = Literal["pin", "forbid", "absent", "split", "merge"]
53
57
  VERBS: tuple[str, ...] = get_args(Verb)
54
58
 
55
- #: Decimal places a correction's times keep: milliseconds, the wire's
56
- #: resolution. Times are rounded to it at construction, so ``to_wire`` →
57
- #: ``from_wire`` is lossless (``decimal_str`` writes exactly this many).
58
- TIME_PLACES = 3
59
-
60
59
  #: How far a ``merge`` time may sit from the boundary it means. A human taps
61
60
  #: approximately; the machine finds the cut (``docs/alignment/07 §7.3`` step 3
62
61
  #: snaps taps within ±1.5 s).
@@ -69,6 +68,8 @@ DFLT_MIN_STEP_S = 0.25
69
68
 
70
69
  def _to_ms(value, *, what: str) -> float:
71
70
  """A finite time rounded to :data:`TIME_PLACES`, or a ``ValueError``."""
71
+ if isinstance(value, bool): # True is 1.0 to float(); never a time
72
+ raise ValueError(f"{what} must be a time in seconds, got {value!r}")
72
73
  try:
73
74
  t = float(value)
74
75
  except (TypeError, ValueError):
@@ -102,6 +103,18 @@ class Correction:
102
103
  reason: str = ""
103
104
 
104
105
  def __post_init__(self):
106
+ for name in ("author", "at", "reason"):
107
+ if not isinstance(getattr(self, name), str):
108
+ raise ValueError(
109
+ f"{name} must be a string, got {getattr(self, name)!r}"
110
+ )
111
+ for name in ("step_id", "media_key"):
112
+ if getattr(self, name) is not None and not isinstance(
113
+ getattr(self, name), str
114
+ ):
115
+ raise ValueError(
116
+ f"{name} must be a string or None, got {getattr(self, name)!r}"
117
+ )
105
118
  if self.verb not in VERBS:
106
119
  raise ValueError(f"unknown verb {self.verb!r}; expected one of {VERBS}")
107
120
  object.__setattr__(self, "anchor_t", _to_ms(self.anchor_t, what="anchor_t"))
@@ -177,16 +190,6 @@ _WIRE_KEYS = {
177
190
  _REQUIRED_WIRE_KEYS = ("verb", "anchorT")
178
191
 
179
192
 
180
- def time_str(t: float) -> str:
181
- """A time as paces' millisecond wire decimal — the one formatter every
182
- corrections producer shares (the ask page's ``timeStr`` mirrors it).
183
-
184
- >>> time_str(95.80000001), time_str(10.0625) # exact binary ties round half-even
185
- ('95.8', '10.062')
186
- """
187
- return decimal_str(t, places=TIME_PLACES)
188
-
189
-
190
193
  def to_wire(correction: Correction) -> dict:
191
194
  """A JSON-ready dict: camelCase keys, times as decimal strings, defaults omitted."""
192
195
  wire: dict = {"verb": correction.verb, "anchorT": time_str(correction.anchor_t)}
@@ -296,9 +299,11 @@ def replay(
296
299
  against the *final* placement: ``satisfied: forbid@<t>``, or
297
300
  ``correction-unenforced`` — moving a step elsewhere needs a solver, which
298
301
  replay is not. A pin that clips a child step out of existence says so
299
- (``correction-clipped``). A last consistency pass flags
300
- (``inconsistent:``) any empty span, any overlap between steps, and any
301
- step edge missing from ``boundaries``.
302
+ (``correction-clipped``). Checking the result is not replay's job:
303
+ :func:`paces.segmenters.segment` runs
304
+ :func:`~paces.segmentation.inconsistencies` on every segmentation. A log
305
+ in which no correction edits the cut set leaves ``boundaries`` exactly as
306
+ given (duplicates included); once one does, they are rebuilt from it.
302
307
 
303
308
  >>> seg = Segmentation(steps=(SegStep(id="a", spans=((0.0, 10.0),)),),
304
309
  ... boundaries=(0.0, 10.0))
@@ -316,14 +321,18 @@ def replay(
316
321
  seen_ids=frozenset(step.id for step in seg.steps),
317
322
  )
318
323
  outcomes: list = []
324
+ cuts_touched = False # once a correction edits the cut set, it is rebuilt
319
325
  for correction in normalize_corrections(corrections):
326
+ before = state.boundaries
320
327
  state, outcome = _APPLY[correction.verb](state, correction, options)
328
+ cuts_touched = cuts_touched or state.boundaries != before
321
329
  outcomes.append(outcome)
322
- flags = tuple(_final_flags(state.steps, outcomes)) + _inconsistencies(state)
330
+ flags = tuple(_final_flags(state.steps, outcomes))
331
+ unchanged = not cuts_touched
323
332
  return dataclasses.replace(
324
333
  seg,
325
334
  steps=state.steps,
326
- boundaries=tuple(sorted(state.boundaries)),
335
+ boundaries=seg.boundaries if unchanged else tuple(sorted(state.boundaries)),
327
336
  flags=seg.flags + flags,
328
337
  )
329
338
 
@@ -351,15 +360,6 @@ def _edges_at(steps: Sequence[SegStep], t: float, side: int) -> list[tuple[int,
351
360
  ]
352
361
 
353
362
 
354
- def _edges(steps: Iterable[SegStep]) -> set[float]:
355
- return {t for step in steps for span in step.spans for t in span}
356
-
357
-
358
- def _overlaps(a: Span, b: Span) -> bool:
359
- """True when the two spans share a positive length of time."""
360
- return max(a[0], b[0]) < min(a[1], b[1])
361
-
362
-
363
363
  def _falls_in(child: SegStep, span: Span) -> bool:
364
364
  """A child belongs to the parent span its first span starts in."""
365
365
  return bool(child.spans) and span[0] <= child.spans[0][0] < span[1]
@@ -681,34 +681,3 @@ def _final_flags(steps: Sequence[SegStep], outcomes: Iterable) -> Iterable[str]:
681
681
  yield _forbid_flag(steps, outcome)
682
682
  else:
683
683
  yield from outcome
684
-
685
-
686
- def _inconsistencies(state: _State) -> tuple[str, ...]:
687
- """What no segmentation may contain, flagged rather than repaired: empty
688
- spans, spans of different steps that overlap, and step edges missing from
689
- ``boundaries``."""
690
- spans = sorted((span, step.id) for step in state.steps for span in step.spans)
691
- flags = [
692
- f"inconsistent: step {step_id!r} has an empty span at {time_str(span[0])}"
693
- for span, step_id in spans
694
- if span[0] >= span[1]
695
- ]
696
- flags += [
697
- f"inconsistent: steps {a_id!r} and {b_id!r} overlap "
698
- f"({time_str(max(a[0], b[0]))}–{time_str(min(a[1], b[1]))})"
699
- for k, (a, a_id) in enumerate(spans)
700
- for b, b_id in spans[k + 1 :]
701
- if a_id != b_id and _overlaps(a, b)
702
- ]
703
- flags += [
704
- f"inconsistent: child {child.id!r} of {step.id!r} lies outside its parent"
705
- for step in state.steps
706
- for child in step.children
707
- for span in child.spans
708
- if not any(p[0] <= span[0] and span[1] <= p[1] for p in step.spans)
709
- ]
710
- missing = sorted(_edges(state.steps) - state.boundaries)
711
- if missing:
712
- listed = ", ".join(time_str(t) for t in missing)
713
- flags.append(f"inconsistent: step edges not in boundaries: {listed}")
714
- return tuple(flags)
@@ -0,0 +1,169 @@
1
+ """What a segmenter returns: :class:`Segmentation`, its :class:`SegStep` records and
2
+ the ``Span`` they cover — the analysis-result types, with nothing else.
3
+
4
+ They live apart from :mod:`paces.segmenters` (the registry and the
5
+ ``segment()`` facade) so a module that only consumes or reshapes a result —
6
+ :mod:`paces.corrections`, :mod:`paces.projection`, :mod:`paces.ask`, the
7
+ evidence layer — depends on the types and not on the facade, and the facade
8
+ can import those modules without a cycle. :mod:`paces.segmenters` re-exports
9
+ every name here, so ``from paces.segmenters import Segmentation`` keeps
10
+ working.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Iterable, Mapping
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ from paces.model import MetricGrid, decimal_str
20
+
21
+ __all__ = [
22
+ "DFLT_CHAPTERS_CONFIDENCE",
23
+ "DFLT_EXPLICIT_CONFIDENCE",
24
+ "DFLT_GRID_CONFIDENCE",
25
+ "SegStep",
26
+ "Segmentation",
27
+ "Span",
28
+ "TIME_PLACES",
29
+ "edges",
30
+ "inconsistencies",
31
+ "overlaps",
32
+ "time_str",
33
+ ]
34
+
35
+ #: Decimal places every computed time keeps (spans, boundaries, corrections): milliseconds, the wire's
36
+ #: resolution. Corrections round to it at construction, so their wire round trip
37
+ #: is lossless (``decimal_str`` writes exactly this many).
38
+ TIME_PLACES = 3
39
+
40
+
41
+ Span = tuple[float, float] # seconds, half-open
42
+
43
+ # Confidence constants: placement from user-supplied inputs is arithmetic, so
44
+ # what these grade is trust in the inputs, not in any inference.
45
+ DFLT_EXPLICIT_CONFIDENCE = 0.95 # a human said so
46
+ DFLT_GRID_CONFIDENCE = 0.9 # exact given the grid; the grid itself may drift
47
+ DFLT_CHAPTERS_CONFIDENCE = 0.9 # the author said so, in metadata
48
+
49
+
50
+ @dataclass(frozen=True, slots=True, kw_only=True)
51
+ class SegStep:
52
+ """One named piece of the media, as an analysis result.
53
+
54
+ ``name=''`` means "found, not named" — a VALID state, not an error.
55
+ ``spans`` is a tuple because a step legitimately appears in several places
56
+ (run-through AND breakdown).
57
+ """
58
+
59
+ id: str
60
+ name: str = ""
61
+ spans: tuple[Span, ...] = ()
62
+ confidence: float = 0.0
63
+ children: tuple["SegStep", ...] = ()
64
+ source: str = "" # which capability produced it
65
+ evidence: Mapping[str, Any] = field(default_factory=dict)
66
+
67
+
68
+ @dataclass(frozen=True, slots=True, kw_only=True)
69
+ class Segmentation:
70
+ """What every segmenter returns. Reviewable, re-runnable, arguable.
71
+
72
+ ``boundaries`` is separate from ``steps`` and populated even when naming
73
+ failed: nine excellent unnamed cuts are a useful, honest result. ``steps``
74
+ may be empty while ``boundaries`` is not.
75
+ """
76
+
77
+ steps: tuple[SegStep, ...] = () # ordered; MAY be empty while boundaries is not
78
+ boundaries: tuple[float, ...] = () # the raw cut set, always
79
+ unit: str = "seconds" # "eight" | "rep" | "seconds"
80
+ grid: MetricGrid | None = None
81
+ confidence: float = 0.0
82
+ method: str = "" # which capability ran (the ADR's "reported default")
83
+ flags: tuple[str, ...] = () # 'naming-abstained', 'no-signal', 'try: ...'
84
+ elapsed_s: float = 0.0
85
+ spent_usd: float = 0.0
86
+
87
+
88
+ # ── consistency: what no segmentation may contain ───────────────────────────
89
+
90
+
91
+ def edges(steps: Iterable[SegStep]) -> set[float]:
92
+ """Every span start and end of ``steps`` (top level)."""
93
+ return {t for step in steps for span in step.spans for t in span}
94
+
95
+
96
+ def overlaps(a: Span, b: Span) -> bool:
97
+ """True when the two spans share a positive length of time."""
98
+ return max(a[0], b[0]) < min(a[1], b[1])
99
+
100
+
101
+ #: Overlap flags listed one per pair before the rest are summarised: the
102
+ #: check runs on every segmentation, and dense overlaps make pairs by the
103
+ #: hundred thousand.
104
+ DFLT_MAX_OVERLAP_FLAGS = 20
105
+
106
+
107
+ def inconsistencies(
108
+ seg: Segmentation, *, max_overlap_flags: int = DFLT_MAX_OVERLAP_FLAGS
109
+ ) -> tuple[str, ...]:
110
+ """What no segmentation may contain, as ``inconsistent:`` flags — never
111
+ repaired, never raised: empty spans, spans of different steps that
112
+ overlap, children outside their parent, and step edges missing from
113
+ ``boundaries``. :func:`paces.segmenters.segment` runs it on every result,
114
+ whichever segmenter produced it and whether or not a correction log ran.
115
+
116
+ >>> inconsistencies(Segmentation(
117
+ ... steps=(SegStep(id="a", spans=((0.0, 10.0),)),
118
+ ... SegStep(id="b", spans=((8.0, 20.0),))),
119
+ ... boundaries=(0.0, 10.0, 20.0)))
120
+ ("inconsistent: steps 'a' and 'b' overlap (8–10)", 'inconsistent: step edges not in boundaries: 8')
121
+ """
122
+
123
+ spans = sorted((span, step.id) for step in seg.steps for span in step.spans)
124
+ flags = [
125
+ f"inconsistent: step {step_id!r} has an empty span at {time_str(span[0])}"
126
+ for span, step_id in spans
127
+ if span[0] >= span[1]
128
+ ]
129
+ # A sweep, not all pairs: it runs on every segmentation, and subtitle
130
+ # cues can make thousands of steps. Only spans still open can overlap.
131
+ active: list[tuple[Span, str]] = []
132
+ overlap_flags: list[str] = []
133
+ for b, b_id in spans:
134
+ active = [(a, a_id) for a, a_id in active if a[1] > b[0]]
135
+ overlap_flags += [
136
+ f"inconsistent: steps {a_id!r} and {b_id!r} overlap "
137
+ f"({time_str(max(a[0], b[0]))}–{time_str(min(a[1], b[1]))})"
138
+ for a, a_id in active
139
+ if a_id != b_id and overlaps(a, b)
140
+ ]
141
+ active.append((b, b_id))
142
+ flags += overlap_flags[:max_overlap_flags]
143
+ if len(overlap_flags) > max_overlap_flags:
144
+ flags.append(
145
+ f"inconsistent: {len(overlap_flags) - max_overlap_flags} more "
146
+ "overlapping pairs not listed"
147
+ )
148
+ flags += [
149
+ f"inconsistent: child {child.id!r} of {step.id!r} lies outside its parent"
150
+ for step in seg.steps
151
+ for child in step.children
152
+ for span in child.spans
153
+ if not any(p[0] <= span[0] and span[1] <= p[1] for p in step.spans)
154
+ ]
155
+ missing = sorted(edges(seg.steps) - set(seg.boundaries))
156
+ if missing:
157
+ listed = ", ".join(time_str(t) for t in missing)
158
+ flags.append(f"inconsistent: step edges not in boundaries: {listed}")
159
+ return tuple(flags)
160
+
161
+
162
+ def time_str(t: float) -> str:
163
+ """A time as paces' millisecond wire decimal — the one formatter every
164
+ time producer shares (the ask page's ``timeStr`` mirrors it).
165
+
166
+ >>> time_str(95.80000001), time_str(10.0625) # exact binary ties round half-even
167
+ ('95.8', '10.062')
168
+ """
169
+ return decimal_str(t, places=TIME_PLACES)
@@ -52,7 +52,9 @@ from paces.segmentation import ( # noqa: F401 — re-exported; their home is th
52
52
  DFLT_GRID_CONFIDENCE,
53
53
  SegStep,
54
54
  Segmentation,
55
+ TIME_PLACES,
55
56
  Span,
57
+ inconsistencies,
56
58
  )
57
59
  from paces.timed_text import normalize_timed_text
58
60
 
@@ -386,8 +388,8 @@ def _facts(
386
388
  facts.add("steps")
387
389
  if not _undated_ids(steps):
388
390
  facts.add("steps.durations") # children too: placement needs them all
389
- if any(row["spans"] for row in steps):
390
- facts.add("steps.spans")
391
+ if any(row["spans"] for row in _walk_rows(steps)):
392
+ facts.add("steps.spans") # like steps.durations: the whole tree
391
393
  if boundaries:
392
394
  facts.add("boundaries")
393
395
  if grid is not None and grid.tempo_bpm is not None and grid.origin is not None:
@@ -589,7 +591,7 @@ def segment(
589
591
  grid=out_grid,
590
592
  confidence=result.confidence,
591
593
  method=capability.name,
592
- flags=result.flags + extra_flags,
594
+ flags=result.flags + extra_flags + inconsistencies(result),
593
595
  elapsed_s=time.perf_counter() - started,
594
596
  spent_usd=result.spent_usd,
595
597
  )
@@ -598,6 +600,11 @@ def segment(
598
600
  # ── the two v1 segmenters ───────────────────────────────────────────────────
599
601
 
600
602
 
603
+ #: Places a unit offset (cumulative eights, reps…) is rounded to before it
604
+ #: becomes seconds: far below any real duration, far above float noise.
605
+ UNIT_PLACES = 9
606
+
607
+
601
608
  def place_on_grid(steps: Sequence, grid: MetricGrid | Mapping) -> Segmentation:
602
609
  """Place an ordered step list on the timeline using a metric grid.
603
610
 
@@ -632,12 +639,20 @@ def place_on_grid(steps: Sequence, grid: MetricGrid | Mapping) -> Segmentation:
632
639
  spu = seconds_per_unit(grid)
633
640
  origin = float(grid.origin)
634
641
 
642
+ def _seconds(units: float) -> float:
643
+ return round(origin + round(units, UNIT_PLACES) * spu, TIME_PLACES)
644
+
635
645
  def _place(rows: list[dict], at_units: float) -> list[SegStep]:
636
646
  placed = []
637
647
  for row in rows:
638
648
  duration = row["duration"]
639
- start = origin + at_units * spu
640
- end = origin + (at_units + duration) * spu
649
+ # Rounded to the millisecond, like boundaries below: a child's
650
+ # end summed piecewise ((a+0.1)+0.2) and its parent's (a+0.3) are
651
+ # the same time and must compare equal.
652
+ # Unit offsets are rounded first too, or a piecewise sum and the
653
+ # whole land on opposite sides of a half-millisecond tie.
654
+ start = _seconds(at_units)
655
+ end = _seconds(at_units + duration)
641
656
  placed.append(
642
657
  SegStep(
643
658
  id=row["id"],
@@ -652,7 +667,7 @@ def place_on_grid(steps: Sequence, grid: MetricGrid | Mapping) -> Segmentation:
652
667
  at_units += duration
653
668
  return placed
654
669
 
655
- boundaries = tuple(origin + units * spu for units in _cumulative(rows))
670
+ boundaries = tuple(_seconds(units) for units in _cumulative(rows))
656
671
  return Segmentation(
657
672
  steps=tuple(_place(rows, 0.0)),
658
673
  boundaries=boundaries,
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "paces"
9
- version = "0.0.22"
9
+ version = "0.0.24"
10
10
  description = "Turn instructional media into structured, interactive learning material"
11
11
  readme = "README.md"
12
12
  license = "MIT"
@@ -19,6 +19,7 @@ from paces.corrections import (
19
19
  split,
20
20
  to_wire,
21
21
  )
22
+ from paces.segmentation import inconsistencies
22
23
  from paces.segmenters import Capability, Segmentation, SegStep, segment
23
24
 
24
25
  CUTS = [0, 10, 20, 30]
@@ -241,9 +242,9 @@ def _flagged(seg, prefix):
241
242
 
242
243
 
243
244
  def test_times_are_kept_to_the_millisecond_so_the_wire_is_lossless():
244
- """A grid-placed boundary is an ugly float; the wire writes 3 places."""
245
- grid_placed = segment(steps=GRID_ROWS, grid=GRID)
246
- b1, b2 = grid_placed.boundaries[1:3]
245
+ """Any computed time can be an ugly float; the wire writes 3 places.
246
+ (Grid-placed times are already rounded since #53; these are raw.)"""
247
+ b1, b2 = 0.37 + 3.8803 / 7, 4.1 + 0.1 + 0.2
247
248
  assert b1 != round(b1, 3) # the premise: an unrounded time
248
249
  for c in [pin((b1, b2)), split(0.8550444), forbid(b1, (b1, b2)), split(0.1 + 0.2)]:
249
250
  assert from_wire(json.loads(json.dumps(to_wire(c)))) == c
@@ -336,7 +337,8 @@ def test_pin_moves_only_the_abutting_time_order_neighbours():
336
337
  out = replay(overlapping, [pin((5, 20), anchor_t=15)])
337
338
  assert [s.spans for s in out.steps] == [s.spans for s in overlapping.steps]
338
339
  assert _flagged(out, "correction-infeasible: pin@15 (would overlap step 'x')")
339
- assert _flagged(out, "inconsistent: steps") # the input itself overlapped
340
+ # the input itself overlapped: segment() says so, replay does not check
341
+ assert any(f.startswith("inconsistent: steps") for f in inconsistencies(out))
340
342
 
341
343
 
342
344
  def test_pin_clips_its_children_and_flags_any_it_drops():
@@ -468,15 +470,18 @@ def test_a_boundaries_only_segmentation_can_still_be_split_and_merged():
468
470
  assert out.flags == ("corrected: merge@10", "corrected: split@5")
469
471
 
470
472
 
471
- def test_the_consistency_pass_flags_what_replay_cannot_repair():
473
+ def test_the_consistency_check_flags_what_replay_cannot_repair():
472
474
  seg = _seg(
473
475
  SegStep(id="a", spans=((0.0, 10.0),)),
474
476
  SegStep(id="b", spans=((8.0, 20.0),)),
475
477
  boundaries=(0.0, 10.0, 20.0),
476
478
  )
477
479
  out = replay(seg, [absent(25)])
478
- assert "inconsistent: steps 'a' and 'b' overlap (8–10)" in out.flags
479
- assert "inconsistent: step edges not in boundaries: 8" in out.flags
480
+ assert inconsistencies(out) == (
481
+ "inconsistent: steps 'a' and 'b' overlap (8–10)",
482
+ "inconsistent: step edges not in boundaries: 8",
483
+ )
484
+ assert not _flagged(out, "inconsistent") # checking is segment()'s job
480
485
 
481
486
 
482
487
  def test_corrections_land_on_grid_placed_results():
@@ -563,7 +568,7 @@ def test_split_trims_a_child_that_straddles_the_cut():
563
568
  assert not _flagged(out, "inconsistent")
564
569
 
565
570
 
566
- def test_the_consistency_pass_flags_a_child_outside_its_parent():
571
+ def test_the_consistency_check_flags_a_child_outside_its_parent():
567
572
  seg = _seg(
568
573
  SegStep(
569
574
  id="v",
@@ -572,7 +577,9 @@ def test_the_consistency_pass_flags_a_child_outside_its_parent():
572
577
  )
573
578
  )
574
579
  out = replay(seg, [absent(99)])
575
- assert "inconsistent: child 'c' of 'v' lies outside its parent" in out.flags
580
+ assert "inconsistent: child 'c' of 'v' lies outside its parent" in (
581
+ inconsistencies(out)
582
+ )
576
583
 
577
584
 
578
585
  def test_merge_into_a_named_step_keeps_that_steps_record():
@@ -615,3 +622,63 @@ def test_merge_exactly_on_an_end_of_a_boundaries_only_segmentation_is_unmatched(
615
622
  assert out.flags == (
616
623
  "correction-unmatched: merge@11 (that is an end, not a cut between steps)",
617
624
  )
625
+
626
+
627
+ # ── #36: a log that changes nothing leaves the output alone ─────────────────
628
+
629
+
630
+ def test_a_no_op_log_leaves_the_output_exactly_as_without_one():
631
+ plain = segment(boundaries=[0, 10, 10, 20]) # a duplicate cut: an empty step
632
+ logged = segment(boundaries=[0, 10, 10, 20], corrections=[absent(99)])
633
+ assert logged.boundaries == plain.boundaries == (0.0, 10.0, 10.0, 20.0)
634
+ assert logged.steps == plain.steps
635
+ assert [f for f in logged.flags if not f.startswith("correction-")] == list(
636
+ plain.flags
637
+ )
638
+
639
+
640
+ def test_every_segmentation_is_checked_log_or_not():
641
+ """The consistency check runs on every result, not only after a replay."""
642
+ plain = segment(boundaries=[0, 10, 10, 20])
643
+ assert "inconsistent: step 'seg-2' has an empty span at 10" in plain.flags
644
+
645
+
646
+ @pytest.mark.parametrize(
647
+ "kwargs",
648
+ [
649
+ {"verb": "split", "anchor_t": True},
650
+ {"verb": "split", "anchor_t": 1.0, "author": 5},
651
+ {"verb": "split", "anchor_t": 1.0, "step_id": 3},
652
+ ],
653
+ )
654
+ def test_correction_fields_have_their_types(kwargs):
655
+ with pytest.raises(ValueError, match="must be"):
656
+ Correction(**kwargs)
657
+
658
+
659
+ def test_the_overlap_check_scales_to_thousands_of_steps():
660
+ import time
661
+
662
+ n = 5000
663
+ steps = tuple(SegStep(id=f"s{i}", spans=((float(i), i + 1.0),)) for i in range(n))
664
+ seg = Segmentation(steps=steps, boundaries=tuple(float(i) for i in range(n + 1)))
665
+ started = time.perf_counter()
666
+ assert inconsistencies(seg) == ()
667
+ assert time.perf_counter() - started < 1.0 # all-pairs took ~seconds here
668
+
669
+
670
+ def test_a_touched_cut_set_is_rebuilt_even_if_it_ends_where_it_started():
671
+ """The review's fuzz case: the duplicate 50 must not survive its step."""
672
+ seg = segment(
673
+ boundaries=[10, 40, 50, 50],
674
+ corrections=[pin((40, 42.5)), merge(42.5), merge(22.5)],
675
+ )
676
+ assert seg.boundaries == tuple(sorted(set(seg.boundaries)))
677
+
678
+
679
+ def test_dense_overlaps_are_summarised_not_listed_by_the_hundred_thousand():
680
+ steps = tuple(SegStep(id=f"s{i}", spans=((0.0, 10.0),)) for i in range(30))
681
+ flags = inconsistencies(Segmentation(steps=steps, boundaries=(0.0, 10.0)))
682
+ overlap = [f for f in flags if " overlap " in f]
683
+ assert len(overlap) == 20
684
+ assert "inconsistent: 415 more overlapping pairs not listed" in flags
@@ -8,7 +8,13 @@ import pytest
8
8
 
9
9
  from paces.model import MetricGrid
10
10
  from paces.projection import to_document
11
- from paces.segmenters import Capability, capabilities, register, segment
11
+ from paces.segmenters import (
12
+ Capability,
13
+ Segmentation,
14
+ capabilities,
15
+ register,
16
+ segment,
17
+ )
12
18
 
13
19
  GRID = {"unit": "eight", "subdivisions": 8, "tempoBpm": "129.2", "origin": "51.2"}
14
20
  #: The POC routine as (name, eights) pairs — kickoff's "common real case".
@@ -337,3 +343,46 @@ def test_grid_measured_rejects_bad_durations_before_measuring(
337
343
  steps = [("a", 1), {"name": "p", "duration": 2, "children": [("c", bad)]}]
338
344
  with pytest.raises(ValueError, match=r"positive, finite durations: \['c'\]"):
339
345
  segment(str(media), steps=steps, segmenter="grid-measured")
346
+
347
+
348
+ def _spans_reader(inputs):
349
+ return Segmentation(boundaries=(0.0, 1.0))
350
+
351
+
352
+ def test_the_spans_fact_reads_the_whole_step_tree():
353
+ """A span on a child counts, as a duration on a child does (#51): a
354
+ segmenter that needs ``steps.spans`` is selectable from a nested span."""
355
+ needs_spans = Capability(
356
+ name="needs-spans",
357
+ gives="segmentation",
358
+ summary="test double",
359
+ target=f"{__name__}:_spans_reader",
360
+ needs=frozenset({"steps", "steps.spans"}),
361
+ )
362
+ catalog = {"needs-spans": needs_spans}
363
+ nested = [
364
+ {"name": "p", "duration": 2, "children": [{"name": "c", "spans": [(1, 2)]}]}
365
+ ]
366
+ assert segment(steps=nested, catalog=catalog).method == "needs-spans"
367
+ refused = segment(steps=[("p", 2)], catalog=catalog)
368
+ assert refused.method == "" and "no-signal" in refused.flags
369
+
370
+
371
+ def test_grid_placed_children_with_fractional_durations_are_consistent():
372
+ """(a+0.1)+0.2 and a+0.3 are the same time once placed (review of #53)."""
373
+ steps = [
374
+ ("a", 2),
375
+ {"name": "b", "duration": 0.3, "children": [("c1", 0.1), ("c2", 0.2)]},
376
+ ]
377
+ seg = segment(steps=steps, grid={**GRID, "tempoBpm": "120", "origin": "0"})
378
+ assert not [f for f in seg.flags if f.startswith("inconsistent")]
379
+
380
+
381
+ @pytest.mark.parametrize("origin", ["1.2345", "0.0005", "0.0015"])
382
+ def test_grid_placed_children_survive_half_millisecond_origins(origin):
383
+ steps = [
384
+ ("a", 2),
385
+ {"name": "b", "duration": 0.3, "children": [("c1", 0.1), ("c2", 0.2)]},
386
+ ]
387
+ seg = segment(steps=steps, grid={**GRID, "tempoBpm": "120", "origin": origin})
388
+ assert not [f for f in seg.flags if f.startswith("inconsistent")]
@@ -1,74 +0,0 @@
1
- """What a segmenter returns: :class:`Segmentation`, its :class:`SegStep` records and
2
- the ``Span`` they cover — the analysis-result types, with nothing else.
3
-
4
- They live apart from :mod:`paces.segmenters` (the registry and the
5
- ``segment()`` facade) so a module that only consumes or reshapes a result —
6
- :mod:`paces.corrections`, :mod:`paces.projection`, :mod:`paces.ask`, the
7
- evidence layer — depends on the types and not on the facade, and the facade
8
- can import those modules without a cycle. :mod:`paces.segmenters` re-exports
9
- every name here, so ``from paces.segmenters import Segmentation`` keeps
10
- working.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- from collections.abc import Mapping
16
- from dataclasses import dataclass, field
17
- from typing import Any
18
-
19
- from paces.model import MetricGrid
20
-
21
- __all__ = [
22
- "DFLT_CHAPTERS_CONFIDENCE",
23
- "DFLT_EXPLICIT_CONFIDENCE",
24
- "DFLT_GRID_CONFIDENCE",
25
- "SegStep",
26
- "Segmentation",
27
- "Span",
28
- ]
29
-
30
- Span = tuple[float, float] # seconds, half-open
31
-
32
- # Confidence constants: placement from user-supplied inputs is arithmetic, so
33
- # what these grade is trust in the inputs, not in any inference.
34
- DFLT_EXPLICIT_CONFIDENCE = 0.95 # a human said so
35
- DFLT_GRID_CONFIDENCE = 0.9 # exact given the grid; the grid itself may drift
36
- DFLT_CHAPTERS_CONFIDENCE = 0.9 # the author said so, in metadata
37
-
38
-
39
- @dataclass(frozen=True, slots=True, kw_only=True)
40
- class SegStep:
41
- """One named piece of the media, as an analysis result.
42
-
43
- ``name=''`` means "found, not named" — a VALID state, not an error.
44
- ``spans`` is a tuple because a step legitimately appears in several places
45
- (run-through AND breakdown).
46
- """
47
-
48
- id: str
49
- name: str = ""
50
- spans: tuple[Span, ...] = ()
51
- confidence: float = 0.0
52
- children: tuple["SegStep", ...] = ()
53
- source: str = "" # which capability produced it
54
- evidence: Mapping[str, Any] = field(default_factory=dict)
55
-
56
-
57
- @dataclass(frozen=True, slots=True, kw_only=True)
58
- class Segmentation:
59
- """What every segmenter returns. Reviewable, re-runnable, arguable.
60
-
61
- ``boundaries`` is separate from ``steps`` and populated even when naming
62
- failed: nine excellent unnamed cuts are a useful, honest result. ``steps``
63
- may be empty while ``boundaries`` is not.
64
- """
65
-
66
- steps: tuple[SegStep, ...] = () # ordered; MAY be empty while boundaries is not
67
- boundaries: tuple[float, ...] = () # the raw cut set, always
68
- unit: str = "seconds" # "eight" | "rep" | "seconds"
69
- grid: MetricGrid | None = None
70
- confidence: float = 0.0
71
- method: str = "" # which capability ran (the ADR's "reported default")
72
- flags: tuple[str, ...] = () # 'naming-abstained', 'no-signal', 'try: ...'
73
- elapsed_s: float = 0.0
74
- spent_usd: float = 0.0
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
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