Net-effect change reporting for DataGraphs schemas.
This module is the pure, read-only consumer of a
~datagraphs.schema.Schema's tracking state. Given the baseline
snapshot, the recorded op-log, and the current schema dict, it computes a
deterministic, semantically-annotated changelog.
It has no dependency on the Schema class: every function operates on plain
dicts and lists, so it can be tested and reasoned about in isolation. Schema
owns producing the op-log (the cross-cutting tracking concern woven into its
mutating methods); this module owns interpreting it. The single public entry
point is build_change_report; everything else is module-internal
pipeline detail.
| Class | |
Represents a single net-effect change detected by the structural diff. |
| Class | |
Strategy that renders an ordered list of Change instances. |
| Class | |
Render Changes as structured record dicts (the records format). |
| Class | |
Identity correspondence derived by event-sourced op-log replay. |
| Class | |
Render Changes as a human-readable plain-text changelog (the text format). |
| Function | build |
Compute a net-effect changelog from a schema's tracking state. |
| Function | _annotate |
Layer semantic intent from the op-log onto identity-aware structural Changes. |
| Function | _bool |
Render a boolean as lowercase 'true'/'false'; pass anything else through. |
| Function | _change |
Derive the owning class name from a Change's target. |
| Function | _diff |
Compute the net-effect structural delta between two schema dicts. |
| Function | _diff |
Compute per-field changes between two class definitions. |
| Function | _diff |
Diff two ordered property lists for a single class, by identity. |
| Function | _diff |
Compute the per-field changes between two property definitions. |
| Function | _format |
Render one field change as 'field: before -> after' (booleans lowercased). |
| Function | _normalise |
Return the plain-text rendering of a description value. |
| Function | _replay |
Replay the op-log over the baseline to derive identity correspondence. |
| Function | _sort |
Return a copy of changes sorted into a deterministic rendering order. |
| Constant | _CLASS |
Per-class fields that are diffed (description is handled separately). |
| Constant | _CREATE |
Undocumented |
| Constant | _DATE |
Top-level date fields excluded from diff everywhere. |
| Constant | _KIND |
kind_rank: metadata(0) < class(1) < property(2) |
| Constant | _METADATA |
Top-level metadata keys that are server-internal / structural plumbing and must NOT surface as user-facing change-report entries. name and the classes list are handled explicitly; dates are excluded above; everything else listed here (identifiers, JSON-LD context, type tags) is server-owned and a change to it is not a domain-model edit the report should claim. |
| Constant | _OP |
op_rank: a fixed stable order shared by both renderers |
| Constant | _PROPERTY |
Per-property fields diffed (description is handled separately). |
| Constant | _RENDERERS |
Output-format registry: maps a REPORT_FORMAT to its renderer strategy. Adding a format is one entry here plus its ChangeRenderer subclass — no branching in build_change_report, which coerces the requested format to the enum and selects the strategy by lookup. |
dict, change_log: list[ dict], current: dict, fmt: REPORT_FORMAT = REPORT_FORMAT.TEXT) -> str | list[ dict]:
¶
Compute a net-effect changelog from a schema's tracking state.
This is the single public entry point of the report module: it runs the
full pipeline (identity replay -> structural diff -> semantic annotation ->
sentinel pruning -> ordering -> rendering) and is invoked by
datagraphs.schema.Schema.change_report. It is strictly read-only:
it never mutates baseline, change_log, or current.
| Parameters | |
baseline:dict | The schema dict as captured at construction (Schema._baseline). |
changelist[ | The recorded op-log (Schema._change_log). |
current:dict | The live schema dict (Schema._schema). |
fmt:REPORT_FORMAT | A REPORT_FORMAT (or an equivalent string, since the enum
is a ~enum.StrEnum): REPORT_FORMAT.TEXT for the
best-effort human-readable changelog, or REPORT_FORMAT.RECORDS for
the guaranteed structured list[dict]. |
| Returns | |
str | list[ | A str for REPORT_FORMAT.TEXT; a list[dict] for
REPORT_FORMAT.RECORDS. Empty ("" / []) when nothing changed. |
| Raises | |
ValueError | If fmt is not a member (or value) of REPORT_FORMAT. |
list[ Change], change_log: list[ dict], baseline: dict, current: dict, rename_map: RenameMap | None = None) -> list[ Change]:
¶
Layer semantic intent from the op-log onto identity-aware structural Changes.
Runs after _diff + rename folding. Walks the op-log and, for the intent-bearing ops, upgrades the corresponding raw Changes into a single semantic record. Changes with no matching op-log entry are returned unchanged (ADR 0001 graceful degradation).
The following upgrades are applied:
- create_subclass — the diff reports the new subclass as a single op="added" class Change (an added class does not expand into per-property adds). It is replaced by one op="subclass_created" Change with detail={"parent": <parent>, "inherited": N} where N is the number of properties on the newly-created subclass (its full inherited property set, mirroring the parent).
- create_property / update_property with apply_to_subclasses — the op-log records the apply_to_subclasses intent flag. When the flag is set and the parent has at least one current subclass, the parent property Change is referenced from every current subclass via detail={"applied_to_subclasses": [<subclass current name>, ...]}, derived from issued INTENT (the op-log + the current subclass set), NOT from a per-subclass diff effect — so the annotation appears even when a subclass already held the value and produced no structural Change (ADR 0001/0002: the op-log is the authoritative intent source). The per-subclass structural Changes (where they exist) are kept as ordinary records. No annotation is added when the flag is unset or the parent has no subclasses.
- assign_label_property — the diff reports a class labelProperty field flip plus the designated property's isOptional/isLangString flips. The two are fused: the class Change's labelProperty field entry is dropped (if it was the only changed field the whole class Change is removed), and the property Change gains detail={"label_property": <prop current name>} marking it as the coherent label-property designation rather than two unrelated flips.
- assign_property_orders — driven directly from the op-log entry (the authoritative intent, ADR 0001). For each class in the entry the relative order of the properties present in BOTH baseline and current is compared; if it differs, an op="reordered" Change (target=<Class current name>, detail={"order": [<current order>]}) is emitted, INDEPENDENT of any concurrent property add/remove (which suppresses the diff's exact-set __order__ candidate). Any structural __order__ reorder candidate for the class is consumed: replaced by the reordered Change on a genuine reorder, or dropped when the surviving properties are unchanged in order (so the internal sentinel never surfaces on a tracked no-op). An untracked reorder (no op-log entry) is left untouched — documented degradation (ADR 0001).
- delete_class(cascade_to_subclasses=True) — the stripped subClassOf on former subclasses is genuine structural truth and is left exactly as the diff reported it (an op="modified" class Change whose fields carries subClassOf: <parent> -> None). Pinned by tests as documented behaviour.
| Parameters | |
changes:list[ | The identity-aware Changes from _diff. |
changelist[ | The Schema._change_log op-log entries. |
baseline:dict | The baseline schema dict (for parent/inheritance lookups). |
current:dict | The current schema dict (for subclass / property lookups). |
renameRenameMap | None | Identity correspondence (from _replay_identities), or None. Used to resolve op-log call-time names to current names. |
| Returns | |
list[ | A new list of annotated Changes in the original diff order. |
Derive the owning class name from a Change's target.
For "Class.prop" targets this returns "Class". For bare class or metadata targets the target itself is returned so the sort stays total.
| Parameters | |
ch:Change | The change to inspect. |
| Returns | |
str | The class-level sort key string. |
Compute the net-effect structural delta between two schema dicts.
This is the authoritative source of structural truth. It never touches createdDate or lastModifiedDate. Descriptions are normalised to plain text. No-op sequences (create-then-delete, modify-then-revert) produce no entry.
When a rename_map is supplied, classes and properties are matched by canonical/baseline identity rather than by current name (ADR 0003): a baseline class whose identity maps to a differently-named current class is reported as a single op="renamed" Change (from_/to) with any field-level modifications merged into that same record, and its properties are diffed scoped to the class's baseline identity. A name that maps back to itself carries no rename label; an untracked rename (no op-log event) has no map entry and degrades to remove+add.
| Parameters | |
baseline:dict | Deep-copied baseline schema dict (from Schema._baseline). |
current:dict | Live current schema dict (from Schema._schema). |
renameRenameMap | None | Identity correspondence (from _replay_identities), or None for a pure name-keyed diff. |
| Returns | |
list[ | List of Change instances in traversal order. |
Compute per-field changes between two class definitions.
Only fields that actually differ are included. Date fields are excluded. Descriptions are normalised to plain text before comparison.
| Parameters | |
bdict | Baseline class dict. |
cdict | Current class dict. |
| Returns | |
list[ | List of {"field": str, "before": Any, "after": Any} entries. |
list[ dict], c_props: list[ dict], class_name: str, rename_map: RenameMap | None = None, current_class_name: str | None = None) -> list[ Change]:
¶
Diff two ordered property lists for a single class, by identity.
Properties are matched by stable identity through the rename_map's per-baseline-entity fate (ADR 0003): each baseline property's fate is looked up by its baseline identity (class_name, b_name) — its final current name, or None if its identity was deleted during the session. A deleted identity is removed and never matches a current property of the same name (which, if present, is a recycled identity reported added). A surviving property whose final name differs is one op="renamed" Change with field modifications merged in; a current property matched by no surviving baseline identity is added. An untracked rename (no op-log event) leaves the identity's name unchanged in the replay and so degrades to remove+add — exactly as ADR 0001 documents.
A reorder candidate signal is attached as detail={"reorder_candidate": True} on a synthetic op="modified" Change with target "<ClassName>.__order__" when the property-name SET (after rename reconciliation) is unchanged but the ordered SEQUENCE differs. Phase 5 reconciles this against the op-log.
| Parameters | |
blist[ | Baseline property list. |
clist[ | Current property list. |
classstr | Owning class's baseline name (used to scope rename lookups and to build target). |
renameRenameMap | None | Identity correspondence, or None for a pure name-keyed diff. |
currentstr | None | The class's current name (for building target on the current side). Defaults to class_name when unchanged. |
| Returns | |
list[ | List of Change instances. |
Compute the per-field changes between two property definitions.
Only genuinely changed fields are included. Descriptions are normalised to plain text before comparison; date fields are never compared.
| Parameters | |
bdict | Baseline property dict. |
cdict | Current property dict. |
| Returns | |
list[ | List of {"field": str, "before": Any, "after": Any} entries. |
Render one field change as 'field: before -> after' (booleans lowercased).
The shared atom behind every field-change line: class-level field lines, the [renamed] property tail, and the modified property suffix all compose this single rendering — they differ only in the surrounding prefix and join, not in how an individual field is shown.
| Parameters | |
f:dict | A {"field": str, "before": Any, "after": Any} change entry. |
| Returns | |
str | The "field: before -> after" rendering. |
Return the plain-text rendering of a description value.
Descriptions are stored as {"en": "...", "@none": "..."} dicts. Plain strings are passed through unchanged; None maps to "".
| Parameters | |
desc:Any | Raw description value from a schema dict. |
| Returns | |
str | Plain text string. |
Replay the op-log over the baseline to derive identity correspondence.
Each baseline class is seeded with a fresh class-identity and each baseline property with a fresh property-identity scoped to its class-identity. The op-log is then replayed in order, maintaining a live current name -> identity map for classes (and, per class-identity, for its properties):
- create (create_class/create_subclass/create_property) — MINT a brand-new identity (no baseline name) bound to that name, severing the name from any prior occupant. A recycled name therefore binds to a different identity than the original — so a real rename of the original is never stolen, and the net-new entity is reported added, not renamed.
- rename (update_class new_name / rename_property) — REBIND the existing identity's current name (never mint).
- delete (delete_class/delete_property) — END the identity (drop it from the live map; its final name becomes None).
Cascade self-calls are absent from the op-log (recorded only at the outermost public boundary, ADR 0002), so the cascade's per-subclass property creations are invisible here and correctly fall to the diff's "added" path.
| Parameters | |
baseline:dict | The baseline schema dict (Schema._baseline). |
changelist[ | The Schema._change_log op-log entries. |
| Returns | |
RenameMap | A RenameMap carrying the baseline<->current name maps and
the per-op-log-position call-time-name resolutions. |
Return a copy of changes sorted into a deterministic rendering order.
Sort key: (kind_rank, class_name, op_rank, target)
- kind_rank: metadata first (0), then classes (1), then properties (2).
- class_name: alphabetical — groups property changes under their owning class and keeps class-level changes immediately before their properties.
- op_rank: stable across ops — added < subclass_created < renamed < modified < reordered < removed.
- target: alphabetical tie-break within the same class and op.
- a final fully-serialised tie-break (from_/to/fields/detail) so the order is a TOTAL order: two Changes sharing all four primary components (e.g. a recycled name's renamed + removed share kind/class/op rank only partially, but a defensive total order must not fall back to insertion order) still sort deterministically, independent of dict insertion order (ADR 0001 determinism requirement).
Implementation note (performance): the json tie-break is expensive (json.dumps of fields/detail, where a cascade op's detail carries an O(C) applied_to_subclasses list). Under an apply_to_subclasses fan-out there are O(L*C) changes, so serialising the tie-break key for every change is wasteful — the primary 4-tuple already disambiguates the overwhelming majority of changes. This sort is therefore two-phase: sort on the primary tuple first, then serialise the json tie-break only within runs that tie on all four primary components. This yields a total order byte-identical to a single composite key (within each primary-equal run the json key is the sole remaining discriminator, and the primary sort below is stable), while removing the json serialisation from the common, collision-free path.
| Parameters | |
changes:list[ | The annotated list of Change instances. |
| Returns | |
list[ | A new list sorted into rendering order; changes is not mutated. |
Per-class fields that are diffed (description is handled separately).
| Value |
|
Top-level date fields excluded from diff everywhere.
| Value |
|
Top-level metadata keys that are server-internal / structural plumbing and must NOT surface as user-facing change-report entries. name and the classes list are handled explicitly; dates are excluded above; everything else listed here (identifiers, JSON-LD context, type tags) is server-owned and a change to it is not a domain-model edit the report should claim.
| Value |
|
op_rank: a fixed stable order shared by both renderers
| Value |
|
Per-property fields diffed (description is handled separately).
| Value |
|
Output-format registry: maps a REPORT_FORMAT to its renderer
strategy. Adding a format is one entry here plus its ChangeRenderer
subclass — no branching in build_change_report, which coerces the
requested format to the enum and selects the strategy by lookup.
| Value |
|