gabriel / muse public
test_identity_domain_merge.py python
331 lines 14.0 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 22 days ago
1 """TDD — IdentityPlugin.merge() and merge_ops().
2
3 Three-way merge with I1 (acyclicity) enforcement.
4
5 Invariant I1 is enforced during merge: if the merged result would contain a
6 relationship set that introduces a cycle, the offending relationship is flagged
7 as a conflict rather than auto-merged. The graph is never left in a cyclic state.
8
9 Covers:
10 - Disjoint identity adds on both branches → auto-merge (no conflicts)
11 - Disjoint relationship adds on both branches → auto-merge
12 - Both branches add same file identically → auto-merge (consensus)
13 - Both branches modify same file differently → conflict
14 - Merge result includes union of independent adds from both branches
15 - I1: merge of a new relationship that closes a cycle → conflict, not applied
16 - I1: merge of non-cyclic relationships in parallel → auto-merge
17 - Deleted on one branch, unchanged on other → deletion wins
18 - Deleted on both branches → deletion wins
19 - domain field preserved in merged snapshot
20 """
21 from __future__ import annotations
22
23 import pathlib
24
25 import pytest
26
27 from muse.domain import SnapshotManifest
28 from muse.core.types import Manifest, long_id
29 from muse.plugins.identity.plugin import IdentityPlugin
30
31
32 @pytest.fixture
33 def plugin() -> IdentityPlugin:
34 return IdentityPlugin()
35
36
37 # ── snapshot helpers ──────────────────────────────────────────────────────────
38
39 _CTR = 0
40
41 def _hash(tag: str) -> str:
42 return long_id(f"{abs(hash(tag)):064x}")
43
44
45 def snap(files: Manifest | None = None) -> SnapshotManifest:
46 return SnapshotManifest(
47 files=files or {},
48 domain="identity",
49 directories=[],
50 )
51
52
53 def with_files(*paths: str) -> SnapshotManifest:
54 return snap({p: _hash(p) for p in paths})
55
56
57 def with_custom(files: Manifest) -> SnapshotManifest:
58 return snap(files)
59
60
61 # Canonical path helpers
62 def id_path(handle: str) -> str:
63 return f"identities/{handle}.json"
64
65
66 def rel_path(frm: str, edge: str, to: str) -> str:
67 return f"relationships/{frm}--{edge}--{to}.json"
68
69
70 # ── disjoint adds → auto-merge ────────────────────────────────────────────────
71
72 class TestAutoMerge:
73 def test_disjoint_identity_adds(self, plugin: IdentityPlugin) -> None:
74 base = snap()
75 left = with_files(id_path("gabriel"))
76 right = with_files(id_path("alice"))
77 result = plugin.merge(base, left, right)
78 assert result.conflicts == []
79 assert id_path("gabriel") in result.merged["files"]
80 assert id_path("alice") in result.merged["files"]
81
82 def test_disjoint_relationship_adds(self, plugin: IdentityPlugin) -> None:
83 base = snap()
84 left = with_files(rel_path("gabriel", "spawns", "bot-1"))
85 right = with_files(rel_path("alice", "spawns", "bot-2"))
86 result = plugin.merge(base, left, right)
87 assert result.conflicts == []
88 assert rel_path("gabriel", "spawns", "bot-1") in result.merged["files"]
89 assert rel_path("alice", "spawns", "bot-2") in result.merged["files"]
90
91 def test_both_add_same_file_identically(self, plugin: IdentityPlugin) -> None:
92 base = snap()
93 same_hash = _hash("gabriel")
94 left = snap({id_path("gabriel"): same_hash})
95 right = snap({id_path("gabriel"): same_hash})
96 result = plugin.merge(base, left, right)
97 assert result.conflicts == []
98 assert id_path("gabriel") in result.merged["files"]
99
100 def test_add_identity_and_relationship_disjoint(self, plugin: IdentityPlugin) -> None:
101 base = snap()
102 left = with_files(id_path("gabriel"))
103 right = with_files(rel_path("alice", "member_of", "acme"))
104 result = plugin.merge(base, left, right)
105 assert result.conflicts == []
106 assert len(result.merged["files"]) == 2
107
108 def test_domain_preserved_in_merged(self, plugin: IdentityPlugin) -> None:
109 base = snap()
110 left = with_files(id_path("gabriel"))
111 right = with_files(id_path("alice"))
112 result = plugin.merge(base, left, right)
113 assert result.merged["domain"] == "identity"
114
115
116 # ── same-file conflict ─────────────────────────────────────────────────────────
117
118 class TestConflict:
119 def test_both_modify_same_identity_differently(self, plugin: IdentityPlugin) -> None:
120 base = with_custom({id_path("gabriel"): _hash("v1")})
121 left = with_custom({id_path("gabriel"): _hash("v2-left")})
122 right = with_custom({id_path("gabriel"): _hash("v2-right")})
123 result = plugin.merge(base, left, right)
124 assert id_path("gabriel") in result.conflicts
125
126 def test_both_modify_same_relationship_differently(self, plugin: IdentityPlugin) -> None:
127 p = rel_path("alice", "member_of", "acme")
128 base = with_custom({p: _hash("weight-1")})
129 left = with_custom({p: _hash("weight-2")})
130 right = with_custom({p: _hash("weight-3")})
131 result = plugin.merge(base, left, right)
132 assert p in result.conflicts
133
134
135 # ── deletions ─────────────────────────────────────────────────────────────────
136
137 class TestDeletion:
138 def test_deleted_on_left_unchanged_on_right(self, plugin: IdentityPlugin) -> None:
139 p = id_path("alice")
140 base = with_files(p)
141 left = snap() # deleted
142 right = with_files(p) # unchanged
143 result = plugin.merge(base, left, right)
144 assert result.conflicts == []
145 assert p not in result.merged["files"]
146
147 def test_deleted_on_both_branches(self, plugin: IdentityPlugin) -> None:
148 p = id_path("alice")
149 base = with_files(p)
150 left = snap()
151 right = snap()
152 result = plugin.merge(base, left, right)
153 assert result.conflicts == []
154 assert p not in result.merged["files"]
155
156 def test_unchanged_on_both_branches_preserved(self, plugin: IdentityPlugin) -> None:
157 p = id_path("alice")
158 h = _hash("alice")
159 base = with_custom({p: h})
160 left = with_custom({p: h})
161 right = with_custom({p: h})
162 result = plugin.merge(base, left, right)
163 assert result.conflicts == []
164 assert result.merged["files"][p] == h
165
166
167 # ── I1 Acyclicity enforcement ─────────────────────────────────────────────────
168
169 class TestI1AcyclicityEnforcement:
170 """Merge must reject a relationship that would create a cycle.
171
172 The plugin embeds relationship path → (from, edge, to) decoding and
173 maintains the cumulative edge set from the merged snapshot to detect I1
174 violations before accepting a new relationship into the merge result.
175 """
176
177 def test_cycle_in_merged_result_becomes_conflict(self, plugin: IdentityPlugin) -> None:
178 # Base: gabriel --spawns--> bot-1
179 # Left: (unchanged)
180 # Right: adds bot-1 --spawns--> gabriel (would close a cycle)
181 existing = rel_path("gabriel", "spawns", "bot-1")
182 cycle_edge = rel_path("bot-1", "spawns", "gabriel")
183
184 base = with_files(existing)
185 left = with_files(existing)
186 right = with_files(existing, cycle_edge)
187 result = plugin.merge(base, left, right)
188 # cycle_edge must be rejected, not silently applied
189 assert cycle_edge in result.conflicts
190 assert cycle_edge not in result.merged["files"]
191
192 def test_self_loop_becomes_conflict(self, plugin: IdentityPlugin) -> None:
193 self_loop = rel_path("gabriel", "spawns", "gabriel")
194 base = snap()
195 left = snap()
196 right = with_files(self_loop)
197 result = plugin.merge(base, left, right)
198 assert self_loop in result.conflicts
199 assert self_loop not in result.merged["files"]
200
201 def test_indirect_cycle_three_nodes_rejected(self, plugin: IdentityPlugin) -> None:
202 # a→b, b→c already committed; merge tries to add c→a
203 a_b = rel_path("a", "spawns", "b")
204 b_c = rel_path("b", "spawns", "c")
205 c_a = rel_path("c", "spawns", "a") # closes cycle
206
207 base = with_files(a_b, b_c)
208 left = with_files(a_b, b_c)
209 right = with_files(a_b, b_c, c_a)
210 result = plugin.merge(base, left, right)
211 assert c_a in result.conflicts
212 assert c_a not in result.merged["files"]
213
214 def test_non_cyclic_new_edge_is_accepted(self, plugin: IdentityPlugin) -> None:
215 # a→b already; add b→c (linear chain, no cycle)
216 a_b = rel_path("a", "spawns", "b")
217 b_c = rel_path("b", "spawns", "c")
218
219 base = with_files(a_b)
220 left = with_files(a_b)
221 right = with_files(a_b, b_c)
222 result = plugin.merge(base, left, right)
223 assert result.conflicts == []
224 assert b_c in result.merged["files"]
225
226 def test_diamond_dag_not_a_cycle(self, plugin: IdentityPlugin) -> None:
227 # alice→a1, alice→a2, a1→target, a2→target — valid DAG (diamond)
228 alice_a1 = rel_path("alice", "spawns", "a1")
229 alice_a2 = rel_path("alice", "spawns", "a2")
230 a1_target = rel_path("a1", "spawns", "target")
231 a2_target = rel_path("a2", "spawns", "target")
232
233 base = with_files(alice_a1, alice_a2, a1_target)
234 left = with_files(alice_a1, alice_a2, a1_target)
235 right = with_files(alice_a1, alice_a2, a1_target, a2_target)
236 result = plugin.merge(base, left, right)
237 assert result.conflicts == []
238 assert a2_target in result.merged["files"]
239
240 def test_member_of_cycle_rejected(self, plugin: IdentityPlugin) -> None:
241 # org-a → org-b (member_of); merge tries org-b → org-a
242 a_to_b = rel_path("org-a", "member_of", "org-b")
243 b_to_a = rel_path("org-b", "member_of", "org-a")
244
245 base = with_files(a_to_b)
246 left = with_files(a_to_b)
247 right = with_files(a_to_b, b_to_a)
248 result = plugin.merge(base, left, right)
249 assert b_to_a in result.conflicts
250
251 def test_cross_edge_type_cycle_rejected(self, plugin: IdentityPlugin) -> None:
252 # alice --spawns--> bot; merge tries bot --member_of--> alice
253 # spawns + member_of edges share the same DAG universe
254 spawns_edge = rel_path("alice", "spawns", "bot")
255 back_edge = rel_path("bot", "member_of", "alice")
256
257 base = with_files(spawns_edge)
258 left = with_files(spawns_edge)
259 right = with_files(spawns_edge, back_edge)
260 result = plugin.merge(base, left, right)
261 assert back_edge in result.conflicts
262
263
264 # ── HarmonyPlugin fingerprinting ──────────────────────────────────────────────
265
266 class TestHarmonyFingerprint:
267 """IdentityPlugin implements HarmonyPlugin for semantic conflict fingerprinting.
268
269 Two conflicts that have the same structural shape (same edge being added)
270 but different signature timestamps should produce the same fingerprint —
271 enabling Harmony to replay the resolution automatically.
272 """
273
274 def test_plugin_has_conflict_fingerprint(self, plugin: IdentityPlugin) -> None:
275 assert hasattr(plugin, "conflict_fingerprint")
276
277 def test_same_structural_conflict_same_fingerprint(self, plugin: IdentityPlugin, tmp_path: pathlib.Path) -> None:
278 # Same edge added, different signature timestamps → same fingerprint
279 import json
280 from muse.core.types import blob_id
281 from muse.plugins.identity.records import RelationshipRecord, record_to_bytes
282
283 rec1 = RelationshipRecord(
284 from_handle="gabriel", to_handle="bot",
285 edge_type="spawns", weight=None,
286 authorized_by=[{"signer": "gabriel", "signature": "ed25519:AAA", "signed_at": "2026-01-01T00:00:00Z"}],
287 )
288 rec2 = RelationshipRecord(
289 from_handle="gabriel", to_handle="bot",
290 edge_type="spawns", weight=None,
291 authorized_by=[{"signer": "gabriel", "signature": "ed25519:BBB", "signed_at": "2026-06-01T00:00:00Z"}],
292 )
293 p = tmp_path / "relationships" / "gabriel--spawns--bot.json"
294 p.parent.mkdir(parents=True)
295 p.write_bytes(record_to_bytes(rec1))
296 ours_id = blob_id(record_to_bytes(rec1))
297 theirs_id = blob_id(record_to_bytes(rec2))
298
299 fp1 = plugin.conflict_fingerprint(
300 "relationships/gabriel--spawns--bot.json", ours_id, theirs_id, tmp_path
301 )
302 fp2 = plugin.conflict_fingerprint(
303 "relationships/gabriel--spawns--bot.json", theirs_id, ours_id, tmp_path
304 )
305 assert fp1 == fp2 # commutative
306
307 def test_different_structural_conflict_different_fingerprint(self, plugin: IdentityPlugin, tmp_path: pathlib.Path) -> None:
308 from muse.core.types import blob_id
309 from muse.plugins.identity.records import RelationshipRecord, record_to_bytes
310
311 rec_spawn = RelationshipRecord(
312 from_handle="gabriel", to_handle="bot-1",
313 edge_type="spawns", weight=None, authorized_by=[],
314 )
315 rec_member = RelationshipRecord(
316 from_handle="gabriel", to_handle="acme",
317 edge_type="member_of", weight="1", authorized_by=[],
318 )
319 tmp_path.joinpath("relationships").mkdir(parents=True, exist_ok=True)
320
321 def fp(rec: RelationshipRecord, path: str) -> str:
322 p = tmp_path / path
323 p.parent.mkdir(parents=True, exist_ok=True)
324 p.write_bytes(record_to_bytes(rec))
325 ours_id = blob_id(record_to_bytes(rec))
326 theirs_id = blob_id(b'other')
327 return plugin.conflict_fingerprint(path, ours_id, theirs_id, tmp_path)
328
329 fp_spawn = fp(rec_spawn, "relationships/gabriel--spawns--bot-1.json")
330 fp_member = fp(rec_member, "relationships/gabriel--member_of--acme.json")
331 assert fp_spawn != fp_member
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 22 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 23 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 30 days ago