graflo.architecture.evolution.canonical¶
Canonical vocabulary maps and merge-time cluster resolution.
Two declarations say how two manifests' names relate. A
:class:~graflo.architecture.evolution.ops.CanonicalMap translates one side's
vocabulary into canonical names — a partial function on names, identity where
unmapped, and idempotent: a canonical name is a fixed point nothing maps away
from. An equivalence cluster on a
:class:~graflo.architecture.evolution.ops.MergeManifestsOp says which
classes across the two sides are one, and may leave what they are called to
the map. Renames merge, so "canonicalize, then declare equivalences in
canonical names" and "declare equivalences in raw names, then canonicalize"
are the same function; :func:resolve_clusters computes it directly — one
composite relabel per side, applied as a single
:class:~graflo.architecture.evolution.ops.CanonicalizeOp — so there is no
intermediate vocabulary an author has to write in.
Vocabulary¶
- declared map — a
CanonicalMapthe author wrote:op.canonical_maps[scope]and thecanonical_maps=pairs handed to merge. Scopedleft/right(that side's own names) orboth(either side's names, and merged names). Folded per side by :func:compose_canonical_mapsinto a :class:DeclaredMaps. - cluster (:class:
~graflo.architecture.evolution.equivalence.Cluster, resolved from a :class:~graflo.architecture.evolution.equivalence.ClusterSpec) — one equivalence declaration, resolved: its members per side, in the manifests' own spelling, and its merged name —intotranslated through the declared maps, else the canonical name a map gives a member, else the one spelling every member shares. - cluster map — per side, every member onto its merged name, the merged name itself included as a self entry so the op merges into it rather than refusing an occupied target.
- composite map (:class:
SideMaps, oneCanonicalizeOpper side) — the cluster map plus every declared entry that applies to a non-member: what merge applies to that side before the union by name. A relabel, not a vocabulary — two clusters may legitimately chain (one merged name renamed away by another declaration), which aCanonicalMaprefuses. - fixed point — a canonical target. No declared map and no cluster may move it.
- opinion — what the declared maps say a member's canonical name is: the target it maps to, or itself when it is a fixed point.
- satisfied entry — a declared entry whose source is absent from a side and whose target is present: taken as already applied by the caller. A heuristic — it cannot tell that from a target that never had that source — so it is logged.
- dangling entry — a declared entry that matches nothing on any side it
could apply to. A typo, refused: one refusal names every one of them on a
side, each with a near-miss candidate where another spelling denotes the
same concept.
allow_dangling_entriesdrops them instead, for a shared vocabulary deliberately broader than the manifest it is applied to. - synthesized cluster — a cluster merge declares itself under
name_conflict="union_right"for a name both sides carry after their composite maps, or two spellings of one name, so that a union by name goes through the same identity and property reconciliation as a declared one. - completion (:class:
Completion) — the extension that would make an incomplete declaration consistent, carried by :class:MergeIncompleteErroras declaration payloads.
One rule¶
The declared maps and the equivalences must agree on where every name goes, and a canonical target is a fixed point neither may re-map. Every refusal is an instance of it, in one of four classes:
| class | error | a trigger |
|---|---|---|
| contradiction | MergeCanonicalConflictError |
the map says Firm → Company, the cluster names the merged class Party |
| ambiguity | MergeCanonicalConflictError |
one canonical name denotes two members of one cluster |
| incomplete | MergeIncompleteError (carries a Completion) |
a map entry sends a non-member onto a merged name |
| dangling | MergeCanonicalConflictError |
an entry matching no name on any side it could apply to |
Plus the cluster-shape checks of
:mod:~graflo.architecture.evolution.equivalence, which run before any
rename. Every case a declared entry and a cluster can stand in — agreement,
naming, translation and each refusal — is tabulated in
docs/concepts/schema/manifest_evolution.md under "Canonical maps".
Identity is nominal: a class is the same class across two manifests only by name (or by declared equivalence) — nothing structural fingerprints it, and merge never infers a match.
Attributes¶
Scope = Literal['left', 'right', 'both']
module-attribute
¶
__all__ = ['CanonicalMap', 'ClusterResolution', 'Completion', 'DanglingEntry', 'DeclaredMaps', 'MergeCanonicalConflictError', 'MergeIncompleteError', 'Scope', 'SideMaps', 'SideNames', 'canonical_map_to_ops', 'canonical_near_collisions', 'canonicalize_ops', 'clusters_to_side_maps', 'compose_canonical_maps', 'dangling_entries', 'fold_declared_maps', 'resolve_clusters', 'same_name_groups', 'trim_canonical_map', 'validate_and_complete_canonical_map']
module-attribute
¶
logger = logging.getLogger(__name__)
module-attribute
¶
Classes¶
CanonicalMap
¶
Bases: ConfigBaseModel
Declared translation of a source vocabulary into canonical names.
A partial function on names, identity where unmapped: vertices maps
source class names to canonical class names, relations does the same
for relation names, and properties maps, per source class name,
source attribute names to canonical attribute names — including for
classes whose name does not change. Two sources sharing a target is a
merge and must be acknowledged with allow_merges.
It is a vocabulary, so it is idempotent: a canonical name is a fixed
point that no entry maps away from. A chain ({X: Z, Z: Q}) or a swap
is refused at construction — that shape is a relabel, which
:class:CanonicalizeOp expresses directly. The rule is what lets two
maps, or a map and an equivalence, be checked for agreement without
asking in which order they were written.
Used on its own through :func:~graflo.architecture.evolution.canonical.canonical_map_to_ops,
and on :attr:MergeManifestsOp.canonical_maps where it names the
merged classes and is checked against the declared equivalences.
Source code in graflo/architecture/evolution/ops.py
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 | |
Attributes¶
allow_dangling_entries = PydanticField(default=False, description='Accept entries that name nothing in the manifest the map is applied to, dropping and logging each one. A shared vocabulary map is legitimately broader than any single manifest. Off by default, because a misspelt class has exactly the same shape, and dropping it silently narrows the rename to less than the author asked for.')
class-attribute
instance-attribute
¶
allow_merges = PydanticField(default=False, description='Accept a non-injective ``vertices`` / ``relations`` map. Two sources sharing a canonical target is a *merge*, not a rename; it must be a stated intent because merging fuses entities and can create self-relations.')
class-attribute
instance-attribute
¶
properties = PydanticField(default_factory=dict, description='Per-source-class attribute rename map: ``{source_class: {source_attr: canonical_attr}}``.')
class-attribute
instance-attribute
¶
relation_targets
property
¶
Canonical relation names this map establishes.
relations = PydanticField(default_factory=dict, description='Relation rename map: ``{source_relation: canonical_relation}``.')
class-attribute
instance-attribute
¶
vertex_targets
property
¶
Canonical class names this map establishes (targets of a real rename).
vertices = PydanticField(default_factory=dict, description='Class rename map: ``{source_class: canonical_class}``.')
class-attribute
instance-attribute
¶
Methods:¶
canonical_class(source_class)
¶
canonical_property_names(canonical_class)
¶
Canonical attribute names the map establishes on canonical_class.
Source code in graflo/architecture/evolution/ops.py
canonical_relation(source_relation)
¶
Canonical name of source_relation (itself when unmapped).
ClusterResolution
dataclass
¶
What merge applies: the resolved clusters and one composite relabel per side.
declared is the folded declared vocabulary as seen from each side;
side_maps is the composite — every cluster member onto its merged
name, every applicable declared entry as written — one
:class:~graflo.architecture.evolution.ops.CanonicalizeOp per side.
index includes any cluster merge synthesized.
Source code in graflo/architecture/evolution/canonical.py
Completion
dataclass
¶
The extension that would make an incomplete merge declaration consistent.
kind says what to do: extend_cluster — replace one declared cluster
by the payload carried here (the same declaration with one more member);
declare_equivalences — add the carried declarations to the op (or set
name_conflict="union_right", which declares exactly these itself).
Payloads are VertexEquivalence / RelationEquivalence documents, so
a CLI can print them and an author can paste them.
Source code in graflo/architecture/evolution/canonical.py
Attributes¶
kind
instance-attribute
¶
relation_equivalences = ()
class-attribute
instance-attribute
¶
side = None
class-attribute
instance-attribute
¶
vertex_equivalences = ()
class-attribute
instance-attribute
¶
Methods:¶
__init__(kind, side=None, vertex_equivalences=(), relation_equivalences=())
¶
to_dict()
¶
The completion as a plain document.
Source code in graflo/architecture/evolution/canonical.py
DanglingEntry
dataclass
¶
A canonical-map entry whose source matches nothing on its side.
The source names no class or relation the side declares, and the entry is
none of the four ways an absent source is still meaningful: a cluster
member, an already-applied rename, a merged name as the author spelled
it, or a both-scoped entry that applies to the other side.
Carried as data rather than refused one at a time, so that authoring a map against a schema of hundreds of classes is not one refusal per mistake.
Source code in graflo/architecture/evolution/canonical.py
Attributes¶
kind
instance-attribute
¶
side
instance-attribute
¶
source
instance-attribute
¶
suggestion = ''
class-attribute
instance-attribute
¶
target = None
class-attribute
instance-attribute
¶
Methods:¶
__init__(side, kind, source, target=None, suggestion='')
¶
describe()
¶
The entry as it reads in a refusal, without naming the side.
The near-miss :attr:suggestion is left to the caller to place: it is
a trailing clause, and only a listing has somewhere to put one.
Source code in graflo/architecture/evolution/canonical.py
DeclaredMaps
dataclass
¶
The declared vocabulary as each side sees it.
left / right are the both-scoped map folded under that side's
own map; both is kept apart because its entries may legitimately apply
to one side only.
Source code in graflo/architecture/evolution/canonical.py
MergeCanonicalConflictError
¶
Bases: Refusal
A merge op's clusters and its declared maps contradict each other.
A contradiction (one name, two targets; a fixed point moved), an ambiguity
(a canonical name denoting two members), or a dangling entry. The
subclass :class:MergeIncompleteError is the one refusal an extension
resolves.
check names the rule that refused — the parenthesised phrase in the
message — and subjects the names it is about, as
:func:~graflo.architecture.evolution.equivalence.subject ids; see
:class:.Refusal.
Source code in graflo/architecture/evolution/canonical.py
MergeIncompleteError
¶
Bases: MergeCanonicalConflictError
The declarations are consistent but do not cover a name; an extension would.
Distinct from a contradiction: nothing has to be retracted, something has
to be added, and :attr:completion says what.
Source code in graflo/architecture/evolution/canonical.py
SideMaps
dataclass
¶
The composite relabel per side: the one op merge applies to each.
Source code in graflo/architecture/evolution/canonical.py
SideNames
dataclass
¶
The class and relation names one side actually declares.
Source code in graflo/architecture/evolution/canonical.py
Attributes¶
relations
instance-attribute
¶
vertices
instance-attribute
¶
Methods:¶
__init__(vertices, relations)
¶
of(manifest)
classmethod
¶
Source code in graflo/architecture/evolution/canonical.py
Functions:¶
canonical_map_to_ops(cm, *, allow_self_relations=False, allow_observation_fusion=False)
¶
Lower a declared map to its single op.
A canonical map is a function on names, and
:class:~graflo.architecture.evolution.ops.CanonicalizeOp applies exactly
that function in one step: attribute renames (keyed by the source class)
first, then classes and relations simultaneously, so no op order can leak
into the result. A map with no effective entry lowers to no op at all. A
group of more than one class or relation is a merge and is refused unless
allow_merges is set.
Source code in graflo/architecture/evolution/canonical.py
canonical_near_collisions(left_names, right_names, *, exempt)
¶
(left, right) pairs that key alike under canonical_slug but differ.
Exact matches are excluded: those are the same-name path, so the two checks can never report one pair twice.
Source code in graflo/architecture/evolution/canonical.py
canonicalize_ops(op)
¶
op as the op list to apply: empty when it has no effective entry.
clusters_to_side_maps(index, *, allow_merges)
¶
Lower every cluster of index into a pair of per-side relabels, and nothing else.
Source code in graflo/architecture/evolution/canonical.py
compose_canonical_maps(base, extension)
¶
Partial-function union of two declared maps; a target of either is a fixed point.
Every source named by base or extension maps to exactly one target; a
source the two disagree on raises :class:MergeCanonicalConflictError.
A target of either map is a fixed point the other may not move, checked
in both directions so the result does not depend on which map is base.
properties union the same way per source class.
Source code in graflo/architecture/evolution/canonical.py
dangling_entries(cm, manifest, *, side='left')
¶
The map's entries that match nothing in manifest, with near-miss candidates.
A canonical map is authored against one manifest long before it is merged
against another, and this is that check on its own: no equivalences, no
other side, no merge. With no clusters declared there are no merged
names and no both scope, so the classification merge uses collapses
to its two surviving cases — a source the manifest declares is applicable,
a source it does not but whose target it does is already applied — and
everything else dangles.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cm
|
CanonicalMap
|
The declared map. |
required |
manifest
|
GraphManifest
|
The manifest the map is meant to apply to. |
required |
side
|
Side
|
Which side the map is scoped to; names the entries in the result. |
'left'
|
Returns:
| Name | Type | Description |
|---|---|---|
One |
DanglingEntry
|
|
...
|
relations, then attribute maps. Empty means the map applies as written. |
Source code in graflo/architecture/evolution/canonical.py
fold_declared_maps(op, extra)
¶
Source code in graflo/architecture/evolution/canonical.py
resolve_clusters(op, *, left, right, canonical_maps=())
¶
Resolve op's clusters against its declared maps and build the per-side composite.
canonical_maps are extra (side, map) pairs folded into
op.canonical_maps. left / right are the manifests about to be
merged, in whatever vocabulary they are in: a declared entry whose
source is absent on its side but whose target is present is satisfied and
is a no-op; one matching nothing is refused as a typo.
A name both sides carry after their composite maps, and no cluster
merges, is what op.name_conflict decides: error refuses it as
incomplete, naming the equivalences to declare; union_right declares
them itself (a synthesized cluster, so the union goes through the
same identity and property reconciliation as a declared one — two
spellings of one name, canonical_slug alike, are one such cluster
under the left spelling); prefix_right leaves them to merge to keep
apart.
Raises :class:~graflo.architecture.evolution.equivalence.ClusterConflictError
when the declared clusters themselves conflict,
:class:MergeCanonicalConflictError when a map and the equivalences
disagree — a member the map sends elsewhere than the merged name, a
canonical class or attribute re-targeted by a cluster, a cluster with no
name, a dangling entry, a property equivalence naming an absent or
colliding field — and :class:MergeIncompleteError when an extension
would resolve it: a map entry sending a non-member onto a merged name,
or a shared name under name_conflict="error".
Source code in graflo/architecture/evolution/canonical.py
1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 | |
same_name_groups(resolution, names, *, kind, near)
¶
(merged name, left members, right members) for every name no cluster covers.
Names are compared after each side's composite map, since that is what
the union sees. With near, two spellings of one name (canonical_slug
alike) form one group too, merged under the left spelling.
Source code in graflo/architecture/evolution/canonical.py
trim_canonical_map(cm, manifest, *, side='left')
¶
cm with its entries for manifest only, and the entries dropped.
Trimming is an authoring step, not something merge does on its own: the
result is a value to inspect and save, so that a map narrowed to a manifest
is a change with a diff rather than a silent omission at merge time. A
map that is deliberately broader than any one manifest is better served by
allow_dangling_entries, which keeps the map whole.
Returns:
| Type | Description |
|---|---|
CanonicalMap
|
The trimmed map and the entries removed, as |
tuple[DanglingEntry, ...]
|
|
Source code in graflo/architecture/evolution/canonical.py
validate_and_complete_canonical_map(op, *, left, right, canonical_maps=())
¶
Validate op against its declared maps and return the completed per-side relabels.
The merged name of every cluster is completed — from into, the
declared map, or the members' shared spelling — and every member maps
onto it; the declared map's remaining entries are carried as written.
Apply the result to each side with :func:canonicalize_ops before the
schema/resource union, which is what
:func:~graflo.architecture.evolution.merge.merge_manifests does.
Raises :class:MergeCanonicalConflictError for every refusal of
:func:resolve_clusters, wrapping a
:class:~graflo.architecture.evolution.equivalence.ClusterConflictError
when the declared clusters themselves conflict.