paces 0.0.2__tar.gz → 0.0.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paces
3
- Version: 0.0.2
3
+ Version: 0.0.3
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
@@ -48,6 +48,7 @@ from paces.model import (
48
48
  seconds_per_unit,
49
49
  validate_document,
50
50
  )
51
+ from paces.edits import apply_edits, merge_regenerated
51
52
  from paces.projection import to_document
52
53
  from paces.render import render_html
53
54
  from paces.segmenters import (
@@ -87,6 +88,9 @@ __all__ = [
87
88
  "register",
88
89
  "capabilities",
89
90
  "to_document",
91
+ # editing & regeneration
92
+ "apply_edits",
93
+ "merge_regenerated",
90
94
  # rendering
91
95
  "render_html",
92
96
  ]
@@ -0,0 +1,452 @@
1
+ """Document-layer edit protection: typed patches write Locks; regeneration
2
+ merges without eating edits.
3
+
4
+ The POC's single most expensive failure was regeneration destroying hand
5
+ edits (``docs/07-annotation-model.md §1.3``). The remedy has two halves, both
6
+ here and both store-independent (``docs/07 §6.4`` steps 4–6):
7
+
8
+ - :func:`apply_edits` — edits are **typed patches**
9
+ (``{"op": "set", "path": "/steps/b4/name", "value": ...}``), validated
10
+ before application, and every edit writes a :class:`~paces.model.Lock` with
11
+ the pre-edit value, so every edit is reversible and every protected path is
12
+ explicit.
13
+ - :func:`merge_regenerated` — a fresh analysis projection merged against the
14
+ committed document: locked paths keep the committed value, everything else
15
+ takes the fresh value, and committed-only steps that carry protection
16
+ survive.
17
+
18
+ Path rules (each earned by an adversarial review, PR #11):
19
+
20
+ - List items are addressed **by id first** (``/steps/b4/name``); an ASCII
21
+ digit segment is an index only when no item carries that id. Ids are
22
+ stabler across regenerations — always prefer them.
23
+ - Field segments accept the wire's camelCase or Python's snake_case; recorded
24
+ ``Lock.path``\\ s are canonicalised to snake_case and to id-form where an
25
+ unambiguous id exists.
26
+ - On merge, a lock is re-applied by **matching list items structurally**
27
+ (id; a span's exact ``(source, role, start)``, falling back to
28
+ ``(source, role)`` only for singleton groups; an artifact's ``uri``) —
29
+ never by bare position, not even within a group, because regeneration
30
+ reorders lists and a positional write would land on the wrong item. When
31
+ no confident match exists, nothing is written and the lock survives as
32
+ the record. Scalar list items (tags) have no identity besides their
33
+ value, so an edited scalar cannot be re-found after regeneration — the
34
+ edit does not survive; only its lock record does.
35
+ - ``attrs`` bags merge committed-over-fresh per key: they are user/renderer
36
+ data that analysis does not produce, so regeneration never wins there.
37
+
38
+ Not yet recorded anywhere: the fresh values a merge *rejects*
39
+ (``Origin.value_digest`` and the op-log arrive with the evidence layer,
40
+ issue #4).
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import copy
46
+ import re
47
+ from collections.abc import Mapping, Sequence
48
+ from datetime import datetime, timezone
49
+ from typing import Any
50
+
51
+ from paces.model import Lock, StepDocument
52
+
53
+ #: The ops v1 supports. ``append``/``delete`` arrive at the third real need
54
+ #: (they require tombstone semantics in the merge — see issue #5).
55
+ SUPPORTED_OPS = ("set",)
56
+
57
+ _CAMEL_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
58
+
59
+ #: Structural identity keys for list items that carry no ``id``.
60
+ _SPAN_KEYS = frozenset({"source", "role", "start"})
61
+
62
+
63
+ def _now_iso() -> str:
64
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
65
+
66
+
67
+ def _snake(key: str) -> str:
68
+ return _CAMEL_BOUNDARY.sub("_", key).lower()
69
+
70
+
71
+ def _split_path(path: str) -> list[str]:
72
+ if not isinstance(path, str) or not path.startswith("/") or path == "/":
73
+ raise ValueError(
74
+ f"path must be a JSON-pointer-style path like '/steps/b4/name'; "
75
+ f"got {path!r}"
76
+ )
77
+ return path[1:].split("/")
78
+
79
+
80
+ def _is_index(segment: str) -> bool:
81
+ return segment.isascii() and segment.isdigit()
82
+
83
+
84
+ def _index_of(items: list, segment: str, *, at: str) -> int:
85
+ """Resolve a list segment: an item id first; an ASCII digit index second.
86
+
87
+ Id-first because ids are the stable address — and because a segmenter can
88
+ legitimately mint all-digit ids (chapters titled "1", "2", ...), which
89
+ index-first would silently misroute.
90
+ """
91
+ for i, item in enumerate(items):
92
+ if isinstance(item, Mapping) and item.get("id") == segment:
93
+ return i
94
+ if _is_index(segment):
95
+ index = int(segment)
96
+ if index < len(items):
97
+ return index
98
+ raise ValueError(f"{at}: index {index} out of range (len {len(items)})")
99
+ ids = [item.get("id") for item in items if isinstance(item, Mapping)]
100
+ raise ValueError(f"{at}: no item with id {segment!r} (have: {ids})")
101
+
102
+
103
+ def _key_of(container: Mapping, segment: str, *, at: str) -> str:
104
+ """Resolve a field segment; accepts wire camelCase, returns snake_case."""
105
+ if segment in container:
106
+ return segment
107
+ snake = _snake(segment)
108
+ if snake in container:
109
+ return snake
110
+ raise ValueError(f"{at}: no field {segment!r} (have: {sorted(container)})")
111
+
112
+
113
+ def _resolve_parent(root: Any, segments: list[str], *, path: str):
114
+ """The parent container of the leaf, and the resolved leaf key."""
115
+ node = root
116
+ for i, segment in enumerate(segments[:-1]):
117
+ at = "/" + "/".join(segments[: i + 1])
118
+ if isinstance(node, list):
119
+ node = node[_index_of(node, segment, at=at)]
120
+ elif isinstance(node, Mapping):
121
+ node = node[_key_of(node, segment, at=at)]
122
+ else:
123
+ raise ValueError(f"{at}: cannot descend into {type(node).__name__}")
124
+ leaf = segments[-1]
125
+ if isinstance(node, list):
126
+ return node, _index_of(node, leaf, at=path)
127
+ if isinstance(node, Mapping):
128
+ return node, _key_of(node, leaf, at=path)
129
+ raise ValueError(f"{path}: cannot set into {type(node).__name__}")
130
+
131
+
132
+ def _canonical_segments(root: Any, segments: list[str], *, path: str) -> list[str]:
133
+ """The stablest spelling of a path: snake_case fields; list items by id
134
+ when one exists unambiguously in that list, by index otherwise."""
135
+ node, out = root, []
136
+ for i, segment in enumerate(segments):
137
+ at = "/" + "/".join(segments[: i + 1])
138
+ if isinstance(node, list):
139
+ index = _index_of(node, segment, at=at)
140
+ item = node[index]
141
+ item_id = item.get("id") if isinstance(item, Mapping) else None
142
+ ids = [x.get("id") for x in node if isinstance(x, Mapping)]
143
+ unambiguous = item_id is not None and ids.count(item_id) == 1
144
+ out.append(str(item_id) if unambiguous else str(index))
145
+ node = item
146
+ elif isinstance(node, Mapping):
147
+ key = _key_of(node, segment, at=at)
148
+ out.append(key)
149
+ node = node[key]
150
+ else:
151
+ raise ValueError(f"{at}: cannot descend into {type(node).__name__}")
152
+ return out
153
+
154
+
155
+ def _lock_site(dump: dict, segments: list[str]) -> tuple[dict, str]:
156
+ """The node that records the lock, and the lock path relative to it.
157
+
158
+ Structural, not heuristic: only paths of the form
159
+ ``steps/<item>(/steps/<item>)*/<field>...`` are owned by the innermost
160
+ step; everything else — ``attrs`` contents included, whatever shape the
161
+ user's data takes — locks on the document itself.
162
+ """
163
+ site, node, depth, i = dump, dump, 0, 0
164
+ while i + 2 <= len(segments) - 1 and segments[i] == "steps":
165
+ items = node["steps"]
166
+ at = "/" + "/".join(segments[: i + 2])
167
+ node = items[_index_of(items, segments[i + 1], at=at)]
168
+ site, depth = node, i + 2
169
+ i += 2
170
+ return site, "/" + "/".join(segments[depth:])
171
+
172
+
173
+ def apply_edits(
174
+ doc: StepDocument,
175
+ edits: Mapping[str, Any] | Sequence[Mapping[str, Any]],
176
+ *,
177
+ by: str,
178
+ at: str | None = None,
179
+ reason: str | None = None,
180
+ ) -> StepDocument:
181
+ """Apply typed patches to a document, recording a Lock per edit.
182
+
183
+ Each edit is ``{"op": "set", "path": ..., "value": ...}`` (a single edit
184
+ may be passed bare). All edits are validated together — an invalid edit
185
+ means NO edit is applied. Editing an already-locked path replaces the
186
+ lock (``was`` becomes the value this edit overwrote, keeping the last
187
+ edit reversible).
188
+
189
+ >>> from paces.model import Measure, Step, StepDocument
190
+ >>> doc = StepDocument(id='g', title='G', steps=[
191
+ ... Step(id='a', name='old', duration=Measure(value='2', unit='eight'))])
192
+ >>> edited = apply_edits(doc, [{'op': 'set', 'path': '/steps/a/name',
193
+ ... 'value': 'new'}], by='user:thor')
194
+ >>> edited.steps[0].name, edited.steps[0].locks[0].was
195
+ ('new', 'old')
196
+ """
197
+ if isinstance(edits, Mapping):
198
+ edits = [edits]
199
+ timestamp = at or _now_iso()
200
+ dump = doc.model_dump(mode="python", by_alias=False)
201
+ for i, edit in enumerate(edits):
202
+ if not isinstance(edit, Mapping):
203
+ raise ValueError(
204
+ f"edits[{i}]: expected a mapping like "
205
+ f"{{'op': 'set', 'path': ..., 'value': ...}}; "
206
+ f"got {type(edit).__name__}"
207
+ )
208
+ op = edit.get("op")
209
+ if op not in SUPPORTED_OPS:
210
+ raise ValueError(
211
+ f"edits[{i}]: unsupported op {op!r} (v1 supports: "
212
+ f"{', '.join(SUPPORTED_OPS)})"
213
+ )
214
+ raw_path = edit.get("path", "")
215
+ segments = _canonical_segments(dump, _split_path(raw_path), path=raw_path)
216
+ container, key = _resolve_parent(dump, segments, path=raw_path)
217
+ # The lock site must resolve BEFORE the mutation: an edit may change
218
+ # the very value a segment addresses (renaming a step's id).
219
+ site, relative = _lock_site(dump, segments)
220
+ was = copy.deepcopy(container[key])
221
+ container[key] = copy.deepcopy(edit["value"])
222
+ locks = site.setdefault("locks", [])
223
+ locks[:] = [lock for lock in locks if lock.get("path") != relative]
224
+ locks.append(
225
+ Lock(path=relative, by=by, at=timestamp, was=was, reason=reason).model_dump(
226
+ mode="python"
227
+ )
228
+ )
229
+ return StepDocument.model_validate(dump)
230
+
231
+
232
+ # ── merge: locked values win, matched structurally ──────────────────────────
233
+
234
+
235
+ def _match_index(citems: list, cidx: int, fitems: list) -> int | None:
236
+ """The fresh index corresponding to committed item *cidx* — never bare
237
+ position on a reorderable list. ``None`` means no confident match."""
238
+ citem = citems[cidx]
239
+ if isinstance(citem, Mapping):
240
+ cid = citem.get("id")
241
+ if cid is not None:
242
+ for j, fitem in enumerate(fitems):
243
+ if isinstance(fitem, Mapping) and fitem.get("id") == cid:
244
+ return j
245
+ return None
246
+ if _SPAN_KEYS <= set(citem): # a SourceSpan
247
+ # Anchor on the exact (source, role, start) triple first — a
248
+ # reorder keeps starts, so this survives insertion AND intra-group
249
+ # shuffling. Only when that fails, fall back to (source, role),
250
+ # and ONLY when that group is a singleton on both sides: an
251
+ # ordinal within a group is bare position wearing a costume
252
+ # (adversarial re-review of PR #11). Anything else declines.
253
+ def _triple(item):
254
+ return (item.get("source"), item.get("role"), item.get("start"))
255
+
256
+ def _pair(item):
257
+ return (item.get("source"), item.get("role"))
258
+
259
+ triple_hits = [
260
+ j
261
+ for j, fitem in enumerate(fitems)
262
+ if isinstance(fitem, Mapping) and _triple(fitem) == _triple(citem)
263
+ ]
264
+ if len(triple_hits) == 1:
265
+ return triple_hits[0]
266
+ committed_group = [
267
+ x for x in citems if isinstance(x, Mapping) and _pair(x) == _pair(citem)
268
+ ]
269
+ fresh_group = [
270
+ j
271
+ for j, fitem in enumerate(fitems)
272
+ if isinstance(fitem, Mapping) and _pair(fitem) == _pair(citem)
273
+ ]
274
+ if len(committed_group) == 1 and len(fresh_group) == 1:
275
+ return fresh_group[0]
276
+ return None
277
+ if "uri" in citem: # an ArtifactRef: the uri is its identity
278
+ for j, fitem in enumerate(fitems):
279
+ if isinstance(fitem, Mapping) and fitem.get("uri") == citem["uri"]:
280
+ return j
281
+ return None
282
+ return None
283
+ for j, fitem in enumerate(fitems): # scalar: first equal value
284
+ if fitem == citem:
285
+ return j
286
+ return None
287
+
288
+
289
+ def _apply_lock(fresh: Any, committed: Any, path: str) -> bool:
290
+ """Re-apply the committed value at *path* onto *fresh*.
291
+
292
+ Walks both structures in parallel, matching list items structurally.
293
+ Returns False — writing nothing — when the path cannot be confidently
294
+ resolved in the fresh structure; the lock then survives as the record.
295
+ """
296
+ try:
297
+ segments = _split_path(path)
298
+ cnode, fnode = committed, fresh
299
+ for i, segment in enumerate(segments[:-1]):
300
+ at = "/" + "/".join(segments[: i + 1])
301
+ if isinstance(cnode, list):
302
+ if not isinstance(fnode, list):
303
+ return False
304
+ cidx = _index_of(cnode, segment, at=at)
305
+ fidx = _match_index(cnode, cidx, fnode)
306
+ if fidx is None:
307
+ return False
308
+ cnode, fnode = cnode[cidx], fnode[fidx]
309
+ elif isinstance(cnode, Mapping):
310
+ key = _key_of(cnode, segment, at=at)
311
+ if not isinstance(fnode, Mapping) or key not in fnode:
312
+ return False
313
+ cnode, fnode = cnode[key], fnode[key]
314
+ else:
315
+ return False
316
+ leaf = segments[-1]
317
+ if isinstance(cnode, list):
318
+ if not isinstance(fnode, list):
319
+ return False
320
+ cidx = _index_of(cnode, leaf, at=path)
321
+ fidx = _match_index(cnode, cidx, fnode)
322
+ if fidx is None:
323
+ return False
324
+ fnode[fidx] = copy.deepcopy(cnode[cidx])
325
+ return True
326
+ if isinstance(cnode, Mapping):
327
+ key = _key_of(cnode, leaf, at=path)
328
+ if not isinstance(fnode, Mapping) or key not in fnode:
329
+ return False
330
+ fnode[key] = copy.deepcopy(cnode[key])
331
+ return True
332
+ return False
333
+ except ValueError:
334
+ return False
335
+
336
+
337
+ def _value_at(dump: Mapping, path: str) -> Any:
338
+ segments = _split_path(path)
339
+ container, key = _resolve_parent(dict(dump), segments, path=path)
340
+ return copy.deepcopy(container[key])
341
+
342
+
343
+ def _is_protected(step_dump: Mapping) -> bool:
344
+ origin = step_dump.get("origin") or {}
345
+ generated_by = origin.get("generated_by") or ""
346
+ return bool(step_dump.get("locks")) or generated_by.startswith("user:")
347
+
348
+
349
+ def _merged_attrs(committed: Mapping, fresh: Mapping) -> dict:
350
+ """attrs are user/renderer data analysis does not produce: committed wins
351
+ per key, fresh-only keys are kept."""
352
+ return {**copy.deepcopy(dict(fresh)), **copy.deepcopy(dict(committed))}
353
+
354
+
355
+ def _merge_steps(committed: list[dict], fresh: list[dict]) -> list[dict]:
356
+ committed_by_id: dict[str, dict] = {}
357
+ for step in committed: # first occurrence wins, matching apply_edits
358
+ committed_by_id.setdefault(step["id"], step)
359
+ fresh_ids = {step["id"] for step in fresh}
360
+ merged: list[dict] = []
361
+ for fresh_step in fresh:
362
+ committed_step = committed_by_id.get(fresh_step["id"])
363
+ if committed_step is None:
364
+ merged.append(copy.deepcopy(fresh_step))
365
+ continue
366
+ out = copy.deepcopy(fresh_step)
367
+ out["steps"] = _merge_steps(
368
+ committed_step.get("steps", []), fresh_step.get("steps", [])
369
+ )
370
+ out["attrs"] = _merged_attrs(
371
+ committed_step.get("attrs", {}), fresh_step.get("attrs", {})
372
+ )
373
+ out["locks"] = copy.deepcopy(committed_step.get("locks", []))
374
+ for lock in out["locks"]:
375
+ _apply_lock(out, committed_step, lock["path"])
376
+ merged.append(out)
377
+
378
+ # Committed-only steps survive when they carry protection (locks anywhere
379
+ # in their subtree, or a user origin); analysis leftovers are superseded.
380
+ for position, committed_step in enumerate(committed):
381
+ if committed_step["id"] in fresh_ids:
382
+ continue
383
+ subtree_protected = _is_protected(committed_step) or any(
384
+ _is_protected(child) for child in _walk_dumps(committed_step)
385
+ )
386
+ if not subtree_protected:
387
+ continue
388
+ insert_at = len(merged)
389
+ for earlier in reversed(committed[:position]):
390
+ index = _find_index(merged, earlier["id"])
391
+ if index is not None:
392
+ insert_at = index + 1
393
+ break
394
+ else:
395
+ insert_at = 0 if position == 0 else len(merged)
396
+ merged.insert(insert_at, copy.deepcopy(committed_step))
397
+ return merged
398
+
399
+
400
+ def _walk_dumps(step_dump: Mapping):
401
+ for child in step_dump.get("steps", []):
402
+ yield child
403
+ yield from _walk_dumps(child)
404
+
405
+
406
+ def _find_index(steps: list[dict], step_id: str) -> int | None:
407
+ for i, step in enumerate(steps):
408
+ if step["id"] == step_id:
409
+ return i
410
+ return None
411
+
412
+
413
+ def _union_by_id(committed: list[dict], fresh: list[dict]) -> list[dict]:
414
+ """Fresh entries win for shared ids; committed-only entries are kept."""
415
+ fresh_ids = {item["id"] for item in fresh}
416
+ return copy.deepcopy(fresh) + [
417
+ copy.deepcopy(item) for item in committed if item["id"] not in fresh_ids
418
+ ]
419
+
420
+
421
+ def merge_regenerated(committed: StepDocument, fresh: StepDocument) -> StepDocument:
422
+ """Merge a fresh analysis projection against the committed document.
423
+
424
+ The rules, in order of authority:
425
+
426
+ 1. **Locked paths keep the committed value** — on the document and on
427
+ every step matched by id, recursively — re-applied by structural
428
+ match, never by bare position (a reorder must not land a locked value
429
+ on the wrong item).
430
+ 2. Everything else takes the fresh value (regeneration is allowed to
431
+ improve what nobody protected) — except ``attrs``, where committed
432
+ wins per key.
433
+ 3. Committed-only steps survive when protected (locks in their subtree or
434
+ a ``user:`` origin); unprotected ones are superseded analysis output.
435
+ 4. Cues, questions and sources are unioned by id (fresh wins on shared
436
+ ids) — analysis rarely regenerates them, and dropping committed
437
+ content silently is the failure this module exists to prevent.
438
+
439
+ Edits made through :func:`apply_edits` are always locked, so "hand edit"
440
+ and "protected" coincide by construction; edits made by hand-editing the
441
+ JSON without locks are, deliberately, not protected.
442
+ """
443
+ committed_dump = committed.model_dump(mode="python", by_alias=False)
444
+ out = fresh.model_dump(mode="python", by_alias=False)
445
+ out["steps"] = _merge_steps(committed_dump["steps"], out["steps"])
446
+ for key in ("cues", "questions", "sources"):
447
+ out[key] = _union_by_id(committed_dump[key], out[key])
448
+ out["attrs"] = _merged_attrs(committed_dump.get("attrs", {}), out.get("attrs", {}))
449
+ out["locks"] = copy.deepcopy(committed_dump.get("locks", []))
450
+ for lock in out["locks"]:
451
+ _apply_lock(out, committed_dump, lock["path"])
452
+ return StepDocument.model_validate(out)
@@ -231,6 +231,7 @@ class StepDocument(_Base):
231
231
  cues: list[Cue] = Field(default_factory=list)
232
232
  questions: list[OpenQuestion] = Field(default_factory=list)
233
233
  credits: str | None = None
234
+ locks: list[Lock] = Field(default_factory=list) # document-level fields
234
235
  attrs: dict[str, Any] = Field(default_factory=dict)
235
236
 
236
237
 
@@ -322,13 +323,17 @@ def validate_document(doc: StepDocument) -> list[str]:
322
323
  (empty list = clean); never raises.
323
324
 
324
325
  Checks: children durations account for the parent's (``repeat`` included),
325
- span sources exist, cue anchors point at real steps.
326
+ span sources exist, cue anchors point at real steps, step ids are unique
327
+ (id-addressed edits and the regeneration merge both key on them — a
328
+ duplicate makes those silently ambiguous).
326
329
  """
327
330
  issues: list[str] = []
328
331
  source_ids = {s.id for s in doc.sources}
329
332
  step_ids: set[str] = set()
330
333
 
331
334
  def _walk(step: Step, path: str) -> None:
335
+ if step.id in step_ids:
336
+ issues.append(f"{path}: duplicate step id {step.id!r}")
332
337
  step_ids.add(step.id)
333
338
  for i, span in enumerate(step.spans):
334
339
  if span.source not in source_ids: