paces 0.0.18__tar.gz → 0.0.20__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 (59) hide show
  1. {paces-0.0.18 → paces-0.0.20}/PKG-INFO +1 -1
  2. {paces-0.0.18 → paces-0.0.20}/paces/ask.py +1 -1
  3. {paces-0.0.18 → paces-0.0.20}/paces/corrections.py +1 -1
  4. {paces-0.0.18 → paces-0.0.20}/paces/evidence.py +2 -2
  5. {paces-0.0.18 → paces-0.0.20}/paces/measure.py +2 -2
  6. {paces-0.0.18 → paces-0.0.20}/paces/projection.py +1 -1
  7. paces-0.0.20/paces/segmentation.py +74 -0
  8. {paces-0.0.18 → paces-0.0.20}/paces/segmenters.py +62 -66
  9. {paces-0.0.18 → paces-0.0.20}/pyproject.toml +1 -1
  10. {paces-0.0.18 → paces-0.0.20}/tests/test_segment.py +29 -0
  11. {paces-0.0.18 → paces-0.0.20}/.gitignore +0 -0
  12. {paces-0.0.18 → paces-0.0.20}/LICENSE +0 -0
  13. {paces-0.0.18 → paces-0.0.20}/README.md +0 -0
  14. {paces-0.0.18 → paces-0.0.20}/docs/README.md +0 -0
  15. {paces-0.0.18 → paces-0.0.20}/docs/alignment/README.md +0 -0
  16. {paces-0.0.18 → paces-0.0.20}/docs/poc-reference/README.md +0 -0
  17. {paces-0.0.18 → paces-0.0.20}/docs/poc-reference/artifacts/clips.json +0 -0
  18. {paces-0.0.18 → paces-0.0.20}/paces/__init__.py +0 -0
  19. {paces-0.0.18 → paces-0.0.20}/paces/__main__.py +0 -0
  20. {paces-0.0.18 → paces-0.0.20}/paces/bodies/__init__.py +0 -0
  21. {paces-0.0.18 → paces-0.0.20}/paces/bodies/_base.py +0 -0
  22. {paces-0.0.18 → paces-0.0.20}/paces/bodies/derivation.py +0 -0
  23. {paces-0.0.18 → paces-0.0.20}/paces/bodies/document.py +0 -0
  24. {paces-0.0.18 → paces-0.0.20}/paces/bodies/signal.py +0 -0
  25. {paces-0.0.18 → paces-0.0.20}/paces/bodies/step.py +0 -0
  26. {paces-0.0.18 → paces-0.0.20}/paces/cues.py +0 -0
  27. {paces-0.0.18 → paces-0.0.20}/paces/data/ask/ask.css +0 -0
  28. {paces-0.0.18 → paces-0.0.20}/paces/data/ask/ask.html +0 -0
  29. {paces-0.0.18 → paces-0.0.20}/paces/data/ask/ask.js +0 -0
  30. {paces-0.0.18 → paces-0.0.20}/paces/derivation.py +0 -0
  31. {paces-0.0.18 → paces-0.0.20}/paces/edits.py +0 -0
  32. {paces-0.0.18 → paces-0.0.20}/paces/excerpts.py +0 -0
  33. {paces-0.0.18 → paces-0.0.20}/paces/model.py +0 -0
  34. {paces-0.0.18 → paces-0.0.20}/paces/pose.py +0 -0
  35. {paces-0.0.18 → paces-0.0.20}/paces/render.py +0 -0
  36. {paces-0.0.18 → paces-0.0.20}/paces/timed_text.py +0 -0
  37. {paces-0.0.18 → paces-0.0.20}/paces/tools.py +0 -0
  38. {paces-0.0.18 → paces-0.0.20}/tests/audio_synth.py +0 -0
  39. {paces-0.0.18 → paces-0.0.20}/tests/data/body_schemas.json +0 -0
  40. {paces-0.0.18 → paces-0.0.20}/tests/data/routine.json +0 -0
  41. {paces-0.0.18 → paces-0.0.20}/tests/poc_fixture.py +0 -0
  42. {paces-0.0.18 → paces-0.0.20}/tests/test_ask.py +0 -0
  43. {paces-0.0.18 → paces-0.0.20}/tests/test_chapters.py +0 -0
  44. {paces-0.0.18 → paces-0.0.20}/tests/test_cli_surface.py +0 -0
  45. {paces-0.0.18 → paces-0.0.20}/tests/test_corrections.py +0 -0
  46. {paces-0.0.18 → paces-0.0.20}/tests/test_cues.py +0 -0
  47. {paces-0.0.18 → paces-0.0.20}/tests/test_derivation.py +0 -0
  48. {paces-0.0.18 → paces-0.0.20}/tests/test_edit_protection_residuals.py +0 -0
  49. {paces-0.0.18 → paces-0.0.20}/tests/test_edits.py +0 -0
  50. {paces-0.0.18 → paces-0.0.20}/tests/test_evidence.py +0 -0
  51. {paces-0.0.18 → paces-0.0.20}/tests/test_evidence_schemas.py +0 -0
  52. {paces-0.0.18 → paces-0.0.20}/tests/test_excerpts.py +0 -0
  53. {paces-0.0.18 → paces-0.0.20}/tests/test_measure.py +0 -0
  54. {paces-0.0.18 → paces-0.0.20}/tests/test_pose.py +0 -0
  55. {paces-0.0.18 → paces-0.0.20}/tests/test_render_media.py +0 -0
  56. {paces-0.0.18 → paces-0.0.20}/tests/test_roundtrip_poc.py +0 -0
  57. {paces-0.0.18 → paces-0.0.20}/tests/test_smoke.py +0 -0
  58. {paces-0.0.18 → paces-0.0.20}/tests/test_vertical_slice.py +0 -0
  59. {paces-0.0.18 → paces-0.0.20}/tests/video_synth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paces
