gabriel / muse public

strategies.py file-level

at sha256:d · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:0 test: validate staging push and MP workflow for aaronrene · · Jul 7, 2026
1 """Knowtation-specific merge strategy implementations.
2
3 These functions are called by the Knowtation merger (Phase 2.4) when
4 :func:`~muse.core.attributes.resolve_strategy` returns a Knowtation-specific
5 strategy string. They share a common signature::
6
7 fn(ours: bytes, theirs: bytes, base: bytes) -> bytes
8
9 *ours* β€” the current-branch (left) version of the note.
10 *theirs* β€” the incoming-branch (right) version of the note.
11 *base* β€” the common merge-base version of the note.
12
13 All three arguments are raw Markdown bytes (UTF-8, LF line endings normalised
14 by the caller). Return value is the resolved content as raw bytes.
15
16 Strategies defined here
17 -----------------------
18
19 ``prefer-newer-date`` β†’ :func:`prefer_newer_date`
20 ``union-sorted`` β†’ :func:`union_sorted`
21 ``knowtation-3way`` β†’ :func:`knowtation_3way`
22
23 Security notes
24 --------------
25 - Frontmatter is parsed with ``yaml.safe_load`` only.
26 - Date comparisons use ``datetime.date.fromisoformat`` β€” invalid strings are
27 caught and treated as missing rather than propagating exceptions.
28 - Input size is not capped here (the merge engine is responsible for that);
29 however, none of the implementations do anything super-linear on input size.
30
31 OBA review note (prefer-newer-date fallback)
32 --------------------------------------------
33 The fallback hierarchy was chosen after analysis of the three failure modes:
34
35 1. **Both dates missing**: No temporal signal β€” default to ``ours`` (current
36 branch is the authoritative capture point).
37 2. **One date missing**: The versioned note carries deliberate metadata;
38 prefer the versioned side regardless of which branch it is on.
39 3. **Dates equal**: Tie-break with ``ours`` (current branch wins; import
40 pipelines that produce the same timestamp for the same event will never
41 trigger an unwanted remote override).
42 4. **Date malformed** (e.g. ``date: "yesterday"``): Treat as missing; do not
43 crash β€” SPEC Β§2.2 allows free-form date strings for human notes.
44 """
45
46 from __future__ import annotations
47
48 import logging
49 from datetime import date, datetime
50 from typing import Any, Callable
51
52 logger = logging.getLogger(__name__)
53
54 # Strategy function type.
55 StrategyFn = Callable[[bytes, bytes, bytes], bytes]
56
57
58 # ---------------------------------------------------------------------------
59 # Internal helpers
60 # ---------------------------------------------------------------------------
61
62
63 def _parse_date(fm_date: str | None) -> date | None:
64 """Parse a SPEC Β§2.1 date string β†’ ``datetime.date``, or ``None``.
65
66 Accepts ISO 8601 datetime strings and plain ``YYYY-MM-DD`` dates.
67 Returns ``None`` for any string that cannot be parsed.
68
69 Args:
70 fm_date: Value of the ``date`` frontmatter field, or ``None``.
71
72 Returns:
73 A :class:`datetime.date` on success, or ``None`` on failure.
74 """
75 if not fm_date:
76 return None
77 # Try plain date first (most common).
78 try:
79 return date.fromisoformat(str(fm_date).strip()[:10])
80 except (ValueError, TypeError):
81 pass
82 # Try full ISO 8601 datetime (e.g. "2025-01-15T14:30:00Z").
83 try:
84 return datetime.fromisoformat(str(fm_date).strip()[:19]).date()
85 except (ValueError, TypeError):
86 return None
87
88
89 def _parse_fm_safe(content: bytes) -> dict[str, Any] | None:
90 """Extract raw frontmatter dict from *content* without raising.
91
92 Returns ``None`` when the note has no frontmatter or the YAML is invalid.
93 """
94 from muse.plugins.knowtation.parser import _extract_frontmatter_block
95
96 import yaml
97
98 raw = _extract_frontmatter_block(content)
99 if raw is None:
100 return None
101 try:
102 data = yaml.safe_load(raw)
103 except yaml.YAMLError:
104 return None
105 return data if isinstance(data, dict) else None
106
107
108 def _reconstruct(
109 original: bytes,
110 fm_data: dict[str, Any],
111 ) -> bytes:
112 """Serialise *fm_data* back into a YAML frontmatter block and re-attach body.
113
114 The note body (everything after the closing ``---``) is preserved verbatim
115 from *original*.
116
117 Args:
118 original: Original note bytes (used to extract the body).
119 fm_data: Frontmatter dict to serialise.
120
121 Returns:
122 Bytes with rebuilt frontmatter followed by the original body.
123 """
124 import yaml
125
126 try:
127 text = original.decode("utf-8", errors="replace")
128 except Exception:
129 text = ""
130
131 # Extract body: everything after the closing --- delimiter.
132 body = ""
133 if text.startswith("---"):
134 rest = text[3:]
135 for delim in ("\n---", "\n..."):
136 pos = rest.find(delim)
137 if pos != -1:
138 body = rest[pos + len(delim):]
139 break
140
141 fm_yaml = yaml.dump(
142 fm_data,
143 allow_unicode=True,
144 default_flow_style=False,
145 sort_keys=False,
146 )
147 return f"---\n{fm_yaml}---\n{body}".encode("utf-8")
148
149
150 def _coerce_list(value: Any) -> list[str]:
151 """Coerce a YAML value to a sorted list of strings (set semantics)."""
152 from muse.plugins.knowtation.parser import _coerce_str_or_list
153 return _coerce_str_or_list(value)
154
155
156 # Set-valued frontmatter fields that support union/union-sorted semantics.
157 _SET_FIELDS: frozenset[str] = frozenset(
158 {"tags", "entity", "follows", "summarizes", "attachments"}
159 )
160
161
162 # ---------------------------------------------------------------------------
163 # prefer-newer-date
164 # ---------------------------------------------------------------------------
165
166
167 def prefer_newer_date(ours: bytes, theirs: bytes, base: bytes) -> bytes:
168 """Return whichever note has the later ``date`` frontmatter field.
169
170 Implements the ``prefer-newer-date`` strategy. The *base* argument is
171 accepted for interface compatibility but is not used β€” the decision is
172 purely between *ours* and *theirs*.
173
174 Fallback hierarchy (see module docstring for OBA rationale):
175
176 1. Both parseable, *theirs* is strictly later β†’ return *theirs*.
177 2. Otherwise β†’ return *ours*.
178
179 Args:
180 ours: Current-branch note bytes.
181 theirs: Incoming-branch note bytes.
182 base: Common merge-base note bytes (not used).
183
184 Returns:
185 The resolved note bytes.
186 """
187 fm_ours = _parse_fm_safe(ours)
188 fm_theirs = _parse_fm_safe(theirs)
189
190 date_ours = _parse_date(fm_ours.get("date") if fm_ours else None)
191 date_theirs = _parse_date(fm_theirs.get("date") if fm_theirs else None)
192
193 # Case: only theirs has a parseable date β†’ prefer theirs (more intentional).
194 if date_ours is None and date_theirs is not None:
195 logger.debug("prefer-newer-date: ours has no parseable date; returning theirs")
196 return theirs
197
198 # Case: only ours has a parseable date, or both missing β†’ ours.
199 if date_theirs is None:
200 return ours
201
202 # Both parseable: strictly later β†’ theirs; equal or earlier β†’ ours.
203 if date_theirs > date_ours:
204 logger.debug(
205 "prefer-newer-date: theirs (%s) > ours (%s); returning theirs",
206 date_theirs,
207 date_ours,
208 )
209 return theirs
210
211 return ours
212
213
214 # ---------------------------------------------------------------------------
215 # union-sorted
216 # ---------------------------------------------------------------------------
217
218
219 def union_sorted(ours: bytes, theirs: bytes, base: bytes) -> bytes:
220 """Return a union of both notes' set-valued frontmatter fields, sorted.
221
222 Implements the ``union-sorted`` strategy:
223
224 - **Set fields** (``tags``, ``entity``, ``follows``, ``summarizes``,
225 ``attachments``): union of both sides, deduplicated and sorted
226 alphabetically.
227 - **Scalar fields**: ours wins (current-branch is authoritative for
228 non-set values).
229 - **Body**: ours is returned verbatim (body merging is handled by the
230 section-level merger in Phase 2.4).
231
232 Args:
233 ours: Current-branch note bytes.
234 theirs: Incoming-branch note bytes.
235 base: Common merge-base note bytes (used to detect deletions).
236
237 Returns:
238 Resolved note bytes with sorted set-valued frontmatter.
239 """
240 fm_ours = _parse_fm_safe(ours)
241 fm_theirs = _parse_fm_safe(theirs)
242
243 if fm_ours is None:
244 return ours
245 if fm_theirs is None:
246 return ours
247
248 fm_base = _parse_fm_safe(base) or {}
249
250 # Build merged frontmatter starting from ours.
251 merged: dict[str, Any] = dict(fm_ours)
252
253 for field_name in _SET_FIELDS:
254 set_ours = set(_coerce_list(fm_ours.get(field_name)))
255 set_theirs = set(_coerce_list(fm_theirs.get(field_name)))
256 set_base = set(_coerce_list(fm_base.get(field_name)))
257
258 # Union of additions from both sides; honour deletions agreed by both.
259 ours_deletions = set_base - set_ours
260 theirs_deletions = set_base - set_theirs
261 both_deleted = ours_deletions & theirs_deletions
262
263 union = (set_ours | set_theirs) - both_deleted
264
265 if union:
266 merged[field_name] = sorted(union)
267 elif field_name in merged:
268 del merged[field_name]
269
270 return _reconstruct(ours, merged)
271
272
273 # ---------------------------------------------------------------------------
274 # knowtation-3way
275 # ---------------------------------------------------------------------------
276
277
278 def knowtation_3way(ours: bytes, theirs: bytes, base: bytes) -> bytes:
279 """Delegate to the Knowtation structured three-way merger.
280
281 Implements the ``knowtation-3way`` strategy. When the
282 :mod:`~muse.plugins.knowtation.merger` module is available (Phase 2.4),
283 calls its ``merge_notes()`` entry point. Falls back to ``ours`` with a
284 warning when the merger is not yet available, so that Phase 2.2 tests and
285 Phase 2.3 policies can be exercised before Phase 2.4 lands.
286
287 Args:
288 ours: Current-branch note bytes.
289 theirs: Incoming-branch note bytes.
290 base: Common merge-base note bytes.
291
292 Returns:
293 Resolved note bytes from the structured merger, or *ours* on fallback.
294 """
295 try:
296 from muse.plugins.knowtation.merger import merge_notes
297
298 return merge_notes(ours=ours, theirs=theirs, base=base)
299 except ImportError:
300 logger.warning(
301 "knowtation-3way: merger.py (Phase 2.4) is not yet available; "
302 "falling back to 'ours'."
303 )
304 return ours
305 except Exception as exc:
306 logger.warning(
307 "knowtation-3way: merger raised %s; falling back to 'ours'.",
308 exc,
309 )
310 return ours
311
312
313 # ---------------------------------------------------------------------------
314 # Dispatch table
315 # ---------------------------------------------------------------------------
316
317 #: Maps each Knowtation-specific strategy name to its implementation function.
318 #: Used by the merger and any caller that needs to dispatch by name.
319 STRATEGY_DISPATCH: dict[str, StrategyFn] = {
320 "prefer-newer-date": prefer_newer_date,
321 "union-sorted": union_sorted,
322 "knowtation-3way": knowtation_3way,
323 }
324
325
326 def apply_strategy(
327 strategy: str,
328 ours: bytes,
329 theirs: bytes,
330 base: bytes,
331 ) -> bytes | None:
332 """Apply a Knowtation strategy by name.
333
334 Returns the resolved bytes when *strategy* is a Knowtation-specific
335 strategy, or ``None`` when the strategy is not in
336 :data:`STRATEGY_DISPATCH` (meaning it should be handled by the generic
337 merge engine instead).
338
339 Args:
340 strategy: Strategy string (e.g. ``"prefer-newer-date"``).
341 ours: Current-branch bytes.
342 theirs: Incoming-branch bytes.
343 base: Common merge-base bytes.
344
345 Returns:
346 Resolved bytes, or ``None`` when the strategy is not Knowtation-specific.
347 """
348 fn = STRATEGY_DISPATCH.get(strategy)
349 if fn is None:
350 return None
351 return fn(ours, theirs, base)