paces 0.0.15__tar.gz → 0.0.17__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 (58) hide show
  1. {paces-0.0.15 → paces-0.0.17}/PKG-INFO +3 -1
  2. {paces-0.0.15 → paces-0.0.17}/README.md +2 -0
  3. paces-0.0.17/paces/ask.py +252 -0
  4. paces-0.0.17/paces/corrections.py +714 -0
  5. paces-0.0.17/paces/data/ask/ask.css +78 -0
  6. paces-0.0.17/paces/data/ask/ask.html +75 -0
  7. paces-0.0.17/paces/data/ask/ask.js +499 -0
  8. {paces-0.0.15 → paces-0.0.17}/paces/segmenters.py +40 -1
  9. {paces-0.0.15 → paces-0.0.17}/paces/tools.py +51 -2
  10. {paces-0.0.15 → paces-0.0.17}/pyproject.toml +1 -1
  11. paces-0.0.17/tests/test_ask.py +541 -0
  12. paces-0.0.17/tests/test_corrections.py +617 -0
  13. {paces-0.0.15 → paces-0.0.17}/.gitignore +0 -0
  14. {paces-0.0.15 → paces-0.0.17}/LICENSE +0 -0
  15. {paces-0.0.15 → paces-0.0.17}/docs/README.md +0 -0
  16. {paces-0.0.15 → paces-0.0.17}/docs/alignment/README.md +0 -0
  17. {paces-0.0.15 → paces-0.0.17}/docs/poc-reference/README.md +0 -0
  18. {paces-0.0.15 → paces-0.0.17}/docs/poc-reference/artifacts/clips.json +0 -0
  19. {paces-0.0.15 → paces-0.0.17}/paces/__init__.py +0 -0
  20. {paces-0.0.15 → paces-0.0.17}/paces/__main__.py +0 -0
  21. {paces-0.0.15 → paces-0.0.17}/paces/bodies/__init__.py +0 -0
  22. {paces-0.0.15 → paces-0.0.17}/paces/bodies/_base.py +0 -0
  23. {paces-0.0.15 → paces-0.0.17}/paces/bodies/derivation.py +0 -0
  24. {paces-0.0.15 → paces-0.0.17}/paces/bodies/document.py +0 -0
  25. {paces-0.0.15 → paces-0.0.17}/paces/bodies/signal.py +0 -0
  26. {paces-0.0.15 → paces-0.0.17}/paces/bodies/step.py +0 -0
  27. {paces-0.0.15 → paces-0.0.17}/paces/cues.py +0 -0
  28. {paces-0.0.15 → paces-0.0.17}/paces/derivation.py +0 -0
  29. {paces-0.0.15 → paces-0.0.17}/paces/edits.py +0 -0
  30. {paces-0.0.15 → paces-0.0.17}/paces/evidence.py +0 -0
  31. {paces-0.0.15 → paces-0.0.17}/paces/excerpts.py +0 -0
  32. {paces-0.0.15 → paces-0.0.17}/paces/measure.py +0 -0
  33. {paces-0.0.15 → paces-0.0.17}/paces/model.py +0 -0
  34. {paces-0.0.15 → paces-0.0.17}/paces/pose.py +0 -0
  35. {paces-0.0.15 → paces-0.0.17}/paces/projection.py +0 -0
  36. {paces-0.0.15 → paces-0.0.17}/paces/render.py +0 -0
  37. {paces-0.0.15 → paces-0.0.17}/paces/timed_text.py +0 -0
  38. {paces-0.0.15 → paces-0.0.17}/tests/audio_synth.py +0 -0
  39. {paces-0.0.15 → paces-0.0.17}/tests/data/body_schemas.json +0 -0
  40. {paces-0.0.15 → paces-0.0.17}/tests/data/routine.json +0 -0
  41. {paces-0.0.15 → paces-0.0.17}/tests/poc_fixture.py +0 -0
  42. {paces-0.0.15 → paces-0.0.17}/tests/test_chapters.py +0 -0
  43. {paces-0.0.15 → paces-0.0.17}/tests/test_cli_surface.py +0 -0
  44. {paces-0.0.15 → paces-0.0.17}/tests/test_cues.py +0 -0
  45. {paces-0.0.15 → paces-0.0.17}/tests/test_derivation.py +0 -0
  46. {paces-0.0.15 → paces-0.0.17}/tests/test_edit_protection_residuals.py +0 -0
  47. {paces-0.0.15 → paces-0.0.17}/tests/test_edits.py +0 -0
  48. {paces-0.0.15 → paces-0.0.17}/tests/test_evidence.py +0 -0
  49. {paces-0.0.15 → paces-0.0.17}/tests/test_evidence_schemas.py +0 -0
  50. {paces-0.0.15 → paces-0.0.17}/tests/test_excerpts.py +0 -0
  51. {paces-0.0.15 → paces-0.0.17}/tests/test_measure.py +0 -0
  52. {paces-0.0.15 → paces-0.0.17}/tests/test_pose.py +0 -0
  53. {paces-0.0.15 → paces-0.0.17}/tests/test_render_media.py +0 -0
  54. {paces-0.0.15 → paces-0.0.17}/tests/test_roundtrip_poc.py +0 -0
  55. {paces-0.0.15 → paces-0.0.17}/tests/test_segment.py +0 -0
  56. {paces-0.0.15 → paces-0.0.17}/tests/test_smoke.py +0 -0
  57. {paces-0.0.15 → paces-0.0.17}/tests/test_vertical_slice.py +0 -0
  58. {paces-0.0.15 → paces-0.0.17}/tests/video_synth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paces