3
- Version: 0.0.18
3
+ Version: 0.0.20
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
@@ -49,7 +49,7 @@ from paces.corrections import (
49
49
  time_str,
50
50
  )
51
51
  from paces.model import seconds_per_unit
52
- from paces.segmenters import Segmentation
52
+ from paces.segmentation import Segmentation
53
53
 
54
54
  PROPOSAL_KIND = "PacesAskProposal"
55
55
  ANSWERS_KIND = "PacesAskAnswers"
@@ -42,7 +42,7 @@ 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.segmenters import (
45
+ from paces.segmentation import (
46
46
  DFLT_EXPLICIT_CONFIDENCE,
47
47
  SegStep,
48
48
  Segmentation,
@@ -144,7 +144,7 @@ from paces.model import (
144
144
  StepDocument,
145
145
  )
146
146
  from paces.projection import to_document
147
- from paces.segmenters import Segmentation
147
+ from paces.segmentation import Segmentation
148
148
 
149
149
  #: The namespace every paces annotation id is derived under. Derived from the
150
150
  #: repository URL rather than typed as a literal, so it is reproducible from
@@ -454,7 +454,7 @@ def to_store(
454
454
  *seg* is the analysis result; the projection arguments (*doc_id*,
455
455
  *title*, *source*, *domain*, *lang*) are :func:`paces.to_document`'s,
456
456
  unchanged. Everything else is evidence a segmenter does not carry on
457
- :class:`~paces.segmenters.Segmentation` but a pipeline may have:
457
+ :class:`~paces.segmentation.Segmentation` but a pipeline may have:
458
458
 
459
459
  Args:
460
460
  store: Any ``lacing.IntervalAnnotationStore`` — ``MemoryStore()`` for
@@ -45,7 +45,7 @@ from paces.model import MetricGrid, decimal_str, seconds_per_unit
45
45
  from paces.segmenters import (
46
46
  Capability,
47
47
  Segmentation,
48
- _segment_grid_placed,
48
+ place_on_grid,
49
49
  register,
50
50
  )
51
51
 
@@ -318,7 +318,7 @@ def _segment_grid_measured(inputs: Mapping[str, Any]) -> Segmentation:
318
318
  grid = measurement.grid
319
319
  if grid.tempo_bpm is None or grid.origin is None:
320
320
  return Segmentation(grid=grid, confidence=measurement.confidence, flags=flags)
321
- placed = _segment_grid_placed({**inputs, "grid": grid})
321
+ placed = place_on_grid(inputs["steps"], grid)
322
322
  return Segmentation(
323
323
  steps=placed.steps,
324
324
  boundaries=placed.boundaries,
@@ -21,7 +21,7 @@ from paces.model import (
21
21
  StepDocument,
22
22
  seconds_per_unit,
23
23
  )
24
- from paces.segmenters import SegStep, Segmentation
24
+ from paces.segmentation import SegStep, Segmentation
25
25
 
26
26
  #: Decimal places kept when converting computed seconds to wire decimals —
27
27
  #: matches the POC's 0.1 s working resolution with headroom.
@@ -0,0 +1,74 @@
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
@@ -44,55 +44,18 @@ from dataclasses import dataclass, field
44
44
  from pathlib import Path
45
45
  from typing import Any, Callable
46
46
 
47
+ from paces import corrections as corrections_module
47
48
  from paces.model import MetricGrid, seconds_per_unit
49
+ from paces.segmentation import ( # noqa: F401 — re-exported; their home is there
50
+ DFLT_CHAPTERS_CONFIDENCE,
51
+ DFLT_EXPLICIT_CONFIDENCE,
52
+ DFLT_GRID_CONFIDENCE,
53
+ SegStep,
54
+ Segmentation,
55
+ Span,
56
+ )
48
57
  from paces.timed_text import normalize_timed_text
49
58
 
50
- Span = tuple[float, float] # seconds, half-open
51
-
52
- # Confidence constants: placement from user-supplied inputs is arithmetic, so
53
- # what these grade is trust in the inputs, not in any inference.
54
- DFLT_EXPLICIT_CONFIDENCE = 0.95 # a human said so
55
- DFLT_GRID_CONFIDENCE = 0.9 # exact given the grid; the grid itself may drift
56
- DFLT_CHAPTERS_CONFIDENCE = 0.9 # the author said so, in metadata
57
-
58
-
59
- @dataclass(frozen=True, slots=True, kw_only=True)
60
- class SegStep:
61
- """One named piece of the media, as an analysis result.
62
-
63
- ``name=''`` means "found, not named" — a VALID state, not an error.
64
- ``spans`` is a tuple because a step legitimately appears in several places
65
- (run-through AND breakdown).
66
- """
67
-
68
- id: str
69
- name: str = ""
70
- spans: tuple[Span, ...] = ()
71
- confidence: float = 0.0
72
- children: tuple["SegStep", ...] = ()
73
- source: str = "" # which capability produced it
74
- evidence: Mapping[str, Any] = field(default_factory=dict)
75
-
76
-
77
- @dataclass(frozen=True, slots=True, kw_only=True)
78
- class Segmentation:
79
- """What every segmenter returns. Reviewable, re-runnable, arguable.
80
-
81
- ``boundaries`` is separate from ``steps`` and populated even when naming
82
- failed: nine excellent unnamed cuts are a useful, honest result. ``steps``
83
- may be empty while ``boundaries`` is not.
84
- """
85
-
86
- steps: tuple[SegStep, ...] = () # ordered; MAY be empty while boundaries is not
87
- boundaries: tuple[float, ...] = () # the raw cut set, always
88
- unit: str = "seconds" # "eight" | "rep" | "seconds"
89
- grid: MetricGrid | None = None
90
- confidence: float = 0.0
91
- method: str = "" # which capability ran (the ADR's "reported default")
92
- flags: tuple[str, ...] = () # 'naming-abstained', 'no-signal', 'try: ...'
93
- elapsed_s: float = 0.0
94
- spent_usd: float = 0.0
95
-
96
59
 
97
60
  @dataclass(frozen=True, slots=True, kw_only=True)
98
61
  class Capability:
@@ -354,13 +317,6 @@ def _facts(
354
317
  return frozenset(facts)
355
318
 
356
319
 
357
- def _corrections_api():
358
- """:mod:`paces.corrections`, imported lazily: it imports this module."""
359
- from paces import corrections
360
-
361
- return corrections
362
-
363
-
364
320
  def _apply_corrections(
365
321
  result: Segmentation, log: tuple, capability: Capability | None
366
322
  ) -> Segmentation:
@@ -370,7 +326,7 @@ def _apply_corrections(
370
326
  unmatched — rather than dropped."""
371
327
  if not log or (capability is not None and "corrections" in capability.reads):
372
328
  return result
373
- return _corrections_api().replay(result, log)
329
+ return corrections_module.replay(result, log)
374
330
 
375
331
 
376
332
  # ── the facade ──────────────────────────────────────────────────────────────
@@ -418,7 +374,7 @@ def segment(
418
374
  transcript_cues = normalize_timed_text(transcript)
419
375
  # fail fast: a malformed log raises before any segmenter runs
420
376
  correction_log = (
421
- _corrections_api().normalize_corrections(corrections)
377
+ corrections_module.normalize_corrections(corrections)
422
378
  if corrections is not None
423
379
  else ()
424
380
  )
@@ -543,42 +499,77 @@ def segment(
543
499
  # ── the two v1 segmenters ───────────────────────────────────────────────────
544
500
 
545
501
 
546
- def _segment_grid_placed(inputs: Mapping[str, Any]) -> Segmentation:
502
+ def place_on_grid(steps: Sequence, grid: MetricGrid | Mapping) -> Segmentation:
547
503
  """Place an ordered step list on the timeline using a metric grid.
548
504
 
549
505
  The que-calor shape: cumulative domain durations from the grid's origin,
550
506
  ``span = origin + cumulative_units * seconds_per_unit``. Children subdivide
551
- their parent's span the same way.
507
+ their parent's span the same way. ``steps`` takes any form
508
+ :func:`segment` takes (``(name, duration)`` pairs, mappings with
509
+ ``children``); every step needs a positive duration, and the grid a tempo
510
+ and an origin — anything missing or invalid is a ``ValueError`` naming it.
511
+
512
+ >>> seg = place_on_grid([("a", 1), ("b", 2)],
513
+ ... {"unit": "eight", "subdivisions": 8,
514
+ ... "tempoBpm": "120", "origin": "1"})
515
+ >>> [s.spans for s in seg.steps], seg.boundaries
516
+ ([((1.0, 5.0),), ((5.0, 13.0),)], (1.0, 5.0, 13.0))
552
517
  """
553
- grid: MetricGrid = inputs["grid"]
518
+
519
+ def _all_rows(rows: list[dict]):
520
+ for row in rows:
521
+ yield row
522
+ yield from _all_rows(row["children"])
523
+
524
+ grid = _normalize_grid(grid)
525
+ rows = _normalize_steps(steps)
526
+ if grid is None:
527
+ raise ValueError("place_on_grid needs a grid; got None")
528
+ missing = [
529
+ name
530
+ for name, value in (("tempoBpm", grid.tempo_bpm), ("origin", grid.origin))
531
+ if value is None
532
+ ]
533
+ if missing:
534
+ raise ValueError(f"place_on_grid needs a grid with {' and '.join(missing)}")
535
+ if not rows:
536
+ raise ValueError("place_on_grid needs at least one step")
537
+ undated = [row["id"] for row in _all_rows(rows) if row["duration"] is None]
538
+ if undated:
539
+ raise ValueError(f"place_on_grid needs a duration for every step: {undated}")
540
+ bad = [
541
+ row["id"]
542
+ for row in _all_rows(rows)
543
+ if not (math.isfinite(row["duration"]) and row["duration"] > 0)
544
+ ]
545
+ if bad:
546
+ raise ValueError(f"place_on_grid needs positive, finite durations: {bad}")
554
547
  spu = seconds_per_unit(grid)
555
- origin = float(grid.origin) # guaranteed by the 'grid' fact
548
+ origin = float(grid.origin)
556
549
 
557
- def _place(rows: list[dict], at_units: float) -> tuple[list[SegStep], float]:
550
+ def _place(rows: list[dict], at_units: float) -> list[SegStep]:
558
551
  placed = []
559
552
  for row in rows:
560
553
  duration = row["duration"]
561
554
  start = origin + at_units * spu
562
555
  end = origin + (at_units + duration) * spu
563
- children, _ = _place(row["children"], at_units)
564
556
  placed.append(
565
557
  SegStep(
566
558
  id=row["id"],
567
559
  name=row["name"],
568
560
  spans=((start, end),),
569
561
  confidence=DFLT_GRID_CONFIDENCE,
570
- children=tuple(children),
562
+ children=tuple(_place(row["children"], at_units)),
571
563
  source="grid-placed",
572
564
  evidence={"duration_units": duration, "offset_units": at_units},
573
565
  )
574
566
  )
575
567
  at_units += duration
576
- return placed, at_units
568
+ return placed
577
569
 
578
- steps, total_units = _place(inputs["steps"], 0.0)
579
- boundaries = tuple(origin + units * spu for units in _cumulative(inputs["steps"]))
570
+ boundaries = tuple(origin + units * spu for units in _cumulative(rows))
580
571
  return Segmentation(
581
- steps=tuple(steps),
572
+ steps=tuple(_place(rows, 0.0)),
582
573
  boundaries=boundaries,
583
574
  unit=grid.unit,
584
575
  grid=grid,
@@ -586,6 +577,11 @@ def _segment_grid_placed(inputs: Mapping[str, Any]) -> Segmentation:
586
577
  )
587
578
 
588
579
 
580
+ def _segment_grid_placed(inputs: Mapping[str, Any]) -> Segmentation:
581
+ """The ``grid-placed`` capability: :func:`place_on_grid` over the facade's inputs."""
582
+ return place_on_grid(inputs["steps"], inputs["grid"])
583
+
584
+
589
585
  def _cumulative(rows: list[dict]) -> list[float]:
590
586
  offsets, at = [0.0], 0.0
591
587
  for row in rows:
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "paces"
9
- version = "0.0.18"
9
+ version = "0.0.20"
10
10
  description = "Turn instructional media into structured, interactive learning material"
11
11
  readme = "README.md"
12
12
  license = "MIT"
@@ -170,3 +170,32 @@ def test_decimal_str_refuses_non_finite_values(value):
170
170
 
171
171
  with pytest.raises(ValueError, match="non-finite"):
172
172
  decimal_str(value)
173
+
174
+
175
+ def test_place_on_grid_is_the_public_placement():
176
+ from paces.segmenters import place_on_grid
177
+
178
+ seg = place_on_grid(ROUTINE_STEPS, GRID)
179
+ assert [s.spans for s in seg.steps] == [
180
+ s.spans for s in segment(steps=ROUTINE_STEPS, grid=GRID).steps
181
+ ]
182
+
183
+
184
+ @pytest.mark.parametrize(
185
+ "steps, grid, match",
186
+ [
187
+ (ROUTINE_STEPS, {"unit": "eight", "subdivisions": 8}, "tempoBpm and origin"),
188
+ (ROUTINE_STEPS, {**GRID, "origin": None}, "with origin$"),
189
+ ([], GRID, "at least one step"),
190
+ ([("back", -1)], GRID, r"positive, finite durations: \['back'\]"),
191
+ ([("nan", float("nan"))], GRID, "positive, finite"),
192
+ ([("zero", 0)], GRID, "positive, finite"),
193
+ (["no duration"], GRID, "duration for every step"),
194
+ ([{"name": "p", "duration": 2, "children": ["c"]}], GRID, r"\['c'\]"),
195
+ ],
196
+ )
197
+ def test_place_on_grid_names_what_is_missing(steps, grid, match):
198
+ from paces.segmenters import place_on_grid
199
+
200
+ with pytest.raises(ValueError, match=match):
201
+ place_on_grid(steps, grid)
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