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