Skip to content

graflo.architecture.evolution.canonicalize

Canonical form: the payload a content hash is taken over.

Two manifests that describe the same world model must hash equal. The minimal canonical dict produced by :meth:~graflo.architecture.base.ConfigBaseModel.to_minimal_canonical_dict already normalizes defaults, None, aliases and key order. What it does not normalize is list order -- and most lists in the contract are declaration order over a set, so two identical schemas authored in different order, or one authored and one replayed (apply_add_vertices appends), hash differently.

This module adds exactly that one normalization, driven by :data:LIST_ORDER below: a total classification of every list-typed field reachable from GraphManifest as :attr:ListOrder.SORTED or :attr:ListOrder.PRESERVED.

The asymmetry that decides every doubtful case

The two mistakes are not equally bad.

  • Marking an order-significant list SORTED makes two different world models hash equal. A false positive: lineage merges two things that are not the same, and nothing downstream can detect it.
  • Marking an order-insignificant list PRESERVED makes two identical world models hash differently. A false negative: a missed dedup, a redundant commit. Visible, harmless, and fixable later by moving the entry.

So when in doubt, preserve. Every SORTED entry is a positive claim that order carries no meaning in that field, and needs a reason. PRESERVED is the safe default and is used for anything whose ordering semantics are not settled.

Sorting is by the canonical JSON rendering of each element, not by a per-field key. That is total over heterogeneous unions (BindingsRegistry.connectors holds five connector types), needs no tie-break rule, and cannot be input-order-dependent: elements that compare equal are byte-identical and therefore interchangeable. Nothing reads this order -- it is hash-side only, and authored YAML keeps its declaration order.

Nesting is independent. Edge.identities is list[list[str]] marked SORTED: the outer list of alternative keys is sorted, while each inner composite key keeps its order, because (a, b) and (b, a) are different keys.

Attributes

CANON_VERSION = 'graflo/canon@3' module-attribute

CLASSIFIED_MAPPINGS = frozenset({('DatabaseProfile', 'vertex_indexes')}) module-attribute

LIST_ORDER = {('VertexConfig', 'vertices'): SORTED, ('EdgeConfig', 'edges'): SORTED, ('EdgeConfig', 'inverses'): SORTED, ('EdgeConfig', 'symmetric'): SORTED, ('Vertex', 'properties'): SORTED, ('Edge', 'properties'): SORTED, ('Vertex', 'secondary_identities'): SORTED, ('Edge', 'identities'): SORTED, ('Vertex', 'identity'): PRESERVED, ('Vertex', 'hash_identity_properties'): PRESERVED, ('IdentityBranch', 'fields'): PRESERVED, ('IdentityBranch', 'when_all_present'): PRESERVED, ('IdentityFunnel', 'branches'): PRESERVED, ('SecondaryIdentity', 'fields'): PRESERVED, ('Index', 'fields'): PRESERVED, ('DatabaseProfile', 'vertex_indexes'): SORTED, ('DatabaseProfile', 'edge_specs'): SORTED, ('DatabaseProfile', 'native_inverses'): SORTED, ('EdgePhysicalSpec', 'indexes'): SORTED, ('DefaultPropertyValues', 'edges'): SORTED, ('Semantics', 'exact_match'): SORTED, ('Semantics', 'synonyms'): SORTED, ('FieldSemantics', 'exact_match'): SORTED, ('FieldSemantics', 'synonyms'): SORTED, ('IngestionModel', 'resources'): SORTED, ('IngestionModel', 'transforms'): SORTED, ('ResourceConfig', 'pipeline'): PRESERVED, ('ResourceConfig', 'merge_collections'): SORTED, ('ResourceConfig', 'infer_edge_only'): SORTED, ('ResourceConfig', 'infer_edge_except'): SORTED, ('ResourceConfig', 'extra_weights'): SORTED, ('ResourceExtraWeightEntry', 'vertex_weights'): SORTED, ('Weight', 'fields'): PRESERVED, ('ProtoTransform', 'input'): PRESERVED, ('ProtoTransform', 'output'): PRESERVED, ('ProtoTransform', 'input_groups'): PRESERVED, ('ProtoTransform', 'output_groups'): PRESERVED, ('Provenance', 'parents'): PRESERVED, ('KeySelectionConfig', 'names'): SORTED, ('BindingsRegistry', 'connectors'): SORTED, ('BindingsRegistry', 'connector_templates'): SORTED, ('BindingsRegistry', 'connector_connection'): SORTED, ('BindingsRegistry', 'resource_connector'): SORTED, ('BindingsRegistry', 'staging_proxy'): SORTED, ('KafkaConnector', 'topics'): SORTED, ('APIConnector', 'retry_status_forcelist'): SORTED, ('TableConnector', 'joins'): PRESERVED, ('TableConnector', 'select_columns'): PRESERVED, ('JoinClause', 'select_fields'): PRESERVED, ('FilterExpression', 'deps'): PRESERVED, ('FilterExpression', 'value'): PRESERVED, ('TableConnector', 'filters'): PRESERVED, ('Vertex', 'filters'): PRESERVED} module-attribute

