Example 19: Union of manifests with canonical vocabulary and n-ary equivalence¶
Two independent manifests describe overlapping entities. Source A speaks its
own vocabulary (Firm / Shop, firm_id / shop_id); a canonical map
translates Firm into the target model (Company, company_id). Source B's
Org and Branch, and A's Shop, are declared as one n-ary equivalence
cluster with Firm — one VertexEquivalence naming every member on each
side, in each manifest's own vocabulary. The cluster has no into: the map
names the merged class.
Two declarations, one recipe. The map says what things are called; the
cluster says which classes are one. merge_manifests resolves both into a
single composite map per side, applies it in one step, unions by name, and
refuses when the two disagree.
The guiding principle: a primary identity is a property of the class. The
merged Company gets ONE identity definition referencing only canonical
attributes (match_key, local_key). How each source populates them —
gating, normalization, namespacing — is resource knowledge, carried as
identity_alignments on the merge op. The source manifests stay pure.
Prerequisites¶
- Python 3.11+
- GraFlo package (run from the example directory with
uv run)
The recipe¶
from graflo.architecture.evolution import (
AlignmentAttribute,
CanonicalMap,
MergeManifestsOp,
DerivationSpec,
IdentityAlignment,
LocalKeySource,
LocalKeySpec,
VertexEquivalence,
merge_manifests,
)
op = MergeManifestsOp(
vertex_equivalences=[
# {Firm, Shop} ~ {Org, Branch}; named Company by the map
VertexEquivalence(left=["Firm", "Shop"], right=["Org", "Branch"]),
],
allow_merges=True, # a stated intent: >1 member on a side
canonical_maps={"left": canonical_map}, # Firm → Company, firm_id → company_id
identity_alignments=[ALIGNMENT], # applied inside merge
)
union = merge_manifests(A, B, op)
Renames merge, so this is the same function as canonicalizing A on its own
first and then declaring the cluster in canonical names — merge_manifests
accepts either, and a map entry the caller already applied is a no-op:
canonical_a = apply_evolution(
A, canonical_map_to_ops(canonical_map)
) # one CanonicalizeOp
op = MergeManifestsOp(
vertex_equivalences=[
VertexEquivalence(
left=["Company", "Shop"], right=["Org", "Branch"], into="Company"
)
],
allow_merges=True,
canonical_maps={"left": canonical_map},
)
union = merge_manifests(canonical_a, B, op) # identical result
identity_alignments on the merge op still emit only fundamentals:
AddVertexPropertiesOp— declarematch_key+local_keyonCompany;AddResourceTransformsOp— per-resource derivation steps appended to the pipelines;ReplaceIdentityOp— a priority funnel over the canonical attributes only:[match_key, local_key], no side-specific branches;AddSecondaryIdentitiesOp— retired side keys stay addressable for lookups.
How the merged name is found¶
For every cluster, in order: into when given (translated through the map
when the map maps it); else the canonical name the map gives a member; else
the one spelling every member shares; else merge refuses (unnamed
cluster). A member may be spelled by its own name (Firm) or by its
canonical name (Company).
Consistency¶
One rule: the map and the cluster must agree on where every name goes, and a canonical target is a fixed point neither may re-map. Merge refuses, naming both declarations, on:
- a disagreement — the map says
Firm → Companybut the cluster names the merged classParty(--disagreeing-map-demo), or a cluster renames a class the map already established as canonical; - a dangling map entry that matches nothing on any side it could apply to;
- the same rule for attributes: a canonical attribute is a fixed point, and a property equivalence names fields as spelled on the member.
Two declarations can also be consistent but incomplete — nothing to retract, something to add. Merge refuses those as well, and the refusal carries the declaration that settles it:
- a map entry sending a non-member onto a merged class
(
--forgotten-member-demo): the class would arrive atCompanywithout the cluster's identity and property maps governing it. The completion is that same cluster with the member added; - a name both sides arrive at that no cluster merges
(
--shared-name-demo): two sides meeting atOutletis not a disjoint union and is never silently treated as one. The completion names the members in each side's own spelling.--union-rightdeclares that equivalence itself — a synthesized cluster — and merges instead of refusing.
A synthesized cluster is a real cluster, so its members must agree on an
identity exactly as a declared one's must; the demo's maps align the keys
(shop_id / branch_id → outlet_id) and dropping that alignment raises
MergeIdentityError rather than keying on a field no record carries.
Clusters must also not contradict each other — ClusterConflictError,
wrapped as MergeCanonicalConflictError when it surfaces through
validate_and_complete_canonical_map:
- a class claimed by two declarations — e.g.
right:Orgnamed in both{Firm}~{Org, Branch}and{Shop}~{Org}→Party(--conflicting-cluster-demo); - two declarations sharing one merged name — that collapses them into one merged class, which must be spelled as one n-ary declaration instead;
- a merged name that already names an existing, non-member class on a side — that would silently merge into an unrelated type.
How the condition works¶
The gate lives in a resource transform, not a connector filter — a
connector filter would drop the non-matching records entirely, and they must
still be ingested. gated_normalized_key emits the normalized shared key only
when the gate matches, and None otherwise:
AlignmentAttribute(
name="match_key",
sources={
"r_a": DerivationSpec(
input=["secondary_key", "shared_raw"],
params={"prefix": "abc_", "strip_prefix": "ABC-"},
),
"r_b": DerivationSpec(
input=["org_id", "shared_raw"],
params={"prefix": "", "strip_prefix": "ABC-"},
),
},
)
None is an empty value to the identity digest, so the funnel's match_key
branch never fires for a non-gated record — it falls through to local_key,
which each resource fills from its own key via tagged_key, namespaced
so cross-side collisions are impossible.
Derivation inputs are RAW source-doc field names (firm_id, not
company_id): property renames rewrite vertex.from maps so documents keep
their original keys, and transform inputs are never rewritten.
Exact-name attributes fuse for free. After the boundary relabel,
merge_vertex_models unions fields by spelling — list a PropertyEquivalence
only to rename or to flag identity.
Priority semantics¶
With several alignment attributes, their order is funnel priority: a record keys by the highest-priority attribute it carries. Two records fuse when their strongest present attribute coincides.
Run it¶
No live graph database required.
cd examples/19-union-canonical-equivalence
uv run python build_union.py # → artifacts/manifest_union.yaml
uv run python inspect_fusion.py # which records fuse, and to what
uv run python build_union.py --disagreeing-map-demo # map vs cluster → conflict
uv run python build_union.py --conflicting-cluster-demo # overlapping declarations → conflict
uv run python build_union.py --forgotten-member-demo # incomplete → completion: add the member
uv run python build_union.py --shared-name-demo # incomplete → completion: declare the pair
uv run python build_union.py --shared-name-demo --union-right # …or union it by name
The two incompleteness demos print their completion as YAML, ready to paste
into the op — the same thing graflo merge prints when it refuses.
inspect_fusion.py prints one row per emitted vertex doc across the four
resources feeding Company: five records collapse to three vertices, one
fused pair per aligned key.
build_union.py stays because it shows the recipe as Python. The same
recipe is a verb — graflo merge applies the op and its canonical maps
together:
graflo merge manifest_a.yaml manifest_b.yaml \
--op boundary_op.yaml --canonical-map left=canonical_map.yaml \
-o artifacts/manifest_union.yaml
boundary_op.yaml is the cluster written as YAML, with a declared natural
key in place of the alignment. --canonical-map is folded into the op
(canonical_maps), so the same document could carry the map itself. Drop it
and the same op is refused: the cluster has no name and nothing establishes
one.
Previewing the conflicts¶
Each refusal above is one problem, because merge stops at the first.
preview_merge walks the same declarations without refusing and reports all
of them; build_union.py --plot-dir figs draws one figure per mode.
The conflicting-cluster mode is the one worth looking at. Merge reports the
overlap — Org is claimed by two declarations — and stops. The picture shows
that, and the two identity disagreements waiting behind it:
Classes are drawn with a row per attribute, so an attribute-level declaration
lands on the row it is about: the dashed blue edge is firm_id → company_id,
the canonical map's attribute rename. Identity attributes are underlined,
flagged elements carry a numbered badge into the legend, and a red outline is
what merge actually raised against an amber one the preview found itself.
The same view from the shell, written even on a refusal:
graflo merge manifest_a.yaml manifest_b.yaml --op boundary_op.yaml \
--plot conflicts.svg --preview-json conflicts.json
Notes¶
- Equivalence is declared, never inferred. The canonical map, the
VertexEquivalencecluster, and theIdentityAlignmentare author-supplied; merge only cross-checks the declarations against each other and completes the merged name from them. - A merge is a stated intent. A canonical map that collapses two classes
requires
allow_merges: true, and so does a merge op whose cluster names more than one member on a side —MergeManifestsOp(allow_merges=True). A merge that would turn an edge into a self-relation, or make one pipeline level produce the merged class twice, needsallow_self_relations/allow_observation_fusionon the same op: merge forwards both to the per-sideCanonicalizeOpinstead of bypassing the unary guards. - Changing the funnel rekeys the graph — branch order, ids, and field sets all feed the digest (see example 17).
See example 17 for identity funnels on a single manifest, and example 18 for discovering cross-resource identity instead of declaring it.