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.
- {paces-0.0.15 → paces-0.0.17}/PKG-INFO +3 -1
- {paces-0.0.15 → paces-0.0.17}/README.md +2 -0
- paces-0.0.17/paces/ask.py +252 -0
- paces-0.0.17/paces/corrections.py +714 -0
- paces-0.0.17/paces/data/ask/ask.css +78 -0
- paces-0.0.17/paces/data/ask/ask.html +75 -0
- paces-0.0.17/paces/data/ask/ask.js +499 -0
- {paces-0.0.15 → paces-0.0.17}/paces/segmenters.py +40 -1
- {paces-0.0.15 → paces-0.0.17}/paces/tools.py +51 -2
- {paces-0.0.15 → paces-0.0.17}/pyproject.toml +1 -1
- paces-0.0.17/tests/test_ask.py +541 -0
- paces-0.0.17/tests/test_corrections.py +617 -0
- {paces-0.0.15 → paces-0.0.17}/.gitignore +0 -0
- {paces-0.0.15 → paces-0.0.17}/LICENSE +0 -0
- {paces-0.0.15 → paces-0.0.17}/docs/README.md +0 -0
- {paces-0.0.15 → paces-0.0.17}/docs/alignment/README.md +0 -0
- {paces-0.0.15 → paces-0.0.17}/docs/poc-reference/README.md +0 -0
- {paces-0.0.15 → paces-0.0.17}/docs/poc-reference/artifacts/clips.json +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/__init__.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/__main__.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/bodies/__init__.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/bodies/_base.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/bodies/derivation.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/bodies/document.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/bodies/signal.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/bodies/step.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/cues.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/derivation.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/edits.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/evidence.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/excerpts.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/measure.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/model.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/pose.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/projection.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/render.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/paces/timed_text.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/audio_synth.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/data/body_schemas.json +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/data/routine.json +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/poc_fixture.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_chapters.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_cli_surface.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_cues.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_derivation.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_edit_protection_residuals.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_edits.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_evidence.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_evidence_schemas.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_excerpts.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_measure.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_pose.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_render_media.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_roundtrip_poc.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_segment.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_smoke.py +0 -0
- {paces-0.0.15 → paces-0.0.17}/tests/test_vertical_slice.py +0 -0
- {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.
|
|
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
|
+
)
|