PRESERVED = ListOrder.PRESERVED module-attribute

SORTED = ListOrder.SORTED module-attribute

Classes

ListOrder

Bases: str, Enum

Whether a list field's order carries meaning.

Source code in graflo/architecture/evolution/canonicalize.py
class ListOrder(str, Enum):
    """Whether a list field's order carries meaning."""

    #: Order is not meaning: the list is a set written down in some order.
    SORTED = "sorted"
    #: Order is meaning: a program, a precedence chain, a composite key, a
    #: projection, or anything not yet established to be otherwise.
    PRESERVED = "preserved"

Attributes

PRESERVED = 'preserved' class-attribute instance-attribute
SORTED = 'sorted' class-attribute instance-attribute

UnclassifiedListField

Bases: LookupError

A list field reached during canonicalization is absent from LIST_ORDER.

Raised rather than guessed: a default of either kind would silently decide a hash question that this module exists to make explicit.

Source code in graflo/architecture/evolution/canonicalize.py
class UnclassifiedListField(LookupError):
    """A list field reached during canonicalization is absent from LIST_ORDER.

    Raised rather than guessed: a default of either kind would silently decide
    a hash question that this module exists to make explicit.
    """

    def __init__(self, model_name: str, field_name: str) -> None:
        super().__init__(
            f"list field {model_name}.{field_name} is not classified in "
            f"LIST_ORDER ({__name__}). Add it as SORTED (order carries no "
            f"meaning -- state why) or PRESERVED (order is meaning, or is not "
            f"settled), and bump CANON_VERSION if any existing entry moved."
        )
        self.model_name = model_name
        self.field_name = field_name

Attributes

field_name = field_name instance-attribute
model_name = model_name instance-attribute

Methods:

__init__(model_name, field_name)
Source code in graflo/architecture/evolution/canonicalize.py
def __init__(self, model_name: str, field_name: str) -> None:
    super().__init__(
        f"list field {model_name}.{field_name} is not classified in "
        f"LIST_ORDER ({__name__}). Add it as SORTED (order carries no "
        f"meaning -- state why) or PRESERVED (order is meaning, or is not "
        f"settled), and bump CANON_VERSION if any existing entry moved."
    )
    self.model_name = model_name
    self.field_name = field_name

Functions:

canonical_payload(model)

Canonical, hashable payload for any GraFlo config model.

Equivalent to to_minimal_canonical_dict() with every order-insignificant list sorted. Raises :class:UnclassifiedListField if the model tree reaches a list field the audit table does not classify.

Source code in graflo/architecture/evolution/canonicalize.py
def canonical_payload(model: Any) -> Any:
    """Canonical, hashable payload for any GraFlo config model.

    Equivalent to ``to_minimal_canonical_dict()`` with every order-insignificant
    list sorted. Raises :class:`UnclassifiedListField` if the model tree reaches
    a list field the audit table does not classify.
    """
    payload = model.to_minimal_canonical_dict()
    return _canonicalize_node(model, payload)