3
- Version: 0.0.15
3
+ Version: 0.0.17
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
@@ -219,6 +219,8 @@ decisions, not measurements. Full table and rationale in
219
219
  |---|---|
220
220
  | cut media into steps | `segment(media, steps=..., grid=...)` → `Segmentation` |
221
221
  | explicit/human boundaries | `segment(media, boundaries=[...], steps=[names])` |
222
+ | keep a human's corrections across re-runs | `segment(..., corrections=[split(t), merge(t), pin((s, e)), forbid(t, (s, e)), absent(t)])` — from `paces.corrections`; a time-keyed log, replayed as constraints, anything it could not honour flagged |
223
+ | ask a human to check a segmentation | `paces ask seg.json --media video.mp4 --output ask.html` → they confirm the step count, tap along to add cuts (or, when nothing was found, tap the cuts themselves), strike wrong ones → `answers.json` → `paces segment ... --answers answers.json`. The versioned proposal/answers JSON (`load_answers` → `Answers(k, corrections, boundaries)`) is the contract any other UI (or an agent) can speak |
222
224
  | use the video's own chapters | `segment(media, metadata=<yt-dlp info.json>)` |
223
225
  | measure the grid from the media | `segment(local_media, steps=[(name, counts), ...])` — no grid needed; tempo + structure measured, origin estimated and flagged (`pip install paces[audio]`) |
224
226
  | protect edits from regeneration | `apply_edits(doc, patches, by="user:you")` + `merge_regenerated(committed, fresh)` |
@@ -178,6 +178,8 @@ decisions, not measurements. Full table and rationale in
178
178
  |---|---|
179
179
  | cut media into steps | `segment(media, steps=..., grid=...)` → `Segmentation` |
180
180
  | explicit/human boundaries | `segment(media, boundaries=[...], steps=[names])` |
181
+ | keep a human's corrections across re-runs | `segment(..., corrections=[split(t), merge(t), pin((s, e)), forbid(t, (s, e)), absent(t)])` — from `paces.corrections`; a time-keyed log, replayed as constraints, anything it could not honour flagged |
182
+ | ask a human to check a segmentation | `paces ask seg.json --media video.mp4 --output ask.html` → they confirm the step count, tap along to add cuts (or, when nothing was found, tap the cuts themselves), strike wrong ones → `answers.json` → `paces segment ... --answers answers.json`. The versioned proposal/answers JSON (`load_answers` → `Answers(k, corrections, boundaries)`) is the contract any other UI (or an agent) can speak |
181
183
  | use the video's own chapters | `segment(media, metadata=<yt-dlp info.json>)` |
182
184
  | measure the grid from the media | `segment(local_media, steps=[(name, counts), ...])` — no grid needed; tempo + structure measured, origin estimated and flagged (`pip install paces[audio]`) |
183
185
  | protect edits from regeneration | `apply_edits(doc, patches, by="user:you")` + `merge_regenerated(committed, fresh)` |
