graflo.architecture.evolution.merge3¶
Three-way merge over manifest change sets, and tracked re-merges.
Merging two world models is not diffing them. Both sides descend from a common ancestor, so the question is never "what is different" but "what did each side change, and do those changes collide". That is the three-way shape: diff base→left, diff base→right, and reconcile.
Slots¶
Reconciliation happens per slot -- the addressable location an op touches,
such as ("vertex", "person", "field", "age"). Two sides that touch disjoint
slots, and do not change what the other depends on, merge automatically. Two sides that make the same change to one slot
merge to that change, once. Two sides that make different changes to one slot
are a :class:MergeConflict, reported rather than guessed at.
Three things make the slot the right unit:
- An order-significant sequence is one slot. A resource pipeline is an ordered program, and half-merging two edits to a program produces something neither author wrote. It conflicts as a unit or it merges as a unit.
- A rename occupies both names. Renaming
person→customeron one side while the other side adds a field topersonis a genuine collision, and it is invisible unless the rename is understood to touch the old slot too. - Each side's ops are first split one per slot (:func:
split_by_slot): the differ emits one op per kind for the whole manifest, and a decision on one slot must not carry another slot's edits. An op that cannot be split (a swap, a pipeline) is atomic: if any one of its slots is contested, the whole op is held back. Applying half an op is not a merge. - An op reads as well as writes. An edge added onto
companywrites the edge and depends oncompany; the other side removingcompanywrites a different slot and still cannot be merged with it (:func:op_reads). A read is disturbed by a write at or above it, never by one beneath: the edge does not care which fieldscompanycarries.
A side whose change no operation expresses cannot be merged at all, because the
merge is assembled from each side's ops: :func:merge_three_way raises rather
than return a clean result that silently lacks it.
Three-way merge is not merge¶
Three-way merge (this module, commit kind merge3) reconciles two descendants
of a common ancestor: names are expected to agree because both sides
inherited them, so disagreement is a conflict. Merge (merge_manifests, commit
kind merge) joins unrelated lineages by declared equivalence: names are
expected to disagree, and the declaration is what reconciles them. Both produce
multi-parent commits; they are not the same operation and must not be conflated.
Determinism is a contract¶
The same inputs produce the same merged manifest, the same conflicts in the same order, and -- through canonical hashing -- the same content hash. Auto-merged ops are applied left-side-first in their diff order, then right-side. Nothing here consults a set iteration order or a dict insertion order.
Attributes¶
RelationAliases = dict[str, set[str]]
module-attribute
¶
Slot = tuple[str, ...]
module-attribute
¶
_ = op_to_dict
module-attribute
¶
__all__ = ['ConflictResolution', 'MergeConflict', 'MergeError', 'MergeRecipe', 'MergeResult', 'Slot', 'build_merge_recipe', 'build_recipe', 'describe_slot', 'find_merge_base', 'keep_both', 'merge_three_way', 'op_slots', 're_merge', 'take_left', 'take_right']
module-attribute
¶
logger = logging.getLogger(__name__)
module-attribute
¶
Classes¶
ConflictResolution
¶
Bases: ConfigBaseModel
The decision for one contested slot.
Take-left and take-right are not special cases: they are this model holding the corresponding side's op list. A hand-written third answer is expressed the same way, which is what lets a recorded resolution replay on a re-merge.
Source code in graflo/architecture/evolution/merge3.py
Attributes¶
ops = PydanticField(default_factory=list, description="Ops to apply for this slot; empty means 'neither side'.")
class-attribute
instance-attribute
¶
rationale = PydanticField(default=None, description='Why, for the next reader.')
class-attribute
instance-attribute
¶
slot = PydanticField(..., description='The slot being resolved.')
class-attribute
instance-attribute
¶
slot_key
property
¶
MergeConflict
¶
Bases: ConfigBaseModel
One slot both sides changed, differently.
Source code in graflo/architecture/evolution/merge3.py
Attributes¶
base_excerpt = PydanticField(default_factory=dict, description="The ancestor's state at this slot, for a human deciding.")
class-attribute
instance-attribute
¶
left_ops = PydanticField(default_factory=list, description='What the left side did here.')
class-attribute
instance-attribute
¶
reason = PydanticField(default='both sides changed this slot', description='Why this could not be merged automatically.')
class-attribute
instance-attribute
¶
right_ops = PydanticField(default_factory=list, description='What the right side did here.')
class-attribute
instance-attribute
¶
slot = PydanticField(..., description='The contested location, as path segments.')
class-attribute
instance-attribute
¶
slot_key
property
¶
MergeError
¶
MergeRecipe
¶
Bases: ConfigBaseModel
How a merge was performed, recorded so it can be performed again.
This is the rerere analogue: when the left side advances and the same
merge is run again, the recorded resolutions are re-applied to any slot that
conflicts again, and only genuinely new conflicts reach a human. That is
what makes a tracked merge cheap enough to keep re-running, which is what
makes an overlay maintainable rather than a one-time fork.
Source code in graflo/architecture/evolution/merge3.py
Attributes¶
base = PydanticField(default=None, description='Content hash of the merge base; merge3 only.')
class-attribute
instance-attribute
¶
equivalences = PydanticField(default_factory=dict, description='Declared alignment for a merge; empty for merge3.')
class-attribute
instance-attribute
¶
kind = PydanticField(default='merge3', description='merge3 (common ancestor) or merge (unrelated).')
class-attribute
instance-attribute
¶
left = PydanticField(..., description='Content hash of the left state.')
class-attribute
instance-attribute
¶
name_conflict = PydanticField(default=None, description="Merge's name-conflict policy, when applicable.")
class-attribute
instance-attribute
¶
resolutions = PydanticField(default_factory=list, description='Slot-keyed decisions, replayable.')
class-attribute
instance-attribute
¶
right = PydanticField(..., description='Content hash of the right state.')
class-attribute
instance-attribute
¶
Methods:¶
content_hash()
¶
Content address of this recipe.
Resolutions are hashed in slot order, not in the order a human happened to supply them, so the same decisions always address the same recipe.
Source code in graflo/architecture/evolution/merge3.py
MergeResult
¶
Bases: ConfigBaseModel
What a merge produced, or could not.
Source code in graflo/architecture/evolution/merge3.py
Attributes¶
clean
property
¶
Whether the merge completed with no decisions left to make.
conflicts = PydanticField(default_factory=list, description='Slots needing a decision, slot-sorted.')
class-attribute
instance-attribute
¶
merged_hash = PydanticField(default=None, description='Content hash of the merged manifest, if one exists.')
class-attribute
instance-attribute
¶
ops = PydanticField(default_factory=list, description='Ops applied to the base to reach the merged manifest.')
class-attribute
instance-attribute
¶
warnings = PydanticField(default_factory=list)
class-attribute
instance-attribute
¶
Functions:¶
build_merge_recipe(left, right, op)
¶
Record how a merge was declared, addressed by content.
The merge counterpart to :func:build_recipe. Two things differ, and both
follow from merge joining unrelated lineages rather than reconciling
related ones: there is no merge base, so base is None; and there are
no conflicts to resolve, because merge refuses rather than resolving, so
resolutions stays empty.
What takes their place is the declaration itself. The whole op is recorded -- equivalences and their identities, canonical maps, resource renames and the name-conflict policy -- because all of it is "how these two were joined", and a re-merge that had only the equivalences would reconstruct a different manifest.
When the union names a demoted key or an untagged local key by a side's
origin, the resolved origins are recorded with the op, so a re-merge
names them alike after a parent's schema is renamed. Otherwise the op is
recorded as given.
Source code in graflo/architecture/evolution/merge3.py
build_recipe(base, left, right, *, resolutions=None, kind='merge3')
¶
Record how this merge was resolved, addressed by content.
Source code in graflo/architecture/evolution/merge3.py
describe_slot(slot)
¶
find_merge_base(history, left, right)
¶
The best common ancestor of left and right, or None.
"Best" is the common ancestor furthest from the roots, ties broken on commit id so the choice is deterministic. Multiple genuinely-incomparable bases (a criss-cross history) are picked between with a warning rather than handled properly: recursive merge is a known upgrade path and is explicitly out of scope here, because real criss-cross histories do not arise until people are merging merges routinely.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
history
|
Any
|
A |
required |
left
|
str
|
One commit id. |
required |
right
|
str
|
The other commit id. |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
The merge-base commit id, or |
str | None
|
which means they are unrelated lineages, and the operation you want is |
str | None
|
a union ( |
Source code in graflo/architecture/evolution/merge3.py
keep_both(base, conflict, *, rationale=None)
¶
Resolve conflict by keeping both sides' ops, when one side renames.
The renaming side's ops go last, so its rename carries the other side's edits to the new name and no op needs rewriting.
Returns None unless exactly one side renames -- without a rename, both
op lists in sequence mean the later one wins, which is not "both" -- or when
the combined ops do not apply to base. :func:merge_three_way applies a resolution to the base,
preceded only by ops that neither touch nor read its slot, so applying it to
base alone is the same check.
Source code in graflo/architecture/evolution/merge3.py
merge_three_way(base, left, right, *, resolutions=None, hints=None, left_hints=None, right_hints=None)
¶
Reconcile left and right, both descended from base.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base
|
GraphManifest
|
The common ancestor. |
required |
left
|
GraphManifest
|
One descendant. Its ops are applied first. |
required |
right
|
GraphManifest
|
The other descendant. |
required |
resolutions
|
list[ConflictResolution] | None
|
Decisions for contested slots. A slot with a resolution is no longer a conflict; its ops replace both sides' at that slot. |
None
|
hints
|
RenameHints | None
|
Rename hints for the two diffs -- renames are never inferred (a drop plus an add is not a rename), so a rename on either side needs a hint to be seen as one. |
None
|
left_hints
|
RenameHints | None
|
Hints for the left diff alone, in place of hints. A
hint is emitted as a rename, so a rename only one side made belongs
in that side's hints (see :meth: |
None
|
right_hints
|
RenameHints | None
|
Hints for the right diff alone, in place of hints. |
None
|
Returns:
| Type | Description |
|---|---|
GraphManifest | None
|
|
MergeResult
|
when unresolved conflicts remain. |
Raises:
| Type | Description |
|---|---|
MergeError
|
A side changed something no operation expresses -- it dropped the ingestion block, say -- so the merged manifest would silently lack it. Also when the merged change set does not apply to base. |
Source code in graflo/architecture/evolution/merge3.py
1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 | |
op_reads(op, base=None, aliases=None)
¶
What op needs to be there and does not itself change.
:func:op_slots is what an op writes. That is not enough to tell whether
two ops are independent: add_edges writes an edge and reads its
endpoint vertices, so a remove_vertices on the other side -- which
cascades over that vertex's edges -- shares no written slot with it and
still cannot be merged with it. One order drops the new edge without a
word; the other does not apply.
A read conflicts with a write at or above it, never below: an edge onto
company depends on company existing under that name, not on which
fields it carries.
Ops addressed by relation depend on whatever that relation connects, which
the op does not say; that is read from base. A relation one side renamed
is looked up under the name base knows, so its endpoints are not seen --
the rename itself occupies both names and conflicts with the other side's
edits to the relation. aliases (:func:relation_aliases of the op's own
side) lets an op after a rename read the endpoints of the renamed relation.
An op not listed reads nothing beyond what it writes: a property op lives under its vertex's slot, so the containment of written slots already ties it to that vertex.
Source code in graflo/architecture/evolution/merge3.py
613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 | |
op_slots(op)
¶
Every addressable location op touches.
Dispatch is on the op class, not on its op string literal. Two
reasons: a type checker can narrow it, so a field read against the wrong op
model is caught at check time rather than at merge time; and a literal
renamed in the ops package cannot silently fall through to the
catch-all here.
Total over the op vocabulary. An op reaching the fallback is treated as
touching the whole manifest, which conflicts with everything -- the safe
direction, since an unrecognised op silently merging with anything is how a
merge quietly corrupts a schema. test_merge3.py asserts every member of
the union resolves to a real slot, so the fallback is a backstop rather
than a policy.
Source code in graflo/architecture/evolution/merge3.py
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 | |
ops_independent(one, other, base=None)
¶
Whether one and other can be applied in either order to one effect.
Neither writes where the other writes, and neither writes what the other reads. Two ops reading the same thing are independent: two edges onto one vertex do not get in each other's way.
Source code in graflo/architecture/evolution/merge3.py
re_merge(recipe, base, left, right, *, hints=None, left_hints=None, right_hints=None)
¶
Re-run a recorded merge with its resolutions pre-applied.
The point of a tracked merge: when the left side advances, this replays the decisions already made and surfaces only conflicts that are genuinely new. Recorded resolutions for slots that no longer conflict are simply not needed and are reported as such, rather than being force-applied -- a stale decision reapplied to a slot nobody contested is how a re-merge quietly reverts someone's work.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
recipe
|
MergeRecipe
|
The recorded merge. |
required |
base
|
GraphManifest
|
The (possibly new) merge base. |
required |
left
|
GraphManifest
|
The (possibly advanced) left state. |
required |
right
|
GraphManifest
|
The right state. |
required |
hints
|
RenameHints | None
|
Rename hints for the underlying diffs. |
None
|
left_hints
|
RenameHints | None
|
Hints for the left diff alone, as in :func: |
None
|
right_hints
|
RenameHints | None
|
Hints for the right diff alone. |
None
|
Returns:
| Type | Description |
|---|---|
tuple[GraphManifest | None, MergeResult]
|
|
Source code in graflo/architecture/evolution/merge3.py
1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 | |
relation_aliases(side_ops)
¶
The base relations each name a side's renames and merges introduce stands for.
An op after a rename addresses the new name, which the base does not know; its endpoints are those of the relation it was renamed from.
Source code in graflo/architecture/evolution/merge3.py
split_by_slot(op)
¶
op as one op per slot it writes, in order; [op] when it cannot split.
The differ emits one op per kind for the whole manifest, so a single
add_vertex_properties carries every property any vertex gained. Split,
a conflict holds only the part at its slot, and deciding it decides nothing
else.
Source code in graflo/architecture/evolution/merge3.py
take_left(conflict, *, rationale=None)
¶
Resolve conflict by keeping the left side's ops.
Source code in graflo/architecture/evolution/merge3.py
take_right(conflict, *, rationale=None)
¶
Resolve conflict by keeping the right side's ops.