graflo.architecture.evolution.preview¶
What a merge would do, and every way it could refuse — without refusing.
:func:~graflo.architecture.evolution.merge.merge_manifests raises at the
first refusal. That is right for a function that returns a manifest — a
half-merged model is worse than none — but it makes authoring a merge a
game of whack-a-mole: fix the contradiction the message names, run again, learn
about the next one. Three declarations that each refuse take three runs to
discover.
This module is the other view. :func:preview_merge walks the same
declarations and reports every problem it finds, as data:
- the declaration graph — classes and their attributes on each side, the
clusters that collapse them, the canonical names the maps establish, and the
edges between them. This is the
class_A - attr_a - attr_b - class_Bpicture the authoring model is actually about; - findings, each naming the nodes it is about, at one of three severities —
possible(found structurally, by this module),refusal(what merge actually raised, if it was asked to try) andnote(an acknowledged heuristic, such as an entry taken as already applied); - an outcome, from a real merge attempt.
Nothing here is a second implementation of the resolution rules. Every check
calls the function in :mod:~graflo.architecture.evolution.canonical that
merge itself calls, in units small enough — one declaration, one map entry,
one member — that a refusal on one unit does not hide the others. A refusal
carries its check and its subjects, so the finding it becomes is
pinned to the same nodes the message names, and there is no parallel copy of
the rules to fall out of date.
The consistency invariant, asserted in the tests: whatever merge refuses,
the structural pass has a finding of a matching kind for, and a merge that
succeeds leaves no refusal finding behind.
Merging two branches of one lineage needs none of this.
:func:~graflo.architecture.evolution.merge3.merge_three_way already returns
its conflicts rather than raising them, one record per contested slot, so
:func:build_merge3_preview only has to put them in the shape they already
have: slots form a tree — vertex/person contains vertex/person/field/age
— and the tree is what shows where in the model two branches collided.
Attributes¶
EdgeKind = Literal['member', 'map', 'property_map', 'property_equivalence', 'suggested']
module-attribute
¶
FindingKind = Literal['cluster_overlap', 'shared_into', 'occupied_into', 'unknown_member', 'disagreement', 'ambiguity', 'unnamed_cluster', 'incomplete', 'dangling', 'satisfied', 'name_collision', 'near_collision', 'prefixed', 'property_disagreement', 'property_collision', 'property_retarget', 'unknown_property', 'identity_disagreement', 'type_conflict', 'unit_conflict', 'identity_mode_conflict', 'identity_funnel_conflict', 'secondary_identity_conflict', 'edge_conflict']
module-attribute
¶
NodeKind = Literal['class', 'relation', 'attribute', 'merged', 'canonical', 'ghost']
module-attribute
¶
Severity = Literal['refusal', 'possible', 'note']
module-attribute
¶
__all__ = ['EdgeKind', 'FindingKind', 'Merge3Preview', 'MergeFinding', 'MergeOutcome', 'MergePreview', 'NodeKind', 'PreviewCluster', 'PreviewEdge', 'PreviewNode', 'Severity', 'SlotNode', 'build_merge3_preview', 'expected_kinds', 'kind_for_check', 'outcome_from_exception', 'outcome_from_manifest', 'preview_merge']
module-attribute
¶
logger = logging.getLogger(__name__)
module-attribute
¶
Classes¶
Merge3Preview
¶
Bases: ConfigBaseModel
A three-way merge as a slot tree: what moved, and where the branches met.
Source code in graflo/architecture/evolution/preview.py
Attributes¶
clean = PydanticField(default=True, description='Whether the merge left no decision to make.')
class-attribute
instance-attribute
¶
conflicts = PydanticField(default=0, description='Contested slot count.')
class-attribute
instance-attribute
¶
contested
property
¶
The contested slots, in tree order.
merged_hash = PydanticField(default=None, description='Content hash of the merged manifest, if there is one.')
class-attribute
instance-attribute
¶
nodes = PydanticField(default_factory=list)
class-attribute
instance-attribute
¶
warnings = PydanticField(default_factory=list)
class-attribute
instance-attribute
¶
Methods:¶
children_of(node_id)
¶
Slots directly under node_id; pass None for the roots.
MergeFinding
¶
Bases: ConfigBaseModel
One thing wrong with the declarations, or one acknowledged heuristic.
Source code in graflo/architecture/evolution/preview.py
Attributes¶
check = PydanticField(default=None, description="The refusal's own name for the rule.")
class-attribute
instance-attribute
¶
completion = PydanticField(default=None, description='The declaration that would settle it, when there is one.')
class-attribute
instance-attribute
¶
edges = PydanticField(default_factory=list, description='Edge ids this finding is about.')
class-attribute
instance-attribute
¶
error_type = PydanticField(default=None, description='Exception type, for a finding merge raised.')
class-attribute
instance-attribute
¶
kind = PydanticField(..., description='Which rule it is an instance of.')
class-attribute
instance-attribute
¶
message = PydanticField(..., description='What to tell the author.')
class-attribute
instance-attribute
¶
nodes = PydanticField(default_factory=list, description='Node ids this finding is about.')
class-attribute
instance-attribute
¶
severity = PydanticField(..., description='How much it matters.')
class-attribute
instance-attribute
¶
source = PydanticField(..., description='Whether merge raised it, or this module found it.')
class-attribute
instance-attribute
¶
MergeOutcome
¶
Bases: ConfigBaseModel
What a real merge attempt produced, or refused with.
Source code in graflo/architecture/evolution/preview.py
Attributes¶
check = PydanticField(default=None, description='The rule that refused.')
class-attribute
instance-attribute
¶
completion = PydanticField(default=None, description='The extension that would settle an incomplete refusal.')
class-attribute
instance-attribute
¶
edges = PydanticField(default=None, description='Edge count of the merged schema.')
class-attribute
instance-attribute
¶
error_type = PydanticField(default=None, description='Exception type.')
class-attribute
instance-attribute
¶
message = PydanticField(default=None, description='The refusal, in full.')
class-attribute
instance-attribute
¶
status = PydanticField(..., description='Whether merge ran, and how it ended.')
class-attribute
instance-attribute
¶
version = PydanticField(default=None, description='Version of the merged schema.')
class-attribute
instance-attribute
¶
vertices = PydanticField(default=None, description='Vertex count of the merged schema.')
class-attribute
instance-attribute
¶
MergePreview
¶
Bases: ConfigBaseModel
The declaration graph, everything wrong with it, and what merge did.
Source code in graflo/architecture/evolution/preview.py
349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 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 | |
Attributes¶
blocking
property
¶
Findings that would stop a merge: the refusal and every possible one.
clusters = PydanticField(default_factory=list)
class-attribute
instance-attribute
¶
edges = PydanticField(default_factory=list)
class-attribute
instance-attribute
¶
findings = PydanticField(default_factory=list)
class-attribute
instance-attribute
¶
left_name = PydanticField(default='left', description='Name of the left manifest.')
class-attribute
instance-attribute
¶
name_conflict = PydanticField(default='error', description="The op's policy for names no equivalence covers.")
class-attribute
instance-attribute
¶
nodes = PydanticField(default_factory=list)
class-attribute
instance-attribute
¶
outcome = PydanticField(default_factory=lambda: MergeOutcome(status='not_attempted'))
class-attribute
instance-attribute
¶
refused
property
¶
Whether the merge attempt refused.
right_name = PydanticField(default='right', description='Name of the right manifest.')
class-attribute
instance-attribute
¶
Methods:¶
attributes_of(node_id)
¶
Attribute nodes owned by the class node node_id, in declared order.
node(node_id)
¶
with_outcome(outcome, *, subjects=())
¶
A copy carrying outcome, and the finding a refusal becomes.
The structural pass usually found the refusal too — it calls the same
check — so the two are folded into one finding marked refusal
rather than listed twice. Reporting one problem as two would undercut
the only number this is for: how many things are actually wrong.
Source code in graflo/architecture/evolution/preview.py
PreviewCluster
¶
Bases: ConfigBaseModel
One equivalence declaration, resolved as far as it could be.
Source code in graflo/architecture/evolution/preview.py
Attributes¶
aligned = PydanticField(default=False, description='Whether an `identity_alignments` entry names it.')
class-attribute
instance-attribute
¶
declared_identity = PydanticField(default=False, description='Whether the declaration states an `identity`.')
class-attribute
instance-attribute
¶
declared_into = PydanticField(default=None, description='`into` as the author spelled it, before translation.')
class-attribute
instance-attribute
¶
id = PydanticField(..., description='Stable id of the cluster.')
class-attribute
instance-attribute
¶
into = PydanticField(default=None, description='Merged name; None when it could not be resolved.')
class-attribute
instance-attribute
¶
kind = PydanticField(..., description='Whether it collapses classes or relations.')
class-attribute
instance-attribute
¶
left = PydanticField(default_factory=list, description='Left members.')
class-attribute
instance-attribute
¶
right = PydanticField(default_factory=list, description='Right members.')
class-attribute
instance-attribute
¶
synthesized = PydanticField(default=False, description='Declared by merge itself under `union_right`.')
class-attribute
instance-attribute
¶
PreviewEdge
¶
Bases: ConfigBaseModel
One thing a declaration says about a pair of names.
Source code in graflo/architecture/evolution/preview.py
Attributes¶
declared_by = PydanticField(default='', description="Where it was declared: a map scope, or a cluster's merged name.")
class-attribute
instance-attribute
¶
id = PydanticField(..., description='Stable id: `kind:source->target`.')
class-attribute
instance-attribute
¶
kind = PydanticField(..., description='Which declaration said it.')
class-attribute
instance-attribute
¶
label = PydanticField(default='', description='Short caption, when useful.')
class-attribute
instance-attribute
¶
source = PydanticField(..., description='Node id this edge leaves.')
class-attribute
instance-attribute
¶
target = PydanticField(..., description='Node id this edge enters.')
class-attribute
instance-attribute
¶
PreviewNode
¶
Bases: ConfigBaseModel
One class, relation or attribute in the declaration graph.
Source code in graflo/architecture/evolution/preview.py
Attributes¶
exists = PydanticField(default=True, description='False when a declaration names it but the manifest does not.')
class-attribute
instance-attribute
¶
field_type = PydanticField(default=None, description='Declared type of an attribute, when it has one.')
class-attribute
instance-attribute
¶
id = PydanticField(..., description='Stable id, as `subject()` builds it: `left:Firm.firm_id`.')
class-attribute
instance-attribute
¶
identity = PydanticField(default=False, description="Whether this attribute takes part in its class's identity.")
class-attribute
instance-attribute
¶
identity_mode = PydanticField(default=None, description='natural / hash / blank / assigned, for a class.')
class-attribute
instance-attribute
¶
kind = PydanticField(..., description='What this node stands for.')
class-attribute
instance-attribute
¶
name = PydanticField(..., description='The name as its side spells it.')
class-attribute
instance-attribute
¶
owner = PydanticField(default=None, description='For an attribute, the node id of its class.')
class-attribute
instance-attribute
¶
side = PydanticField(default=None, description='Which manifest it comes from; merged names have none.')
class-attribute
instance-attribute
¶
SlotNode
¶
Bases: ConfigBaseModel
One addressable location in the manifest, and what each branch did to it.
Source code in graflo/architecture/evolution/preview.py
Attributes¶
base_excerpt = PydanticField(default=None, description="The ancestor's state here, for whoever decides.")
class-attribute
instance-attribute
¶
clean_ops = PydanticField(default_factory=list, description='Ops the merge applied here without a decision.')
class-attribute
instance-attribute
¶
contested = PydanticField(default=False, description='Whether both branches changed this slot.')
class-attribute
instance-attribute
¶
depth = PydanticField(..., description='How many segments deep it sits.')
class-attribute
instance-attribute
¶
id = PydanticField(..., description='The slot as a path: `vertex/person/field/age`.')
class-attribute
instance-attribute
¶
left_ops = PydanticField(default_factory=list, description='What the left branch did here, by op name.')
class-attribute
instance-attribute
¶
parent = PydanticField(default=None, description='The containing slot, or None at the root.')
class-attribute
instance-attribute
¶
reason = PydanticField(default=None, description='Why it could not be reconciled automatically.')
class-attribute
instance-attribute
¶
right_ops = PydanticField(default_factory=list, description='What the right branch did here.')
class-attribute
instance-attribute
¶
segment = PydanticField(..., description="This slot's own last segment.")
class-attribute
instance-attribute
¶
Functions:¶
build_merge3_preview(result, *, base=None)
¶
Project a :class:~graflo.architecture.evolution.merge3.MergeResult onto its slot tree.
Slots are paths and contain one another, so the set of slots a merge touched is already a tree once its prefixes are filled in. Contested slots carry what each branch did and the ancestor's state; slots the merge settled on its own carry the ops it applied, which is the context that makes a conflict legible — a rename colliding with three field edits is one conflict about the vertex, with the field edits visible beneath it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
Any
|
What |
required |
base
|
GraphManifest | None
|
The common ancestor. Unused today; accepted so a caller can pass it without knowing whether the excerpt came from the result. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
Merge3Preview
|
|
Merge3Preview
|
is exactly the case worth drawing. |
Source code in graflo/architecture/evolution/preview.py
1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 | |
expected_kinds(outcome)
¶
Structural finding kinds that should accompany outcome.
The invariant this module is tested against: whatever merge refused, the structural pass saw something of a matching kind. An empty set means the refusal is one the preview is not asked to anticipate -- a structural merge error from deep inside the union, say -- and asserts nothing.
Source code in graflo/architecture/evolution/preview.py
kind_for_check(check, error_type=None)
¶
The finding kind a refusal's check phrase is an instance of.
Longest match wins, so "property rename collision" is not read as the
plain "collision" of a name clash. Falls back to the exception type,
and finally to disagreement -- the most general of the four classes.
Source code in graflo/architecture/evolution/preview.py
outcome_from_exception(exc)
¶
The outcome a refusal is, and the node ids it names.
Source code in graflo/architecture/evolution/preview.py
outcome_from_manifest(manifest)
¶
The outcome a merged manifest is.
Source code in graflo/architecture/evolution/preview.py
preview_merge(left, right, op, *, canonical_maps=(), attempt=True)
¶
The declaration graph of a merge, and everything wrong with it.
Walks op's equivalences and canonical maps against left and right
without refusing: each declaration, each map entry and each member is put
through the same check
:func:~graflo.architecture.evolution.merge.merge_manifests uses, one
at a time, so a problem with one does not hide the rest. Every refusal
becomes a possible finding naming the nodes it is about.
With attempt, merge is then run for real and its result -- the merged
schema's shape, or the one refusal it raised, with the completion that
would settle it -- is recorded as the outcome and as a single refusal
finding. Set it to False to describe the declarations without merging.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
left
|
GraphManifest
|
The left manifest, in whatever vocabulary it is in. |
required |
right
|
GraphManifest
|
The right manifest. |
required |
op
|
MergeManifestsOp
|
The merge op: equivalences, canonical maps, identity alignments. |
required |
canonical_maps
|
Sequence[tuple[Side, CanonicalMap]]
|
Extra |
()
|
attempt
|
bool
|
Whether to run a real merge for the authoritative outcome. |
True
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
MergePreview
|
|
MergePreview
|
declarations -- that is the point -- so an empty |
|
MergePreview
|
|
|
MergePreview
|
like. |
Source code in graflo/architecture/evolution/preview.py
1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 | |