@@ -0,0 +1,252 @@
1
+ """The ask segmenter's interaction half: what the machine proposes, and the page
2
+ a human answers it on (issue #10, ``docs/alignment/07 §7.2–7.4``).
3
+
4
+ "Ask the user" is first-class, and the gap between a 30-minute chore and a
5
+ 2-minute confirmation is the *proposal*. So this module is three pure pieces
6
+ around one JSON contract:
7
+
8
+ - :func:`propose` — what the machine pre-computes from a :class:`Segmentation`:
9
+ a proposed step count K with the evidence for it (the cheapest, highest-value
10
+ question comes first, §7.3 step 1), the candidate cuts, the steps, and the
11
+ snap targets a tap lands on (the cuts, plus the grid's unit ticks when there
12
+ is a grid: on rhythmic content a boundary is a downbeat).
13
+ - :func:`render_ask_page` — the default UI: one self-contained static HTML page
14
+ (no server, no framework, no dependency) that asks for K, lets a human tap
15
+ along with the media to add cuts, strike cuts that are wrong and mark steps
16
+ that are not in the media, and exports the answers as JSON.
17
+ - :func:`load_answers` — reads those answers back as :class:`Answers`: the
18
+ step count (only when the human touched it), a :mod:`paces.corrections`
19
+ log, and — when the proposal had nothing to correct (the refused state) —
20
+ the boundaries the human tapped, so the answers re-enter the next
21
+ :func:`~paces.segmenters.segment` run as constraints or as boundaries.
22
+
23
+ **The seam is the contract, not a callback.** Any other UI — the real
24
+ frontend, a CLI prompt, a notebook widget, or an agent with the proposal in
25
+ context (§7.4) — reads the proposal JSON and writes the answers JSON; nothing
26
+ here has to change for it.
27
+
28
+ >>> from paces.segmenters import segment
29
+ >>> proposal = propose(segment(boundaries=[0, 10, 20, 30]))
30
+ >>> proposal["k"], proposal["cuts"]
31
+ (3, ['10', '20'])
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import html
37
+ import json
38
+ import math
39
+ import re
40
+ from collections.abc import Mapping
41
+ from dataclasses import dataclass
42
+ from importlib.resources import files
43
+
44
+ from paces.corrections import (
45
+ DFLT_MERGE_TOLERANCE_S,
46
+ DFLT_MIN_STEP_S,
47
+ Correction,
48
+ normalize_corrections,
49
+ time_str,
50
+ )
51
+ from paces.model import seconds_per_unit
52
+ from paces.segmenters import Segmentation
53
+
54
+ PROPOSAL_KIND = "PacesAskProposal"
55
+ ANSWERS_KIND = "PacesAskAnswers"
56
+ #: The proposal/answers contract version. Both documents carry it; a reader
57
+ #: refuses any other, so a page and a loader of different ages fail loudly.
58
+ CONTRACT_VERSION = 1
59
+
60
+ #: How far a tap may land from a snap target and still snap to it (§7.3 step
61
+ #: 3). It is the merge tolerance on purpose: both absorb the same thing, the
62
+ #: imprecision of a human tapping along, so one number serves both.
63
+ DFLT_SNAP_S = DFLT_MERGE_TOLERANCE_S
64
+ #: Above this many grid ticks the page snaps to cuts only — a dense tick list
65
+ #: would make every tap snap somewhere and bloat the page.
66
+ DFLT_MAX_GRID_TICKS = 2000
67
+
68
+ _ASSETS = files("paces") / "data" / "ask"
69
+ _ANSWERS_KEYS = {"kind", "version", "k", "corrections", "boundaries", "proposal"}
70
+
71
+
72
+ def _grid_ticks(seg: Segmentation, lo: float, hi: float, *, limit: int) -> list[str]:
73
+ """The grid's unit boundaries inside ``[lo, hi]`` (downbeats, for a dance)."""
74
+ grid = seg.grid
75
+ spu = seconds_per_unit(grid) if grid is not None else None
76
+ if not spu or grid.origin is None or hi <= lo:
77
+ return []
78
+ origin = float(grid.origin)
79
+ first, last = math.ceil((lo - origin) / spu), math.floor((hi - origin) / spu)
80
+ if last - first + 1 > limit:
81
+ return []
82
+ return [time_str(origin + n * spu) for n in range(first, last + 1)]
83
+
84
+
85
+ def propose(
86
+ seg: Segmentation,
87
+ *,
88
+ media: str | None = None,
89
+ title: str = "",
90
+ snap_s: float = DFLT_SNAP_S,
91
+ min_step_s: float = DFLT_MIN_STEP_S,
92
+ max_grid_ticks: int = DFLT_MAX_GRID_TICKS,
93
+ ) -> dict:
94
+ """What the machine pre-computes for a human to confirm — a JSON-ready dict.
95
+
96
+ ``k`` is the proposed step count (``None`` when the segmentation has
97
+ nothing to count) and ``k_evidence`` says where it came from, so the human
98
+ confirms a number they can see the reasoning for. Times are decimal
99
+ strings, as everywhere on paces' wire; cuts that are the same millisecond
100
+ are one cut. ``minStepS`` is the narrowest step a split may make
101
+ (:data:`~paces.corrections.DFLT_MIN_STEP_S`): the page ignores a tap
102
+ closer than that to a cut, as replay would.
103
+
104
+ >>> propose(Segmentation(boundaries=(0, 10, 10.0001, 20)))["cuts"]
105
+ ['10']
106
+ """
107
+ cuts = sorted({float(time_str(t)) for t in seg.boundaries})
108
+ interior = cuts[1:-1]
109
+ if seg.steps:
110
+ k, why = (
111
+ len(seg.steps),
112
+ f"{len(seg.steps)} steps found by {seg.method or 'the segmenter'}",
113
+ )
114
+ elif len(cuts) >= 2:
115
+ k, why = len(cuts) - 1, f"{len(cuts)} cuts make {len(cuts) - 1} spans (unnamed)"
116
+ else:
117
+ k, why = None, "nothing to count yet: the segmenter found no cuts"
118
+ extent = (cuts[0], cuts[-1]) if len(cuts) >= 2 else None
119
+ ticks = _grid_ticks(seg, *extent, limit=max_grid_ticks) if extent else []
120
+ return {
121
+ "kind": PROPOSAL_KIND,
122
+ "version": CONTRACT_VERSION,
123
+ "answersKind": ANSWERS_KIND,
124
+ "title": title,
125
+ "media": media,
126
+ "method": seg.method,
127
+ "flags": list(seg.flags),
128
+ "k": k,
129
+ "kEvidence": why,
130
+ "extent": [time_str(t) for t in extent] if extent else None,
131
+ "cuts": [time_str(t) for t in interior],
132
+ "steps": [
133
+ {
134
+ "id": step.id,
135
+ "name": step.name,
136
+ "spans": [[time_str(a), time_str(b)] for a, b in step.spans],
137
+ }
138
+ for step in seg.steps
139
+ ],
140
+ "minStepS": time_str(min_step_s),
141
+ "snap": {
142
+ "windowS": time_str(snap_s),
143
+ "targets": sorted({*map(time_str, interior), *ticks}, key=float),
144
+ },
145
+ }
146
+
147
+
148
+ def render_ask_page(proposal: Mapping, *, author: str = "") -> str:
149
+ """The default ``ask`` UI: a self-contained static HTML page.
150
+
151
+ It posts nothing. The human's answers autosave in the browser (so a
152
+ closed tab loses nothing) and leave as a downloaded ``answers.json`` or
153
+ copied text, which :func:`load_answers` reads. ``author`` is stamped on
154
+ every correction the page emits.
155
+ """
156
+ if proposal.get("kind") != PROPOSAL_KIND:
157
+ raise ValueError(
158
+ f"expected a {PROPOSAL_KIND} (from propose()), got {proposal.get('kind')!r}"
159
+ )
160
+ if proposal.get("version") != CONTRACT_VERSION:
161
+ raise ValueError(
162
+ f"expected a version {CONTRACT_VERSION} proposal, "
163
+ f"got version {proposal.get('version')!r}"
164
+ )
165
+ data = json.dumps({**proposal, "author": author}, ensure_ascii=False)
166
+ # No "<" may reach the data block: a "</script>" (or "<!--") inside a
167
+ # title or flag would end it early. JSON reads "\u003c" back as "<".
168
+ data = data.replace("<", "\\u003c")
169
+ fills = {
170
+ "title": html.escape(proposal.get("title") or "Check the steps"),
171
+ "css": (_ASSETS / "ask.css").read_text(encoding="utf-8"),
172
+ "js": (_ASSETS / "ask.js").read_text(encoding="utf-8"),
173
+ "data": data,
174
+ }
175
+ template = (_ASSETS / "ask.html").read_text(encoding="utf-8")
176
+ # One pass: a filled value is never scanned again, so a title such as
177
+ # "{{js}}" stays text instead of pulling the script into the heading.
178
+ return re.sub(r"\{\{(\w+)\}\}", lambda m: fills[m.group(1)], template)
179
+
180
+
181
+ @dataclass(frozen=True, slots=True)
182
+ class Answers:
183
+ """What a human answered on the ask page, ready for the next run.
184
+
185
+ ``k`` is the step count they gave (``None`` unless they touched it);
186
+ ``corrections`` is a :mod:`paces.corrections` log; ``boundaries`` holds
187
+ the cuts they tapped when the proposal had none to correct (the refused
188
+ state), from 0 to the media's end — ``None`` otherwise.
189
+ """
190
+
191
+ k: int | None
192
+ corrections: tuple[Correction, ...] = ()
193
+ boundaries: tuple[float, ...] | None = None
194
+
195
+
196
+ def _answered_boundaries(value) -> tuple[float, ...] | None:
197
+ if value is None:
198
+ return None
199
+ if not isinstance(value, list) or len(value) < 2:
200
+ raise ValueError(f"boundaries must be a list of 2 or more times, got {value!r}")
201
+ if any(isinstance(t, bool) for t in value):
202
+ raise ValueError(f"boundaries must be times in seconds, got {value!r}")
203
+ try:
204
+ times = tuple(float(time_str(float(t))) for t in value)
205
+ except (TypeError, ValueError):
206
+ raise ValueError(
207
+ f"boundaries must be times in seconds, got {value!r}"
208
+ ) from None
209
+ if not all(math.isfinite(t) and t >= 0 for t in times) or any(
210
+ a >= b for a, b in zip(times, times[1:])
211
+ ):
212
+ raise ValueError(f"boundaries must be increasing times >= 0, got {value!r}")
213
+ return times
214
+
215
+
216
+ def load_answers(answers: Mapping) -> Answers:
217
+ """:class:`Answers` from the page's answers JSON.
218
+
219
+ Validated here, so a bad file fails now and not halfway through a re-run:
220
+ the kind, the contract ``version`` (required), ``k``, every correction,
221
+ and the boundaries.
222
+
223
+ >>> load_answers({"kind": "PacesAskAnswers", "version": 1, "k": 4,
224
+ ... "corrections": [{"verb": "split", "anchorT": "15"}]})
225
+ Answers(k=4, corrections=(Correction(verb='split', anchor_t=15.0, span=None, step_id=None, author='', at='', media_key=None, reason=''),), boundaries=None)
226
+ """
227
+ if not isinstance(answers, Mapping) or answers.get("kind") != ANSWERS_KIND:
228
+ got = (
229
+ answers.get("kind")
230
+ if isinstance(answers, Mapping)
231
+ else type(answers).__name__
232
+ )
233
+ raise ValueError(
234
+ f"expected {ANSWERS_KIND} answers (from the ask page), got {got!r}"
235
+ )
236
+ unknown = sorted(set(answers) - _ANSWERS_KEYS)
237
+ if unknown:
238
+ raise ValueError(f"unknown answers keys {unknown}")
239
+ version = answers.get("version")
240
+ if version != CONTRACT_VERSION:
241
+ raise ValueError(
242
+ f"expected version {CONTRACT_VERSION} answers, got version {version!r} "
243
+ "(re-export them from a page made by this paces)"
244
+ )
245
+ k = answers.get("k")
246
+ if k is not None and (isinstance(k, bool) or not isinstance(k, int) or k < 1):
247
+ raise ValueError(f"k must be a positive integer or null, got {k!r}")
248
+ return Answers(
249
+ k=k,
250
+ corrections=normalize_corrections(answers.get("corrections", [])),
251
+ boundaries=_answered_boundaries(answers.get("boundaries")),
252
+ )