Skip to content

Comparison

etcion.comparison

Model comparison and diff utilities (EPIC-024, FEAT-24.1 / FEAT-24.2).

FieldChange(field, old, new) dataclass

A single field-level change between two concept snapshots.

ConceptChange(concept_id, concept_type, changes) dataclass

A concept present in both models whose fields differ.

ModelDiff(added, removed, modified) dataclass

Immutable result of comparing two models.

to_dict()

Return a JSON-serializable dict representation.

Source code in src/etcion/comparison.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def to_dict(self) -> dict[str, Any]:
    """Return a JSON-serializable dict representation."""

    def _concept_entry(c: Concept) -> dict[str, Any]:
        return {
            "id": c.id,
            "type": type(c).__name__,
            "name": getattr(c, "name", None),
        }

    def _change_entry(cc: ConceptChange) -> dict[str, Any]:
        return {
            "concept_id": cc.concept_id,
            "concept_type": cc.concept_type,
            "changes": {k: {"old": fc.old, "new": fc.new} for k, fc in cc.changes.items()},
        }

    return {
        "added": [_concept_entry(c) for c in self.added],
        "removed": [_concept_entry(c) for c in self.removed],
        "modified": [_change_entry(cc) for cc in self.modified],
    }

summary()

Return a human-readable one-line summary.

Source code in src/etcion/comparison.py
68
69
70
71
72
73
74
def summary(self) -> str:
    """Return a human-readable one-line summary."""
    return (
        f"ModelDiff: {len(self.added)} added, "
        f"{len(self.removed)} removed, "
        f"{len(self.modified)} modified"
    )

diff_models(model_a, model_b, *, match_by='id')

Compare two models and return a structured diff.

:param model_a: Baseline ("before") model. :param model_b: Revised ("after") model. :param match_by: "id" matches concepts by id; "type_name" matches by (type_name, name) tuple. :returns: A frozen :class:ModelDiff.

Source code in src/etcion/comparison.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def diff_models(
    model_a: Model,
    model_b: Model,
    *,
    match_by: Literal["id", "type_name"] = "id",
) -> ModelDiff:
    """Compare two models and return a structured diff.

    :param model_a: Baseline ("before") model.
    :param model_b: Revised ("after") model.
    :param match_by: ``"id"`` matches concepts by id; ``"type_name"`` matches
        by ``(type_name, name)`` tuple.
    :returns: A frozen :class:`ModelDiff`.
    """
    lookup_a = {_build_key(c, match_by): c for c in model_a.concepts}
    lookup_b = {_build_key(c, match_by): c for c in model_b.concepts}

    keys_a = set(lookup_a)
    keys_b = set(lookup_b)

    added = tuple(lookup_b[k] for k in keys_b - keys_a)
    removed = tuple(lookup_a[k] for k in keys_a - keys_b)

    modified: list[ConceptChange] = []
    for key in keys_a & keys_b:
        ca, cb = lookup_a[key], lookup_b[key]
        dump_a = _normalize_dump(ca)
        dump_b = _normalize_dump(cb)
        changes = _diff_fields(dump_a, dump_b)
        if changes:
            modified.append(
                ConceptChange(
                    concept_id=ca.id,
                    concept_type=type(ca).__name__,
                    changes=changes,
                )
            )

    return ModelDiff(added=added, removed=removed, modified=tuple(modified))