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. - An op touching several slots 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¶
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', '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, canonical maps, identity alignments, 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.
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
|
merge, not merge. |
Source code in graflo/architecture/evolution/merge3.py
merge_three_way(base, left, right, *, resolutions=None, 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
|
Returns:
| Type | Description |
|---|---|
GraphManifest | None
|
|
MergeResult
|
when unresolved conflicts remain. |
Raises:
| Type | Description |
|---|---|
MergeError
|
A side changed something no operation expresses -- a property gained by one of a relation's edges and not its siblings, an edited pipeline -- 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
746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 | |
op_reads(op, base=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, which covers the common case.
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
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 | |
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 ops.py 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
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 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 | |
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)
¶
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
|
Returns:
| Type | Description |
|---|---|
tuple[GraphManifest | None, MergeResult]
|
|
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.