differ.py python
1,216 lines 42.8 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
1 """Knowtation four-layer note differ — Phase 2.1.
2
3 Produces a structured :class:`~muse.domain.StructuredDelta` between two
4 knowtation note byte-strings and applies that delta deterministically with
5 :func:`apply`.
6
7 The differ operates in four sequential layers:
8
9 * **Layer 1 — Title.** Compares the YAML ``title`` field. Emits a single
10 :class:`~muse.domain.ReplaceOp` when the title changes; otherwise no op.
11 * **Layer 2 — Frontmatter.** For every other frontmatter key, emits
12 fine-grained :class:`~muse.domain.InsertOp` / :class:`~muse.domain.DeleteOp`
13 per element for set-valued dimensions (``tags``, ``entity``, ``follows``,
14 ``summarizes``, ``attachments``) and :class:`~muse.domain.ReplaceOp` (or
15 Insert / Delete when the field appears / disappears) for scalar fields.
16 * **Layer 3 — Sections.** Splits each note body by Markdown heading, runs
17 Myers diff (:func:`muse.core.diff_algorithms.lcs.myers_ses`) on the section
18 ID sequence, and emits Insert / Delete / Move ops per section. Section IDs
19 are level-, title-, and occurrence-qualified so that duplicate headings in
20 the same note remain individually addressable.
21 * **Layer 4 — Body lines.** For each section whose ID is present in both
22 notes, runs Myers diff on the line sequence and emits Insert / Delete ops
23 per changed line.
24
25 Round-trip invariant
26 --------------------
27 For any two notes ``A`` and ``B`` in *canonical form*::
28
29 apply(diff_notes(A, B), A) == B
30
31 Canonical form is produced by :func:`canonicalize`:
32
33 * LF-only line endings (CRLF is normalised).
34 * Frontmatter serialised via :func:`yaml.safe_dump` with
35 ``sort_keys=False``, ``default_flow_style=False``, ``allow_unicode=True``,
36 ``width=10000``; ``title`` is hoisted to the first key, every other key
37 is sorted alphabetically; set-valued list fields are deduplicated and
38 sorted.
39 * Frontmatter delimiters ``---\\n`` on both sides; no leading whitespace.
40
41 Tests in ``tests/test_knowtation_differ.py`` always pre-canonicalise both
42 sides before asserting the round-trip.
43
44 Security and robustness
45 -----------------------
46 * YAML parsing uses :func:`yaml.safe_load` exclusively — no arbitrary Python
47 object instantiation.
48 * Input size is bounded by :data:`_MAX_NOTE_BYTES`; oversize inputs raise
49 :class:`ValueError`.
50 * Invalid UTF-8 is decoded with ``errors="replace"`` and never raises.
51 * Binary content (e.g. leading NUL bytes) is gracefully treated as a body
52 with no frontmatter.
53
54 No new dependencies are introduced. The module relies only on stdlib,
55 ``pyyaml`` (already a runtime dependency), and existing ``muse`` modules.
56 """
57
58 from __future__ import annotations
59
60 import hashlib
61 import logging
62 from dataclasses import dataclass
63 from typing import Any
64
65 import yaml
66
67 from muse.core.diff_algorithms.lcs import detect_moves, myers_ses
68 from muse.domain import (
69 DeleteOp,
70 DomainOp,
71 InsertOp,
72 MoveOp,
73 ReplaceOp,
74 StructuredDelta,
75 )
76 from muse.plugins.knowtation.symbols import split_sections
77
78 logger = logging.getLogger(__name__)
79
80
81 # ──────────────────────────────────────────────────────────────────────────────
82 # Configuration constants
83 # ──────────────────────────────────────────────────────────────────────────────
84
85 #: Maximum note size accepted by :func:`_parse_note`. Inputs larger than this
86 #: are rejected with :class:`ValueError` to defend against runaway YAML parsing
87 #: and pathological inputs. 16 MiB is generous for any realistic vault note
88 #: while remaining small enough to bound algorithm complexity.
89 _MAX_NOTE_BYTES: int = 16 * 1024 * 1024
90
91 #: Frontmatter fields treated as unordered sets when diffing. Insertions and
92 #: deletions are emitted per element rather than as a single scalar replace.
93 _SET_FIELDS: frozenset[str] = frozenset(
94 {"tags", "entity", "follows", "summarizes", "attachments"}
95 )
96
97 #: The title field is handled separately as Layer 1.
98 _TITLE_KEY: str = "title"
99
100 #: Address namespace for Layer 1 (title).
101 _TITLE_ADDR: str = "title"
102
103 #: Address prefix for Layer 2 (frontmatter).
104 _FM_PREFIX: str = "frontmatter:"
105
106 #: Address prefix for Layer 3 (sections).
107 _SECTION_PREFIX: str = "section:"
108
109 #: Address infix used by Layer 4 to disambiguate per-line body ops from the
110 #: section addresses that contain them. The full address shape is
111 #: ``"section:<level>:<title>#<occurrence>::line:<n>"``.
112 _LINE_INFIX: str = "::line:"
113
114 #: The domain name returned in every :class:`StructuredDelta`.
115 _DOMAIN: str = "knowtation"
116
117
118 # ──────────────────────────────────────────────────────────────────────────────
119 # Hash and stringify helpers
120 # ──────────────────────────────────────────────────────────────────────────────
121
122
123 def _content_id(value: str) -> str:
124 """Return a deterministic SHA-256 hex digest of *value* (UTF-8 encoded).
125
126 Used to populate ``content_id`` / ``old_content_id`` / ``new_content_id``
127 on every op so that downstream merge engines can rely on content-addressed
128 equality without re-hashing.
129
130 Args:
131 value: The text whose content ID is required.
132
133 Returns:
134 64-character lowercase hexadecimal string.
135 """
136 return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
137
138
139 def _stringify(value: Any) -> str:
140 """Render a YAML scalar (any type) to a stable string representation.
141
142 The result is used both as the human-readable ``content_summary`` /
143 ``old_summary`` / ``new_summary`` on ops and as the input to
144 :func:`_content_id`. Booleans are rendered as the YAML literals
145 ``"true"`` / ``"false"`` so that ``True`` and ``"true"`` produce
146 identical content IDs. ``None`` becomes the empty string.
147
148 Args:
149 value: Any YAML-loadable scalar (str, int, float, bool, None, …).
150
151 Returns:
152 A deterministic string representation.
153 """
154 if value is None:
155 return ""
156 if isinstance(value, bool):
157 return "true" if value else "false"
158 return str(value)
159
160
161 # ──────────────────────────────────────────────────────────────────────────────
162 # Note parsing and canonical re-serialisation
163 # ──────────────────────────────────────────────────────────────────────────────
164
165
166 @dataclass
167 class _ParsedNote:
168 """Internal representation of a note used by the differ.
169
170 ``text`` is the full note text with CRLF normalised to LF. ``frontmatter``
171 is the parsed YAML mapping (always a ``dict``; empty when no frontmatter
172 is present). ``body`` is the text following the frontmatter block, or
173 the full text when no frontmatter exists.
174 """
175
176 text: str
177 frontmatter: dict[str, Any]
178 body: str
179
180
181 def _normalize_text(content: bytes) -> str:
182 """Decode bytes to text and normalise line endings to LF only.
183
184 Args:
185 content: Raw note bytes.
186
187 Returns:
188 Decoded UTF-8 text with CRLF and bare CR converted to LF.
189
190 Raises:
191 ValueError: If *content* exceeds :data:`_MAX_NOTE_BYTES`.
192 """
193 if len(content) > _MAX_NOTE_BYTES:
194 raise ValueError(
195 f"Note size {len(content)} bytes exceeds maximum {_MAX_NOTE_BYTES}"
196 )
197 text = content.decode("utf-8", errors="replace")
198 return text.replace("\r\n", "\n").replace("\r", "\n")
199
200
201 def _parse_note(content: bytes) -> _ParsedNote:
202 """Parse note bytes into a :class:`_ParsedNote`.
203
204 Recognises a YAML frontmatter block delimited by ``---`` on both ends
205 (with ``...`` also accepted as the closing delimiter, per the YAML spec).
206 When parsing fails the entire content is treated as body text with no
207 frontmatter — the function never raises on malformed input (apart from
208 the size check inside :func:`_normalize_text`).
209
210 Args:
211 content: Raw note bytes.
212
213 Returns:
214 A :class:`_ParsedNote` with normalised text, parsed frontmatter, and
215 the body text.
216 """
217 text = _normalize_text(content)
218
219 if not text.startswith("---"):
220 return _ParsedNote(text=text, frontmatter={}, body=text)
221
222 rest = text[3:]
223 if not rest.startswith("\n"):
224 return _ParsedNote(text=text, frontmatter={}, body=text)
225
226 end = -1
227 for delim in ("\n---", "\n..."):
228 pos = rest.find(delim)
229 if pos != -1 and (end == -1 or pos < end):
230 end = pos
231
232 if end == -1:
233 return _ParsedNote(text=text, frontmatter={}, body=text)
234
235 fm_yaml = rest[:end]
236 close_end = end + 4
237 if close_end < len(rest) and rest[close_end] == "\n":
238 close_end += 1
239 body = rest[close_end:]
240
241 try:
242 data = yaml.safe_load(fm_yaml)
243 except yaml.YAMLError as exc:
244 logger.debug("YAMLError parsing frontmatter, treating as body: %s", exc)
245 return _ParsedNote(text=text, frontmatter={}, body=text)
246
247 if not isinstance(data, dict):
248 return _ParsedNote(text=text, frontmatter={}, body=text)
249
250 return _ParsedNote(text=text, frontmatter=data, body=body)
251
252
253 def _order_frontmatter_keys(fm: dict[str, Any]) -> dict[str, Any]:
254 """Reorder a frontmatter dict into canonical key order.
255
256 Canonical order: ``title`` first when present, all remaining keys sorted
257 alphabetically. This guarantees that two notes with identical key /
258 value pairs in different YAML orderings canonicalise to the same bytes.
259
260 Args:
261 fm: Frontmatter mapping.
262
263 Returns:
264 A new ``dict`` with canonically-ordered keys.
265 """
266 result: dict[str, Any] = {}
267 if _TITLE_KEY in fm:
268 result[_TITLE_KEY] = fm[_TITLE_KEY]
269 for k in sorted(fm):
270 if k != _TITLE_KEY:
271 result[k] = fm[k]
272 return result
273
274
275 def _canonicalize_frontmatter(fm: dict[str, Any]) -> dict[str, Any]:
276 """Apply canonical-form rules to a frontmatter dict.
277
278 * Key order via :func:`_order_frontmatter_keys`.
279 * Set-valued list fields (:data:`_SET_FIELDS`) are deduplicated and sorted.
280
281 Args:
282 fm: Frontmatter mapping.
283
284 Returns:
285 A new ``dict`` in canonical form.
286 """
287 out = dict(fm)
288 for key in _SET_FIELDS:
289 val = out.get(key)
290 if isinstance(val, list):
291 unique_sorted = sorted({_stringify(x) for x in val})
292 if unique_sorted:
293 out[key] = unique_sorted
294 else:
295 out.pop(key, None)
296 return _order_frontmatter_keys(out)
297
298
299 def _serialize_note(frontmatter: dict[str, Any], body: str) -> bytes:
300 """Re-serialise a parsed note back to canonical bytes.
301
302 When *frontmatter* is empty the output is just the encoded body (no
303 delimiter lines). Otherwise the frontmatter is dumped via
304 :func:`yaml.safe_dump` with the canonical option set (see module
305 docstring) and wrapped in ``---`` delimiters.
306
307 Args:
308 frontmatter: Parsed frontmatter mapping.
309 body: Body text (post-frontmatter).
310
311 Returns:
312 Canonical-form note bytes.
313 """
314 canonical_fm = _canonicalize_frontmatter(frontmatter) if frontmatter else {}
315 if not canonical_fm:
316 return body.encode("utf-8")
317 yaml_text = yaml.safe_dump(
318 canonical_fm,
319 sort_keys=False,
320 default_flow_style=False,
321 allow_unicode=True,
322 width=10000,
323 )
324 return f"---\n{yaml_text}---\n{body}".encode("utf-8")
325
326
327 def canonicalize(content: bytes) -> bytes:
328 """Return the canonical-form bytes for a note.
329
330 Idempotent: ``canonicalize(canonicalize(x)) == canonicalize(x)``.
331
332 Use this on both sides of a round-trip test to absorb formatting
333 differences (CRLF vs LF, unsorted tags, alternative YAML key orders)
334 that the round-trip invariant intentionally normalises away.
335
336 Args:
337 content: Raw note bytes.
338
339 Returns:
340 Canonical-form bytes.
341 """
342 note = _parse_note(content)
343 return _serialize_note(note.frontmatter, note.body)
344
345
346 # ──────────────────────────────────────────────────────────────────────────────
347 # Section model
348 # ──────────────────────────────────────────────────────────────────────────────
349
350
351 @dataclass
352 class _Section:
353 """A heading section with an occurrence-disambiguated stable ID.
354
355 ``text`` is the verbatim section text, including the heading line itself
356 and every line up to (but not including) the next heading. Concatenating
357 every section's ``text`` reproduces the full body.
358 """
359
360 sid: str
361 level: int
362 title: str
363 text: str
364
365
366 def _section_id(level: int, title: str, occurrence: int) -> str:
367 """Compute a unique section ID from level, title, and occurrence index.
368
369 Two sections with the same level and title within the same note are
370 disambiguated by their ``occurrence`` (0-based count of the (level,
371 title) pair so far). This keeps section IDs unique inside any single
372 note while remaining deterministic across parses.
373
374 Args:
375 level: Heading level (0 for preamble, 1-6 for H1-H6).
376 title: Heading text (no ``#`` characters).
377 occurrence: 0-based index among sections sharing the same level/title.
378
379 Returns:
380 Address string, e.g. ``"section:2:Background#0"``.
381 """
382 return f"{_SECTION_PREFIX}{level}:{title}#{occurrence}"
383
384
385 def _split_sections_typed(body: str) -> list[_Section]:
386 """Split *body* into a list of :class:`_Section` with stable IDs.
387
388 Reuses :func:`muse.plugins.knowtation.symbols.split_sections` for the raw
389 splitting logic, then assigns occurrence-indexed IDs. Always returns a
390 list (possibly empty) — never raises.
391
392 Args:
393 body: Body text with frontmatter already stripped.
394
395 Returns:
396 Ordered list of sections; empty if *body* is blank.
397 """
398 try:
399 raw = split_sections(body)
400 except Exception as exc:
401 logger.debug("split_sections raised; treating as no sections: %s", exc)
402 return []
403
404 occurrence: dict[tuple[int, str], int] = {}
405 sections: list[_Section] = []
406 for level, title, sec_body, _line in raw:
407 key = (level, title)
408 idx = occurrence.get(key, 0)
409 occurrence[key] = idx + 1
410 sections.append(
411 _Section(
412 sid=_section_id(level, title, idx),
413 level=level,
414 title=title,
415 text=sec_body,
416 )
417 )
418 return sections
419
420
421 def _section_from_address(address: str, text: str) -> _Section:
422 """Reconstruct a :class:`_Section` from a Layer 3 op's address and content.
423
424 The address shape is ``"section:<level>:<title>#<occurrence>"`` (see
425 :func:`_section_id`). Parsing is best-effort: malformed addresses default
426 to ``level=0`` / ``title="(preamble)"`` so that apply() remains
427 crash-free on adversarial input.
428
429 Args:
430 address: Section address from an op.
431 text: Full section text from the op's ``content_summary``.
432
433 Returns:
434 A reconstructed :class:`_Section`.
435 """
436 level = 0
437 title = "(preamble)"
438 if address.startswith(_SECTION_PREFIX):
439 rest = address[len(_SECTION_PREFIX) :]
440 level_str, _, name_part = rest.partition(":")
441 try:
442 level = int(level_str)
443 except ValueError:
444 level = 0
445 # rpartition handles titles that themselves contain '#' characters.
446 head, sep, _occ = name_part.rpartition("#")
447 title = head if sep else name_part
448 return _Section(sid=address, level=level, title=title, text=text)
449
450
451 # ──────────────────────────────────────────────────────────────────────────────
452 # Layer 1 — title diff
453 # ──────────────────────────────────────────────────────────────────────────────
454
455
456 def _diff_title(a: _ParsedNote, b: _ParsedNote) -> list[DomainOp]:
457 """Emit at most one :class:`ReplaceOp` capturing the title change.
458
459 The title is treated as a string ('' when the field is absent). No op is
460 emitted when both sides agree.
461
462 Args:
463 a: Parsed base note.
464 b: Parsed target note.
465
466 Returns:
467 ``[ReplaceOp]`` when the title changed, else ``[]``.
468 """
469 title_a = a.frontmatter.get(_TITLE_KEY)
470 title_b = b.frontmatter.get(_TITLE_KEY)
471 if title_a == title_b:
472 return []
473 old = _stringify(title_a)
474 new = _stringify(title_b)
475 return [
476 ReplaceOp(
477 op="replace",
478 address=_TITLE_ADDR,
479 position=None,
480 old_content_id=_content_id(old),
481 new_content_id=_content_id(new),
482 old_summary=old,
483 new_summary=new,
484 )
485 ]
486
487
488 # ──────────────────────────────────────────────────────────────────────────────
489 # Layer 2 — frontmatter diff (per-key, set-aware)
490 # ──────────────────────────────────────────────────────────────────────────────
491
492
493 def _diff_frontmatter(a: _ParsedNote, b: _ParsedNote) -> list[DomainOp]:
494 """Per-key frontmatter diff, excluding the title (handled by Layer 1).
495
496 Keys are processed in alphabetical order so that the output is
497 deterministic. For each key:
498
499 * Present only in *b* → :func:`_diff_field_added`.
500 * Present only in *a* → :func:`_diff_field_removed`.
501 * Present in both with different values → :func:`_diff_field_changed`.
502 * Equal in both → no op.
503
504 Args:
505 a: Parsed base note.
506 b: Parsed target note.
507
508 Returns:
509 Ordered list of ops (delete-side first, then insert-side, by key).
510 """
511 fm_a = {k: v for k, v in a.frontmatter.items() if k != _TITLE_KEY}
512 fm_b = {k: v for k, v in b.frontmatter.items() if k != _TITLE_KEY}
513 ops: list[DomainOp] = []
514 for key in sorted(set(fm_a) | set(fm_b)):
515 if key in fm_a and key not in fm_b:
516 ops.extend(_diff_field_removed(key, fm_a[key]))
517 elif key not in fm_a and key in fm_b:
518 ops.extend(_diff_field_added(key, fm_b[key]))
519 else:
520 if fm_a[key] != fm_b[key]:
521 ops.extend(_diff_field_changed(key, fm_a[key], fm_b[key]))
522 return ops
523
524
525 def _set_field_addr(key: str, item: str) -> str:
526 """Return the per-element address for a set-valued frontmatter field.
527
528 Args:
529 key: Frontmatter key (e.g. ``"tags"``).
530 item: Element value.
531
532 Returns:
533 ``"frontmatter:<key>::<item>"``.
534 """
535 return f"{_FM_PREFIX}{key}::{item}"
536
537
538 def _diff_field_added(key: str, value: Any) -> list[DomainOp]:
539 """Emit insert ops for a newly-added frontmatter field.
540
541 Set-valued fields produce one :class:`InsertOp` per element; scalar
542 fields produce a single :class:`InsertOp` for the whole value.
543
544 Args:
545 key: Frontmatter key.
546 value: New value.
547
548 Returns:
549 Ordered list of insert ops.
550 """
551 if key in _SET_FIELDS and isinstance(value, list):
552 return [
553 InsertOp(
554 op="insert",
555 address=_set_field_addr(key, _stringify(item)),
556 position=None,
557 content_id=_content_id(_stringify(item)),
558 content_summary=_stringify(item),
559 )
560 for item in sorted({_stringify(x) for x in value})
561 ]
562 rendered = _stringify(value)
563 return [
564 InsertOp(
565 op="insert",
566 address=f"{_FM_PREFIX}{key}",
567 position=None,
568 content_id=_content_id(rendered),
569 content_summary=rendered,
570 )
571 ]
572
573
574 def _diff_field_removed(key: str, value: Any) -> list[DomainOp]:
575 """Emit delete ops for a removed frontmatter field.
576
577 Mirrors :func:`_diff_field_added` for the inverse direction.
578
579 Args:
580 key: Frontmatter key.
581 value: Old value.
582
583 Returns:
584 Ordered list of delete ops.
585 """
586 if key in _SET_FIELDS and isinstance(value, list):
587 return [
588 DeleteOp(
589 op="delete",
590 address=_set_field_addr(key, _stringify(item)),
591 position=None,
592 content_id=_content_id(_stringify(item)),
593 content_summary=_stringify(item),
594 )
595 for item in sorted({_stringify(x) for x in value})
596 ]
597 rendered = _stringify(value)
598 return [
599 DeleteOp(
600 op="delete",
601 address=f"{_FM_PREFIX}{key}",
602 position=None,
603 content_id=_content_id(rendered),
604 content_summary=rendered,
605 )
606 ]
607
608
609 def _diff_field_changed(key: str, val_a: Any, val_b: Any) -> list[DomainOp]:
610 """Emit fine-grained ops for an existing field whose value changed.
611
612 Set fields are diffed element-wise (Insert per added, Delete per removed).
613 Scalar fields become a single :class:`ReplaceOp`.
614
615 Args:
616 key: Frontmatter key.
617 val_a: Old value.
618 val_b: New value.
619
620 Returns:
621 Ordered list of ops.
622 """
623 if key in _SET_FIELDS and isinstance(val_a, list) and isinstance(val_b, list):
624 set_a = {_stringify(x) for x in val_a}
625 set_b = {_stringify(x) for x in val_b}
626 added = sorted(set_b - set_a)
627 removed = sorted(set_a - set_b)
628 ops: list[DomainOp] = []
629 for item in removed:
630 ops.append(
631 DeleteOp(
632 op="delete",
633 address=_set_field_addr(key, item),
634 position=None,
635 content_id=_content_id(item),
636 content_summary=item,
637 )
638 )
639 for item in added:
640 ops.append(
641 InsertOp(
642 op="insert",
643 address=_set_field_addr(key, item),
644 position=None,
645 content_id=_content_id(item),
646 content_summary=item,
647 )
648 )
649 return ops
650 old = _stringify(val_a)
651 new = _stringify(val_b)
652 return [
653 ReplaceOp(
654 op="replace",
655 address=f"{_FM_PREFIX}{key}",
656 position=None,
657 old_content_id=_content_id(old),
658 new_content_id=_content_id(new),
659 old_summary=old,
660 new_summary=new,
661 )
662 ]
663
664
665 # ──────────────────────────────────────────────────────────────────────────────
666 # Layer 3 — section diff
667 # ──────────────────────────────────────────────────────────────────────────────
668
669
670 def _diff_sections(
671 sections_a: list[_Section],
672 sections_b: list[_Section],
673 ) -> tuple[list[DomainOp], set[str]]:
674 """Diff two section sequences using Myers on the section ID list.
675
676 For each Myers edit step:
677
678 * ``keep`` → record the section ID as stable (used by Layer 4).
679 * ``insert`` → emit :class:`InsertOp` carrying the full section text.
680 * ``delete`` → emit :class:`DeleteOp` carrying the full section text.
681
682 Adjacent insert / delete pairs sharing a content ID (unchanged section
683 bytes) are collapsed into :class:`MoveOp` via
684 :func:`muse.core.diff_algorithms.lcs.detect_moves`.
685
686 Args:
687 sections_a: Sections from the base note.
688 sections_b: Sections from the target note.
689
690 Returns:
691 Tuple ``(ops, stable_section_ids)``. ``stable_section_ids`` is the
692 set of IDs the Myers algorithm matched between the two sides; these
693 sections are eligible for body-line diffing in Layer 4.
694 """
695 ids_a = [s.sid for s in sections_a]
696 ids_b = [s.sid for s in sections_b]
697 a_by_id = {s.sid: s for s in sections_a}
698 b_by_id = {s.sid: s for s in sections_b}
699
700 steps = myers_ses(ids_a, ids_b)
701
702 raw_inserts: list[InsertOp] = []
703 raw_deletes: list[DeleteOp] = []
704 stable_ids: set[str] = set()
705
706 for step in steps:
707 if step.kind == "keep":
708 stable_ids.add(step.item)
709 elif step.kind == "insert":
710 sec = b_by_id[step.item]
711 raw_inserts.append(
712 InsertOp(
713 op="insert",
714 address=sec.sid,
715 position=step.target_index,
716 content_id=_content_id(sec.text),
717 content_summary=sec.text,
718 )
719 )
720 elif step.kind == "delete":
721 sec = a_by_id[step.item]
722 raw_deletes.append(
723 DeleteOp(
724 op="delete",
725 address=sec.sid,
726 position=step.base_index,
727 content_id=_content_id(sec.text),
728 content_summary=sec.text,
729 )
730 )
731
732 moves, rem_inserts, rem_deletes = detect_moves(raw_inserts, raw_deletes)
733 ops: list[DomainOp] = [*rem_deletes, *rem_inserts, *moves]
734 return ops, stable_ids
735
736
737 # ──────────────────────────────────────────────────────────────────────────────
738 # Layer 4 — per-stable-section body line diff
739 # ──────────────────────────────────────────────────────────────────────────────
740
741
742 def _diff_body_lines(sid: str, body_a: str, body_b: str) -> list[DomainOp]:
743 """Run Myers on the line sequences of one stable section's bodies.
744
745 Each line gets an op whose ``address`` includes the parent section's ID
746 and the per-line offset (``<sid>::line:<n>``). Inserts use the target
747 line index; deletes use the base line index.
748
749 Args:
750 sid: Stable section ID (present in both A and B).
751 body_a: Section body text from A.
752 body_b: Section body text from B.
753
754 Returns:
755 Ordered list of ops: every delete first (descending base index is
756 preserved by :func:`apply`), then every insert.
757 """
758 lines_a = body_a.split("\n")
759 lines_b = body_b.split("\n")
760 if lines_a == lines_b:
761 return []
762
763 steps = myers_ses(lines_a, lines_b)
764
765 inserts: list[InsertOp] = []
766 deletes: list[DeleteOp] = []
767 for step in steps:
768 if step.kind == "insert":
769 inserts.append(
770 InsertOp(
771 op="insert",
772 address=f"{sid}{_LINE_INFIX}{step.target_index}",
773 position=step.target_index,
774 content_id=_content_id(step.item),
775 content_summary=step.item,
776 )
777 )
778 elif step.kind == "delete":
779 deletes.append(
780 DeleteOp(
781 op="delete",
782 address=f"{sid}{_LINE_INFIX}{step.base_index}",
783 position=step.base_index,
784 content_id=_content_id(step.item),
785 content_summary=step.item,
786 )
787 )
788 return [*deletes, *inserts]
789
790
791 # ──────────────────────────────────────────────────────────────────────────────
792 # Public diff entry point
793 # ──────────────────────────────────────────────────────────────────────────────
794
795
796 def diff_notes(note_a: bytes, note_b: bytes) -> StructuredDelta:
797 """Diff two knowtation notes, returning a typed :class:`StructuredDelta`.
798
799 Runs all four layers in sequence and concatenates the resulting ops.
800 The ``summary`` field is a short human-readable count of ops per layer.
801
802 Args:
803 note_a: Base note bytes (older state).
804 note_b: Target note bytes (newer state).
805
806 Returns:
807 :class:`StructuredDelta` with ``domain="knowtation"`` and an ordered
808 ``ops`` list.
809
810 Raises:
811 ValueError: If either input exceeds :data:`_MAX_NOTE_BYTES`.
812 """
813 a = _parse_note(note_a)
814 b = _parse_note(note_b)
815
816 title_ops = _diff_title(a, b)
817 fm_ops = _diff_frontmatter(a, b)
818
819 sections_a = _split_sections_typed(a.body)
820 sections_b = _split_sections_typed(b.body)
821 section_ops, stable_ids = _diff_sections(sections_a, sections_b)
822
823 body_ops: list[DomainOp] = []
824 a_section_text = {s.sid: s.text for s in sections_a}
825 b_section_text = {s.sid: s.text for s in sections_b}
826 for sid in sorted(stable_ids):
827 text_a = a_section_text.get(sid, "")
828 text_b = b_section_text.get(sid, "")
829 if text_a != text_b:
830 body_ops.extend(_diff_body_lines(sid, text_a, text_b))
831
832 ops: list[DomainOp] = [*title_ops, *fm_ops, *section_ops, *body_ops]
833 summary = _summarize_ops(title_ops, fm_ops, section_ops, body_ops)
834 return StructuredDelta(domain=_DOMAIN, ops=ops, summary=summary)
835
836
837 def _summarize_ops(
838 title_ops: list[DomainOp],
839 fm_ops: list[DomainOp],
840 section_ops: list[DomainOp],
841 body_ops: list[DomainOp],
842 ) -> str:
843 """Compose a human-readable summary across the four diff layers.
844
845 Args:
846 title_ops: Ops produced by Layer 1.
847 fm_ops: Ops produced by Layer 2.
848 section_ops: Ops produced by Layer 3.
849 body_ops: Ops produced by Layer 4.
850
851 Returns:
852 A short comma-separated string, or ``"no changes"`` when every
853 layer is empty.
854 """
855 parts: list[str] = []
856 if title_ops:
857 parts.append("title changed")
858 if fm_ops:
859 n_ins = sum(1 for op in fm_ops if op["op"] == "insert")
860 n_del = sum(1 for op in fm_ops if op["op"] == "delete")
861 n_rep = sum(1 for op in fm_ops if op["op"] == "replace")
862 sub: list[str] = []
863 if n_ins:
864 sub.append(f"{n_ins} frontmatter added")
865 if n_del:
866 sub.append(f"{n_del} frontmatter removed")
867 if n_rep:
868 sub.append(f"{n_rep} frontmatter changed")
869 parts.extend(sub)
870 if section_ops:
871 n_ins = sum(1 for op in section_ops if op["op"] == "insert")
872 n_del = sum(1 for op in section_ops if op["op"] == "delete")
873 n_mov = sum(1 for op in section_ops if op["op"] == "move")
874 sub_s: list[str] = []
875 if n_ins:
876 sub_s.append(f"{n_ins} sections added")
877 if n_del:
878 sub_s.append(f"{n_del} sections removed")
879 if n_mov:
880 sub_s.append(f"{n_mov} sections moved")
881 parts.extend(sub_s)
882 if body_ops:
883 n_lines = len(body_ops)
884 parts.append(f"{n_lines} body lines changed")
885 return ", ".join(parts) if parts else "no changes"
886
887
888 # ──────────────────────────────────────────────────────────────────────────────
889 # apply()
890 # ──────────────────────────────────────────────────────────────────────────────
891
892
893 def apply(delta: StructuredDelta, content: bytes) -> bytes:
894 """Apply *delta* to a note's bytes, returning canonical-form new bytes.
895
896 The four layers are applied in order: title → frontmatter → sections →
897 body lines. The function is pure: it never mutates *content* or
898 *delta* and always returns a new ``bytes`` object.
899
900 The output is in canonical form (see :func:`canonicalize` and the module
901 docstring). Tests should canonicalise both sides of a round-trip
902 assertion to absorb formatting differences.
903
904 Args:
905 delta: A :class:`StructuredDelta` produced by :func:`diff_notes`.
906 content: The base note bytes the delta applies to.
907
908 Returns:
909 The new note bytes after every op has been applied.
910
911 Raises:
912 ValueError: If *content* exceeds :data:`_MAX_NOTE_BYTES`.
913 """
914 note = _parse_note(content)
915 fm = dict(note.frontmatter)
916
917 title_ops, fm_ops, section_ops, body_line_ops = _bucket_ops(
918 delta.get("ops", [])
919 )
920
921 fm = _apply_title_ops(fm, title_ops)
922 fm = _apply_fm_ops(fm, fm_ops)
923
924 sections = _split_sections_typed(note.body)
925 sections = _apply_section_ops(sections, section_ops)
926 sections = _apply_body_line_ops(sections, body_line_ops)
927
928 new_body = "".join(s.text for s in sections)
929 return _serialize_note(fm, new_body)
930
931
932 def _bucket_ops(
933 ops: list[DomainOp],
934 ) -> tuple[
935 list[DomainOp],
936 list[DomainOp],
937 list[DomainOp],
938 dict[str, list[DomainOp]],
939 ]:
940 """Sort ops into per-layer buckets based on their address shape.
941
942 Address conventions (see module docstring):
943
944 * ``"title"`` → Layer 1.
945 * ``"frontmatter:…"`` → Layer 2.
946 * ``"section:…::line:n"`` → Layer 4, keyed by parent section ID.
947 * ``"section:…"`` → Layer 3.
948
949 Unknown addresses are logged and silently dropped to keep apply() robust
950 against partial / adversarial deltas.
951
952 Args:
953 ops: The op list from a :class:`StructuredDelta`.
954
955 Returns:
956 Tuple of ``(title_ops, fm_ops, section_ops, body_line_ops_by_sid)``.
957 """
958 title_ops: list[DomainOp] = []
959 fm_ops: list[DomainOp] = []
960 section_ops: list[DomainOp] = []
961 body_line_ops: dict[str, list[DomainOp]] = {}
962
963 for op in ops:
964 addr = op.get("address", "")
965 if addr == _TITLE_ADDR:
966 title_ops.append(op)
967 elif _LINE_INFIX in addr:
968 sid = addr.split(_LINE_INFIX, 1)[0]
969 body_line_ops.setdefault(sid, []).append(op)
970 elif addr.startswith(_FM_PREFIX):
971 fm_ops.append(op)
972 elif addr.startswith(_SECTION_PREFIX):
973 section_ops.append(op)
974 else:
975 logger.debug("Dropping op with unknown address: %r", addr)
976 return title_ops, fm_ops, section_ops, body_line_ops
977
978
979 def _apply_title_ops(
980 fm: dict[str, Any], ops: list[DomainOp]
981 ) -> dict[str, Any]:
982 """Apply Layer 1 ops to *fm*, returning a new dict.
983
984 A ReplaceOp on the title sets / clears the ``title`` key. When the new
985 value is the empty string the key is removed entirely so that the
986 canonical-form serialisation does not include an empty title line.
987
988 Args:
989 fm: Current frontmatter mapping.
990 ops: Layer 1 ops (zero or one ReplaceOp).
991
992 Returns:
993 New frontmatter mapping.
994 """
995 result = dict(fm)
996 for op in ops:
997 if op["op"] != "replace":
998 continue
999 new = op.get("new_summary", "")
1000 if new == "":
1001 result.pop(_TITLE_KEY, None)
1002 else:
1003 result[_TITLE_KEY] = new
1004 return result
1005
1006
1007 def _apply_fm_ops(
1008 fm: dict[str, Any], ops: list[DomainOp]
1009 ) -> dict[str, Any]:
1010 """Apply Layer 2 ops to *fm*, returning a new dict.
1011
1012 Set-field addresses (``"frontmatter:<key>::<item>"``) trigger element-wise
1013 set updates; scalar-field addresses (``"frontmatter:<key>"``) trigger
1014 insert / delete / replace on the value. The resulting set fields are
1015 deduplicated and sorted to keep canonical form stable.
1016
1017 Args:
1018 fm: Current frontmatter mapping.
1019 ops: Layer 2 ops (any combination of insert / delete / replace).
1020
1021 Returns:
1022 New frontmatter mapping.
1023 """
1024 result = dict(fm)
1025 for op in ops:
1026 addr: str = op["address"]
1027 if "::" in addr:
1028 base_addr, _, item = addr.partition("::")
1029 key = base_addr[len(_FM_PREFIX) :]
1030 current = result.get(key)
1031 current_list: list[str] = (
1032 [str(x) for x in current] if isinstance(current, list) else []
1033 )
1034 kind = op["op"]
1035 if kind == "insert":
1036 if item not in current_list:
1037 current_list.append(item)
1038 elif kind == "delete":
1039 while item in current_list:
1040 current_list.remove(item)
1041 current_list = sorted(set(current_list))
1042 if current_list:
1043 result[key] = current_list
1044 else:
1045 result.pop(key, None)
1046 else:
1047 key = addr[len(_FM_PREFIX) :]
1048 kind = op["op"]
1049 if kind == "insert":
1050 result[key] = op.get("content_summary", "")
1051 elif kind == "delete":
1052 result.pop(key, None)
1053 elif kind == "replace":
1054 result[key] = op.get("new_summary", "")
1055 return result
1056
1057
1058 def _apply_section_ops(
1059 sections: list[_Section], ops: list[DomainOp]
1060 ) -> list[_Section]:
1061 """Apply Layer 3 ops to *sections*, returning a new list.
1062
1063 MoveOps are decomposed back into a delete-at-source + insert-at-target
1064 pair using the base section list as the content source (a move never
1065 changes section bytes by definition). Then all deletes are applied in
1066 descending position order, followed by all inserts in ascending target
1067 position order. This delete-then-insert ordering is the well-known
1068 correct strategy for LCS edit-script replay.
1069
1070 Args:
1071 sections: Current section list.
1072 ops: Layer 3 ops.
1073
1074 Returns:
1075 New section list.
1076 """
1077 cid_to_section: dict[str, _Section] = {
1078 _content_id(s.text): s for s in sections
1079 }
1080
1081 deletes: list[DomainOp] = []
1082 inserts: list[DomainOp] = []
1083
1084 for op in ops:
1085 if op["op"] == "delete":
1086 deletes.append(op)
1087 elif op["op"] == "insert":
1088 inserts.append(op)
1089 elif op["op"] == "move":
1090 move_op: MoveOp = op
1091 cid = move_op["content_id"]
1092 src = cid_to_section.get(cid)
1093 if src is None:
1094 logger.debug(
1095 "MoveOp references unknown content_id %r; skipping", cid
1096 )
1097 continue
1098 deletes.append(
1099 DeleteOp(
1100 op="delete",
1101 address=move_op["address"],
1102 position=move_op["from_position"],
1103 content_id=cid,
1104 content_summary=src.text,
1105 )
1106 )
1107 inserts.append(
1108 InsertOp(
1109 op="insert",
1110 address=move_op["address"],
1111 position=move_op["to_position"],
1112 content_id=cid,
1113 content_summary=src.text,
1114 )
1115 )
1116
1117 intermediate = list(sections)
1118 for d in sorted(deletes, key=lambda o: -int(o.get("position") or 0)):
1119 pos_val = d.get("position")
1120 if pos_val is None:
1121 continue
1122 pos = int(pos_val)
1123 if 0 <= pos < len(intermediate):
1124 del intermediate[pos]
1125
1126 result = list(intermediate)
1127 for ins in sorted(inserts, key=lambda o: int(o.get("position") or 0)):
1128 pos_val = ins.get("position")
1129 if pos_val is None:
1130 pos = len(result)
1131 else:
1132 pos = int(pos_val)
1133 text = ins.get("content_summary", "")
1134 sec = _section_from_address(ins["address"], text)
1135 if pos < 0:
1136 pos = 0
1137 if pos > len(result):
1138 pos = len(result)
1139 result.insert(pos, sec)
1140 return result
1141
1142
1143 def _apply_body_line_ops(
1144 sections: list[_Section],
1145 body_line_ops: dict[str, list[DomainOp]],
1146 ) -> list[_Section]:
1147 """Apply Layer 4 ops, returning a new section list with edited bodies.
1148
1149 For each section ID present in *body_line_ops*, look up the section in
1150 *sections* by ID, split its body into lines, then run the standard
1151 delete-descending / insert-ascending replay. Sections not present in
1152 *body_line_ops* are passed through unchanged.
1153
1154 Args:
1155 sections: Current section list (after Layer 3 has been applied).
1156 body_line_ops: Mapping of section ID → list of ops.
1157
1158 Returns:
1159 New section list with body-line edits applied.
1160 """
1161 if not body_line_ops:
1162 return sections
1163
1164 sid_to_index: dict[str, int] = {s.sid: i for i, s in enumerate(sections)}
1165
1166 result = list(sections)
1167 for sid, ops in body_line_ops.items():
1168 idx = sid_to_index.get(sid)
1169 if idx is None:
1170 logger.debug(
1171 "Body line ops reference unknown section %r; skipping", sid
1172 )
1173 continue
1174 sec = result[idx]
1175 lines = sec.text.split("\n")
1176 deletes = [op for op in ops if op["op"] == "delete"]
1177 inserts = [op for op in ops if op["op"] == "insert"]
1178
1179 for d in sorted(deletes, key=lambda o: -int(o.get("position") or 0)):
1180 pos_val = d.get("position")
1181 if pos_val is None:
1182 continue
1183 pos = int(pos_val)
1184 if 0 <= pos < len(lines):
1185 del lines[pos]
1186
1187 for ins in sorted(inserts, key=lambda o: int(o.get("position") or 0)):
1188 pos_val = ins.get("position")
1189 if pos_val is None:
1190 pos = len(lines)
1191 else:
1192 pos = int(pos_val)
1193 line = ins.get("content_summary", "")
1194 if pos < 0:
1195 pos = 0
1196 if pos > len(lines):
1197 pos = len(lines)
1198 lines.insert(pos, line)
1199
1200 new_text = "\n".join(lines)
1201 result[idx] = _Section(
1202 sid=sec.sid, level=sec.level, title=sec.title, text=new_text
1203 )
1204
1205 return result
1206
1207
1208 # ──────────────────────────────────────────────────────────────────────────────
1209 # Public re-exports
1210 # ──────────────────────────────────────────────────────────────────────────────
1211
1212 __all__ = [
1213 "diff_notes",
1214 "apply",
1215 "canonicalize",
1216 ]
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 33 days ago