0002 — Comparing model states, and reviewing them in Git¶
Status Accepted (2026-09-25) Date 2026-09-25
Context¶
Anyone keeping DEXPI models under version control asks different questions about change:
- What differs between these two model states? Revision comparison, review UIs, APIs.
- What should a reviewer see in GitHub or GitLab? A stable text view that diffs well.
- Is this model exactly the one I stored? A fingerprint that can be persisted.
The first question had a partial answer and the second none. A separate tool,
dexpidiff, prototypes the second.
What existed¶
pydexpi.diff compares two models. PyDexpiDiffEngine matches objects through a pluggable
SemanticEquality (id_equality, TagEquality), then reports added, removed, moved and modified
objects as DiffResult. When this was decided the results were stdlib dataclasses;
AttributeChange carried the live objects of both models, so a result could not be serialised.
Composition-list order was ignored and reference lists were compared as sets.
dexpidiff renders a Proteus file as INI-like text (categories such as EQUIPMENT and
PIPING, tag-named sections, reversible _XX name escaping) and diffs two such files section by
section. It is built on pyDEXPI 1.x and model_dump(), and carries no licence.
Evidence that shapes the design¶
Snapshots are not history. A property changed 8 → 10 → 12 → 8 gives an empty diff between the first and last state, and three changes in between. Both answers are correct; they are answers to different questions, and a comparison of states answers only the first.
Stable ids exist in only one of the two domains. Loading the same file twice re-mints the id of every object the file did not identify. Measured by loading each of the 35 TrainingTestCases twice:
| objects | new id on reload | |
|---|---|---|
| all | 2803 | 1996 |
with a make_id-shaped id |
1996 | 1996 |
with a tag (display_name.get_tag) |
286 | 0 |
| non-graphics | 808 | 155 |
So in XML-derived models the unstable ids are exactly the generated-looking ones, the
EngineeringModel root among them, and every tagged object keeps the id its file gave it. Models
maintained as JSON, by contrast, keep every id — including make_id-shaped ids of objects created
in code, which look generated but are stable once saved. The shape of an id alone therefore
cannot say whether to trust it; the caller must.
The comparison was blind to things that change what a model says. List order (z-order; the
flow order of a segment's items and connections), a move between two attributes of the same
parent, and — in the generated data-type equality — the order of a list inside a data type, such
as the languages of a MultiLanguageString. (A list-valued data attribute such as
PolyLine.points is compared element by element, so its order is seen.)
The existing document hash is not stable across processes. hashing.canonical_json_hash uses
Python's salted hash(), and its canonical serialisation sorted data-type lists by hash() as
well. It cannot be persisted.
Options considered¶
One change format for everything. Make the Git text a diff format, or read it back into a model change. Rejected: the Git text is lossy by design, and a comparison of two loaded models is the exact answer.
Import dexpidiff's code. Rejected: no licence, a pyDEXPI 1.x data model, and an identity
scheme (tagName → subTagName → list index) that collides on duplicate tags and renumbers every
untagged sibling on insertion. Its concepts are reused.
For the Git projection: nested INI, YAML, canonical JSON, or a flat line format. Nested INI and YAML encode the hierarchy in section paths or indentation, so a rename or reparenting rewrites the whole subtree. Canonical JSON diffs as brace and comma noise and gives poor hunk headers. A flat, one-block-per-object text keeps a change inside the changed object's block.
Decision¶
Separate representations, with one-way bridges between them:
State comparison -> DiffResult pydexpi.diff
Exact state -> model_digest pydexpi.hashing
Git readability -> text projection pydexpi.versioning
model A, model B --id_diff--> DiffResult
model --projection--> text --git diff--> review (never back to a model)
- A.
DiffResultbecomes a versioned Pydantic model.format_versionand amatchinglabel (id,tag,custom— never a callable's name) travel with it; values stay live in memory for existing callers and serialise to a documented wire form (pydexpi.loaders.value_codec). No timestamps or other nondeterministic fields. - B.
hashing.model_digestfingerprints the exact serialized state, list order included, as a SHA-256 digest that is the same in every process and on every machine, so it can be stored. - C. The Git projection lives in a new
pydexpi.versioningpackage, which depends on the diff's identity rules and on nothing that depends on it. - D. Identity is domain-specific. Acting on a diff needs stable ids (
id_diff); a tag-matched diff is for display. The projection takes anid_policy: trust every id (models kept as JSON) or only ids not shaped likemake_idids (third-party XML; a heuristic on the id's shape, borne out by the measurement above, not knowledge of provenance), falling back to deterministic structural identities. - E. The projection is a flat, line-based text, one block per object, versioned, not round-trippable. The canonical DEXPI file stays the source of truth; the projection is a review artifact.
Whether the projection is committed next to the model, generated by a Git textconv driver, or
produced in CI is a repository-workflow choice pyDEXPI does not make.
Addendum: the projection's identity for untrusted objects (measured)¶
Decision D left the key for objects without a trusted id to a measurement. On the TrainingTestCases (changed projection lines; 35 reloads, 3 property changes on untrusted objects, 11 insertions into the largest untrusted sibling group, 1 rename of a tank created in code):
| key | reload | property | insertion | rename |
|---|---|---|---|---|
| anchored: nearest trusted ancestor + path + own content + tag | 0 | 18 | 83 | 10 |
| parent token + content ("structure first") | 0 | 4032 | 83 | 8 |
| tag first, then content | 0 | 4088 | 83 | 8 |
| position among siblings | 0 | 6 | 685 | 4 |
Anchored keys were adopted. Keying on the parent's token cascades a change into every descendant; keying on position renumbers later siblings; keying a lone child by position alone renames it when a second sibling appears. The price of anchored keys is local: changing or renaming an untrusted object changes that object's own token (the tag is part of its key), while a trusted object's token is its id alone.
Consequences¶
- There are two equalities, and the tests name which one they use: semantic equality (
id_diffreports no changes) and exact serialized-state equality (hashing.model_digest, which is order-sensitive and stable across processes). - Comparing two independently exported third-party files produces a diff whose ids cannot be trusted to name the same object twice.
- The projection's identity for objects without a trusted id is a trade-off with no perfect answer;
the choice is made by measuring churn and recorded in
docs/mkdocs/versioning.md. dexpidiffis not integrated (closed as won't-do). It has no licence that would allow incorporating it, and pyDEXPI provides loading, the semantic diff and the Git projection itself, as library functions and in thepydexpiCLI. Its concepts remain prior art; there is no second diff engine to consolidate.
Revisit when¶
- Tag-matched comparisons need stable answers, for merging third-party revisions. That needs an explicit answer to which id a matched pair keeps.