test_identity_domain_merge.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 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 |