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.
- {paces-0.0.22 → paces-0.0.24}/PKG-INFO +1 -1
- {paces-0.0.22 → paces-0.0.24}/paces/corrections.py +30 -61
- paces-0.0.24/paces/segmentation.py +169 -0
- {paces-0.0.22 → paces-0.0.24}/paces/segmenters.py +21 -6
- {paces-0.0.22 → paces-0.0.24}/pyproject.toml +1 -1
- {paces-0.0.22 → paces-0.0.24}/tests/test_corrections.py +76 -9
- {paces-0.0.22 → paces-0.0.24}/tests/test_segment.py +50 -1
- paces-0.0.22/paces/segmentation.py +0 -74
- {paces-0.0.22 → paces-0.0.24}/.gitignore +0 -0
- {paces-0.0.22 → paces-0.0.24}/LICENSE +0 -0
- {paces-0.0.22 → paces-0.0.24}/README.md +0 -0
- {paces-0.0.22 → paces-0.0.24}/docs/README.md +0 -0
- {paces-0.0.22 → paces-0.0.24}/docs/alignment/README.md +0 -0
- {paces-0.0.22 → paces-0.0.24}/docs/poc-reference/README.md +0 -0
- {paces-0.0.22 → paces-0.0.24}/docs/poc-reference/artifacts/clips.json +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/__init__.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/__main__.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/ask.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/bodies/__init__.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/bodies/_base.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/bodies/derivation.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/bodies/document.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/bodies/signal.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/bodies/step.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/cues.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/data/ask/ask.css +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/data/ask/ask.html +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/data/ask/ask.js +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/derivation.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/edits.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/evidence.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/excerpts.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/measure.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/model.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/pose.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/projection.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/render.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/timed_text.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/paces/tools.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/audio_synth.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/data/body_schemas.json +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/data/routine.json +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/poc_fixture.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_ask.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_chapters.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_cli_surface.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_cues.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_derivation.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_edit_protection_residuals.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_edits.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_evidence.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_evidence_schemas.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_excerpts.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_measure.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_pose.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_render_media.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_roundtrip_poc.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_smoke.py +0 -0
- {paces-0.0.22 → paces-0.0.24}/tests/test_vertical_slice.py +0 -0
- {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.
|
|
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``).
|
|
300
|
-
|
|
301
|
-
|
|
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))
|
|
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
|
-
|
|
640
|
-
end
|
|
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(
|
|
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,
|
|
@@ -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
|
-
"""
|
|
245
|
-
|
|
246
|
-
b1, b2 =
|
|
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
|
-
|
|
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
|
|
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
|
|
479
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|