From 101da1ad2da25d3d2b1fc5f1826bab1dd8843737 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:01:08 -0400 Subject: [PATCH 1/8] feat(metamodel): observed-schema and diff contracts ObservedSchema/ObservedSchemaSource SPI for what a live graph actually holds, and a signature-aware diff layer: PropertySignatureChanged reports a reshaped property as one entry, the declared set for drift carries the full label closure so inherited labels never read as drift, and every diff value object is JVM-immutable at the public boundary. Refs #45; stacks on feat/metamodel-version-store. --- CHANGELOG.md | 16 + .../embabel/dice/metamodel/MetamodelDiff.kt | 361 ++++++++ .../embabel/dice/metamodel/MetamodelDiffer.kt | 87 ++ .../embabel/dice/metamodel/ObservedSchema.kt | 77 ++ .../dice/metamodel/ObservedSchemaSource.kt | 54 ++ .../support/StructuralMetamodelDiffer.kt | 200 ++++ .../dice/metamodel/MetamodelDifferTest.kt | 870 ++++++++++++++++++ docs/design/INDEX.md | 6 +- docs/design/metamodel-diff.md | 174 ++++ docs/design/metamodel-versioning.md | 10 +- 10 files changed, 1851 insertions(+), 4 deletions(-) create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiffer.kt create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchema.kt create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchemaSource.kt create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt create mode 100644 dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt create mode 100644 docs/design/metamodel-diff.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6524fd6f..b04a1b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,3 +76,19 @@ and the consumer PRs that deliver it). module dependency; no existing API touched. Stored nodes stay readable: every property that existed before keeps its name, meaning, and encoding, and the two new alias fields are absent when nothing declares them. +- Schema diffing contracts in `dice-metamodel`: `MetamodelDiffer` compares two declared + stamps and returns a `MetamodelDiff` — an ordered, canonically sorted list of sealed + `MetamodelChange` entries. The taxonomy covers property *signatures*, not just names: + alongside `EntityTypeAdded`/`Removed`/`Modified` and `RelationshipAdded`/`Removed`, a + `PropertySignatureChanged` pairs `before` and `after` for a property that kept its name + but changed value type, cardinality, or value-vs-reference kind — the case a name-only + diff reports as nothing at all. `DeclaredObservedDiffer` answers the other question, + comparing a `DeclaredSchema` against an `ObservedSchema` snapshot of a live graph and + separating drift (observed but undeclared, actionable) from unobserved (declared but + empty, normal). That comparison is names-only on the observed side, deliberately: a graph + reports labels and relationship types, never declared property shapes. + `ObservedSchemaSource` is the SPI a storage backend implements later; `ObservedSchema` is + a plain value type, so tests drive the whole comparison from a canned snapshot. + `StructuralMetamodelDiffer` implements both interfaces — deterministic, stateless, no + database and no LLM. No drift runner, no quarantine, no Spring, and no new dependency. + **Compatibility: additive.** New types in an existing module; no existing API touched. diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt new file mode 100644 index 00000000..3c54b5d5 --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt @@ -0,0 +1,361 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.metamodel + +import java.util.Objects + +/** + * Copy into a collection nothing can change afterwards — not even a Java caller mutating what a + * getter handed back, or the builder still holding the original. + * + * `java.util.Set.copyOf` would also be genuinely immutable, but its iteration order is deliberately + * randomised, and these results are sorted on the way out so a diff reads and logs the same way + * every run. Wrapping a fresh `LinkedHashSet` keeps that order and is just as unmodifiable: the + * mutable copy inside never escapes. + */ +private fun immutableCopy(values: Set): Set = + java.util.Collections.unmodifiableSet(LinkedHashSet(values)) + +/** The list equivalent: a genuinely immutable copy that keeps its order. */ +private fun immutableCopy(values: List): List = java.util.List.copyOf(values) + +/** + * One structural change between two metamodel versions. + * + * Sealed, so a caller can handle every change kind in a `when` and have the compiler tell them when + * a new kind arrives. That matters more than usual here: the next slice decides which changes are + * lossy enough to quarantine data over, and a change kind nobody noticed would quietly be treated + * as harmless. + * + * The kinds don't overlap. A given difference is reported exactly once, by whichever kind describes + * it most precisely — a property that keeps its name but changes shape is a + * [PropertySignatureChanged], never an add plus a remove. + */ +sealed interface MetamodelChange { + + /** + * An entity type present in the newer schema but absent from the older one. + * + * @property typeName The name of the added entity type. + */ + data class EntityTypeAdded(val typeName: String) : MetamodelChange + + /** + * An entity type present in the older schema but gone from the newer one. Propositions whose + * entity mentions reference it are the obvious quarantine candidates later. + * + * @property typeName The name of the removed entity type. + */ + data class EntityTypeRemoved(val typeName: String) : MetamodelChange + + /** + * An entity type that exists in both versions but gained or lost labels or whole properties. + * One entry carries both deltas for the type; at least one of the four sets is non-empty. + * + * Properties are matched by **name**. A property that appears or disappears lands here, with + * its full signature so a reader can see what was gained or lost. A property whose name + * survives but whose shape moved is a [PropertySignatureChanged] instead — it isn't a removal + * and an addition, and reporting it as one would lose the before/after pairing that makes the + * change readable. + * + * The one exception is a type that declares the same property name more than once with + * different signatures, which a `DataDictionary` permits when two same-named domain types are + * merged into one stamp. There is no single before and after to pair up, so the differing + * signatures are reported here as added and removed instead. Rare, and better than inventing a + * pairing. + * + * @property typeName The entity type name (unchanged). + * @property addedLabels Labels present in the new version but not the old. + * @property removedLabels Labels present in the old version but not the new. + * @property addedProperties Full signatures of properties whose names are new in this version. + * @property removedProperties Full signatures of properties whose names are gone in this version. + */ + class EntityTypeModified @JvmOverloads constructor( + typeName: String, + addedLabels: Set = emptySet(), + removedLabels: Set = emptySet(), + addedProperties: Set = emptySet(), + removedProperties: Set = emptySet(), + ) : MetamodelChange { + + val typeName: String = typeName + + val addedLabels: Set = immutableCopy(addedLabels) + + val removedLabels: Set = immutableCopy(removedLabels) + + val addedProperties: Set = immutableCopy(addedProperties) + + val removedProperties: Set = immutableCopy(removedProperties) + + /** Just the names from [addedProperties], for callers that don't care about the shapes. */ + val addedPropertyNames: Set get() = addedProperties.mapTo(LinkedHashSet()) { it.name } + + /** Just the names from [removedProperties]. */ + val removedPropertyNames: Set get() = removedProperties.mapTo(LinkedHashSet()) { it.name } + + override fun equals(other: Any?): Boolean = + other is EntityTypeModified && + typeName == other.typeName && + addedLabels == other.addedLabels && + removedLabels == other.removedLabels && + addedProperties == other.addedProperties && + removedProperties == other.removedProperties + + override fun hashCode(): Int = + Objects.hash(typeName, addedLabels, removedLabels, addedProperties, removedProperties) + + override fun toString(): String = + "EntityTypeModified(typeName=$typeName, addedLabels=$addedLabels, removedLabels=$removedLabels, " + + "addedProperties=$addedProperties, removedProperties=$removedProperties)" + } + + /** + * A property that kept its name on a type present in both versions, but changed shape: its + * value type narrowed or widened, its cardinality moved, or it turned from a plain value into a + * reference to another type (or back). + * + * This is the change the name-only taxonomy used to miss entirely. Turning `age` from a string + * into an integer, or a single `worksAt` into a list of them, is a real change to what the + * graph can hold, and data extracted under the old shape may no longer fit the new one — but + * with only names on both sides, nothing moved and nothing was reported. + * + * Whether a particular move is *lossy* is deliberately not decided here. A diff states what + * changed; deciding that string→integer strands existing values while integer→string doesn't is + * a policy question, and it belongs to the quarantine slice that comes next. + * + * @property typeName The entity type carrying the property. + * @property propertyName The property name, the same on both sides. + * @property before The signature in the older version. + * @property after The signature in the newer version. + */ + data class PropertySignatureChanged( + val typeName: String, + val propertyName: String, + val before: PropertySignature, + val after: PropertySignature, + ) : MetamodelChange { + + init { + require(before.name == propertyName && after.name == propertyName) { + "PropertySignatureChanged pairs signatures for one property name, but got " + + "propertyName='$propertyName', before.name='${before.name}', after.name='${after.name}'" + } + require(before != after) { + "PropertySignatureChanged requires an actual change, but before and after are identical: $before" + } + } + + /** `true` when the value type or reference target moved (`string` → `integer`, say). */ + val typeChanged: Boolean get() = before.type != after.type + + /** `true` when the property went from one value to many, or the reverse. */ + val cardinalityChanged: Boolean get() = before.cardinality != after.cardinality + + /** `true` when the property flipped between holding a value and pointing at another type. */ + val kindChanged: Boolean get() = before.kind != after.kind + } + + /** + * An allowed relationship present in the newer schema but absent from the older one. + * + * @property descriptor A readable descriptor of the form `From-[name]->To`. + */ + data class RelationshipAdded(val descriptor: String) : MetamodelChange + + /** + * An allowed relationship present in the older schema but gone from the newer one. + * + * @property descriptor A readable descriptor of the form `From-[name]->To`. + */ + data class RelationshipRemoved(val descriptor: String) : MetamodelChange +} + +/** + * What changed between two declared [MetamodelVersion]s. + * + * Both sides are declarations — two things somebody decided — so the comparison is symmetric and + * every difference is a change. Comparing a declaration against a live graph is a different + * question with a different answer shape; that's [DeclaredObservedDiff]. + * + * A diff is **empty** when the two schemas are structurally equivalent. That is exactly the + * condition [MetamodelVersion.hasSameContentAs] reports, and the two agree by construction: the + * differ walks the same fields the content hash is built from. + * + * @property fromVersion The baseline (older) version. + * @property toVersion The target (newer) version. + * @property changes Every change, in a deterministic order: entity-type changes first (by type + * name), then relationship changes. The same pair of versions always produces the same list. + */ +class MetamodelDiff( + val fromVersion: MetamodelVersion, + val toVersion: MetamodelVersion, + changes: List, +) { + + /** + * Copied into a genuinely immutable list. A diff is a result, and results don't change: a Java + * caller doing `getChanges().clear()`, or the differ still holding the builder list, must not be + * able to reshape one after the fact. Kotlin's `List` is a compile-time promise only, so this + * has to refuse at runtime. Plain class rather than a `data class` for the same reason — a + * generated `copy()` would hand its argument straight to the field and skip the copying. + */ + val changes: List = immutableCopy(changes) + + /** `true` when nothing changed. */ + val isEmpty: Boolean get() = changes.isEmpty() + + /** Names from every [MetamodelChange.EntityTypeRemoved] — the quarantine candidates. */ + val removedEntityTypes: Set + get() = changes + .filterIsInstance() + .mapTo(mutableSetOf()) { it.typeName } + + /** Names from every [MetamodelChange.EntityTypeAdded]. */ + val addedEntityTypes: Set + get() = changes + .filterIsInstance() + .mapTo(mutableSetOf()) { it.typeName } + + /** Every [MetamodelChange.EntityTypeModified] entry. */ + val modifiedEntityTypes: List + get() = changes.filterIsInstance() + + /** Every [MetamodelChange.PropertySignatureChanged] entry. */ + val propertySignatureChanges: List + get() = changes.filterIsInstance() + + /** + * Every entity type this diff says something about — added, removed, modified, or holding a + * property whose signature moved. A reshaped type shows up in both [modifiedEntityTypes] and + * [propertySignatureChanges], so answering "did anything about `Person` change?" otherwise + * means checking several lists and forgetting one. + */ + val touchedEntityTypes: Set + get() = changes.mapNotNullTo(mutableSetOf()) { change -> + when (change) { + is MetamodelChange.EntityTypeAdded -> change.typeName + is MetamodelChange.EntityTypeRemoved -> change.typeName + is MetamodelChange.EntityTypeModified -> change.typeName + is MetamodelChange.PropertySignatureChanged -> change.typeName + is MetamodelChange.RelationshipAdded -> null + is MetamodelChange.RelationshipRemoved -> null + } + } + + override fun equals(other: Any?): Boolean = + other is MetamodelDiff && + fromVersion == other.fromVersion && + toVersion == other.toVersion && + changes == other.changes + + override fun hashCode(): Int = Objects.hash(fromVersion, toVersion, changes) + + override fun toString(): String = + "MetamodelDiff(fromVersion=${fromVersion.contentHash}, toVersion=${toVersion.contentHash}, " + + "changes=$changes)" +} + +/** + * What a declared schema and a live graph disagree about. + * + * A different question from [MetamodelDiff], which compares two declarations to each other. Here + * one side is what was decided and the other is a snapshot of reality, and the two can legitimately + * disagree in either direction — so the result isn't a symmetric change list, it's two clearly + * separated buckets: + * + * - **Drift** ([driftedEntityTypes] / [driftedRelationshipTypes]): observed in the graph, never + * declared. This is the actionable case. Concretely it means data is sitting in the graph whose + * declaring integration has since been removed, or never registered one, so nothing here can tell + * that data apart as valid or explain its shape. + * - **Unobserved** ([unobservedEntityTypes] / [unobservedRelationshipTypes]): declared, but with + * zero instances in the graph right now. Purely informational — a declared type with no data yet + * is a completely normal state, not drift. + * + * **Names only, both ways.** The declaration knows each property's kind, type and cardinality; the + * graph doesn't, and can't be asked. So this comparison stops at type and relationship names, and + * says nothing about whether a declared property's shape matches what the graph stores. Pretending + * otherwise would mean sampling nodes and calling the sample a schema. Property signatures are + * compared where both sides genuinely have them: declared against declared, in [MetamodelDiff]. + * + * **A declared label counts as declared.** What a graph reports is labels, and a type usually + * carries more than one: declaring `Person` with parent `Agent` puts both labels on every `Person` + * node. So the declared side of the drift comparison is every entity type name *plus* every label + * those types declare — otherwise an inherited label would be reported as undeclared drift on a + * schema nobody had changed. Going the other way, [unobservedEntityTypes] stays on the type names + * alone: "declared but with no data" is a statement about types, and listing a parent label as an + * unobserved type would be noise about something that was never a type in its own right. + * + * @property declared The schema as declared at snapshot time, stamp and bare relationship names. + * @property observedSchema What the live graph actually held at snapshot time. + * @property driftedEntityTypes Observed labels matching neither a declared type name nor a declared + * label. + * @property driftedRelationshipTypes Relationship type names observed with no matching declaration. + * @property unobservedEntityTypes Declared entity type names with no observed instances. + * @property unobservedRelationshipTypes Declared relationship type names with no observed instances. + */ +class DeclaredObservedDiff( + val declared: DeclaredSchema, + val observedSchema: ObservedSchema, + driftedEntityTypes: Set, + driftedRelationshipTypes: Set, + unobservedEntityTypes: Set, + unobservedRelationshipTypes: Set, +) { + + // Copied into genuinely immutable sets, and a plain class rather than a `data class`, for the + // same reason as MetamodelDiff: a result must not be reshapeable after the fact, and a + // generated copy() would hand its arguments straight to the fields and skip the copying. + + val driftedEntityTypes: Set = immutableCopy(driftedEntityTypes) + + val driftedRelationshipTypes: Set = immutableCopy(driftedRelationshipTypes) + + val unobservedEntityTypes: Set = immutableCopy(unobservedEntityTypes) + + val unobservedRelationshipTypes: Set = immutableCopy(unobservedRelationshipTypes) + + /** The stamp inside [declared] — its hash is what a drift report would record. */ + val declaredVersion: MetamodelVersion get() = declared.version + + /** `true` when the graph holds any type or relationship that was never declared. */ + val hasDrift: Boolean + get() = driftedEntityTypes.isNotEmpty() || driftedRelationshipTypes.isNotEmpty() + + override fun equals(other: Any?): Boolean = + other is DeclaredObservedDiff && + declared == other.declared && + observedSchema == other.observedSchema && + driftedEntityTypes == other.driftedEntityTypes && + driftedRelationshipTypes == other.driftedRelationshipTypes && + unobservedEntityTypes == other.unobservedEntityTypes && + unobservedRelationshipTypes == other.unobservedRelationshipTypes + + override fun hashCode(): Int = Objects.hash( + declared, + observedSchema, + driftedEntityTypes, + driftedRelationshipTypes, + unobservedEntityTypes, + unobservedRelationshipTypes, + ) + + override fun toString(): String = + "DeclaredObservedDiff(declared=$declared, observedSchema=$observedSchema, " + + "driftedEntityTypes=$driftedEntityTypes, driftedRelationshipTypes=$driftedRelationshipTypes, " + + "unobservedEntityTypes=$unobservedEntityTypes, " + + "unobservedRelationshipTypes=$unobservedRelationshipTypes)" +} diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiffer.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiffer.kt new file mode 100644 index 00000000..18644f71 --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiffer.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.metamodel + +import com.embabel.agent.core.DataDictionary + +/** + * Compares two declared schemas and says what changed. + * + * A version stamp answers "is this the same schema as before?"; a differ answers "and what moved?" + * — which is what you need to decide whether stored knowledge is still described by the schema it + * was extracted under. + * + * Take the [MetamodelVersion] overload when you already have stamps, which is the normal case: an + * application stamps at ingestion time and stores the stamp, so the older side comes back out of a + * [MetamodelVersionStore] rather than being recomputed from a dictionary that has since moved on. + */ +interface MetamodelDiffer { + + /** + * Compare two stamps. + * + * @param from The baseline (older) version. + * @param to The target (newer) version. + * @return An immutable diff. [MetamodelDiff.isEmpty] is `true` when the schemas are equivalent. + */ + fun diff(from: MetamodelVersion, to: MetamodelVersion): MetamodelDiff + + /** + * Stamp two dictionaries and diff the results. + * + * This governs everything in both dictionaries, the closed-world reading. If the application + * governs only part of its domain, stamp with its [GovernedTypeSelector] first (or take the two + * stamps off `DeclaredSchema.from(...)`) and use the other overload — otherwise exploratory + * types nobody committed to will show up as schema changes. + * + * @param from The baseline (older) [DataDictionary]. + * @param to The target (newer) [DataDictionary]. + * @return An immutable diff. + */ + fun diff(from: DataDictionary, to: DataDictionary): MetamodelDiff = + diff(MetamodelVersion.from(from), MetamodelVersion.from(to)) +} + +/** + * Compares a [DeclaredSchema] against an [ObservedSchema] — what was declared against what a live + * graph actually holds — and says where the two disagree. + * + * Its own interface rather than another overload on [MetamodelDiffer], because it answers a + * different question. [MetamodelDiffer] compares two declarations, both of them decisions somebody + * made; this compares a decision to an observation. The answer shape differs too: a + * [MetamodelDiff] is a symmetric list of changes, while a [DeclaredObservedDiff] separates drift + * (observed but undeclared, actionable) from merely-unobserved (declared but empty, normal). + * Folding them together would force every caller to sift a generic change list to tell those apart. + * + * It takes a whole [DeclaredSchema] rather than a bare stamp because the comparison needs the bare + * relationship type names, and those can only travel alongside the stamp — never be recovered from + * it. [MetamodelVersion.relationshipNames] holds rendered `From-[name]->To` descriptors, and these + * names come from free text and LLM extraction, so a name can itself contain a `-[...]->`-shaped + * substring; reverse-parsing a descriptor is ambiguous and silently picks the wrong segment. + * `DeclaredSchema.from(dictionary, selector)` builds both halves under the same governance rule, so + * they can't drift apart. + */ +interface DeclaredObservedDiffer { + + /** + * Compare [declared] against [observed]. + * + * @param declared The schema as declared, stamp plus bare relationship type names. + * @param observed A snapshot of what the live graph actually holds. + * @return An immutable diff separating drift from unobserved-but-declared types. + */ + fun diffAgainstObserved(declared: DeclaredSchema, observed: ObservedSchema): DeclaredObservedDiff +} diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchema.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchema.kt new file mode 100644 index 00000000..4549a3f7 --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchema.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.metamodel + +import java.time.Instant +import java.util.Objects + +/** + * A snapshot of what a live graph actually holds, as opposed to what a [DeclaredSchema] says it + * should hold. + * + * Names only, and deliberately so. A graph can tell you which labels and relationship types exist + * in it; it cannot tell you what a property was *declared* to be. Two nodes with the same label can + * carry different property sets, a property can be absent on most of them, and a value that looks + * like an integer today may be a string tomorrow — so anything richer than a name would be a + * sample, not a schema, and would make a diff read as authoritative when it isn't. The declared + * side keeps full [PropertySignature]s; the observed side stays at names, and + * [DeclaredObservedDiff] is honest about the asymmetry. + * + * This is a plain value type — nothing in this module knows how to go and read a graph. A storage + * layer (Neo4j or otherwise) builds one by querying the live database for its distinct labels and + * relationship types, then hands it here to be compared. That split is deliberate: schemas in the + * wild are often partly dynamic, with integrations contributing types that come and go, so what is + * actually in the graph can drift from what was ever declared, and this module needs a way to talk + * about that without depending on any particular graph driver. + * + * @property entityTypeNames Entity type (label) names actually observed in the graph. + * @property relationshipTypeNames Relationship type names actually observed in the graph. + * @property capturedAt When this snapshot was taken. + */ +class ObservedSchema( + entityTypeNames: Set, + relationshipTypeNames: Set, + val capturedAt: Instant, +) { + + // Both sets are copied into genuinely immutable ones, keeping the order they arrived in. A + // snapshot is a statement about a moment, so it must not change afterwards — and a backend + // typically builds these from a mutable set it keeps filling as it walks query results. Kotlin's + // read-only `Set` is a compile-time promise only; a Java caller sees straight through it. Plain + // class rather than a `data class` because a generated `copy()` would skip the copying. + + val entityTypeNames: Set = immutableCopy(entityTypeNames) + + val relationshipTypeNames: Set = immutableCopy(relationshipTypeNames) + + override fun equals(other: Any?): Boolean = + other is ObservedSchema && + entityTypeNames == other.entityTypeNames && + relationshipTypeNames == other.relationshipTypeNames && + capturedAt == other.capturedAt + + override fun hashCode(): Int = Objects.hash(entityTypeNames, relationshipTypeNames, capturedAt) + + override fun toString(): String = + "ObservedSchema(entityTypeNames=$entityTypeNames, relationshipTypeNames=$relationshipTypeNames, " + + "capturedAt=$capturedAt)" + + private companion object { + + private fun immutableCopy(values: Set): Set = + java.util.Collections.unmodifiableSet(LinkedHashSet(values)) + } +} diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchemaSource.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchemaSource.kt new file mode 100644 index 00000000..1be40983 --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchemaSource.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.metamodel + +import com.embabel.agent.core.ContextId + +/** + * Takes a fresh snapshot of what a live graph actually holds, so a [DeclaredObservedDiffer] can + * compare it against what was declared. + * + * This is the storage-side SPI of the pair: [DeclaredSchemaSource] is where an application says + * what it governs, and this is where a backend says what is really there. The module has no graph + * driver dependency and can't implement it — a storage layer (Neo4j via Drivine, or anything else) + * provides the real implementation by querying its live database for distinct labels and + * relationship types. Tests supply a canned [ObservedSchema] instead and never touch a database. + * + * An ordinary interface, not a `fun interface`: it carries two entry points — the scoped [observe] + * an implementation provides, and the no-argument [observe] convenience it gets for free — and a + * SAM lambda can only ever supply the first. The no-argument form is a real method with a body + * rather than a Kotlin default argument so Java callers can write `observe()` too; Java cannot see + * a Kotlin default argument. It is also all Java gets: `ContextId` is a Kotlin value class, so the + * scoped form compiles to a mangled JVM name Java can't call. + */ +interface ObservedSchemaSource { + + /** + * @param contextId `null` snapshots the whole graph. Non-null scopes the snapshot to that one + * context: only that context's own data is consulted. What "scoped" means concretely is up + * to the implementation — a graph-backed one typically walks the context's own nodes, and + * may return an empty relationship-type set when it can only reach node labels that way. + * @return a fresh [ObservedSchema] snapshot, captured at call time. + */ + fun observe(contextId: ContextId?): ObservedSchema + + /** + * Snapshot the whole graph, unscoped. + * + * @return a fresh [ObservedSchema] snapshot, captured at call time. + */ + fun observe(): ObservedSchema = observe(null) +} diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt new file mode 100644 index 00000000..268f4921 --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.metamodel.support + +import com.embabel.dice.metamodel.DeclaredObservedDiff +import com.embabel.dice.metamodel.DeclaredObservedDiffer +import com.embabel.dice.metamodel.DeclaredSchema +import com.embabel.dice.metamodel.MetamodelChange +import com.embabel.dice.metamodel.MetamodelDiff +import com.embabel.dice.metamodel.MetamodelDiffer +import com.embabel.dice.metamodel.MetamodelVersion +import com.embabel.dice.metamodel.ObservedSchema +import com.embabel.dice.metamodel.PropertySignature + +/** + * The shipped differ: a deterministic, structural comparison, with no heuristics and no LLM + * anywhere near it. + * + * It works on exactly the data a [MetamodelVersion] already carries — entity type names, per-type + * label sets, per-type property *signatures*, and relationship descriptors. Those are the same + * fields the content hash is built from, which is what makes an empty diff and an equal hash mean + * the same thing. + * + * Two rules it keeps throughout. Sets are compared as sets, never as a delimiter-joined projection, + * so a label or property name containing a comma or a space — routine when names come from LLM + * extraction — can't collapse two genuinely different sets into a false "unchanged". And output is + * canonical: type names, property names and signatures all come out sorted, so the same pair of + * versions always produces the same change list, in the same order, and a stored diff can be + * compared with another one. + * + * Stateless and thread-safe; one shared instance is fine. + */ +class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { + + override fun diff(from: MetamodelVersion, to: MetamodelVersion): MetamodelDiff { + val changes = mutableListOf() + + val fromTypes = from.entityTypeNames.toSet() + val toTypes = to.entityTypeNames.toSet() + + (fromTypes - toTypes).sorted().mapTo(changes) { MetamodelChange.EntityTypeRemoved(it) } + (toTypes - fromTypes).sorted().mapTo(changes) { MetamodelChange.EntityTypeAdded(it) } + + // A type present in both versions can still have moved: labels, whole properties, or the + // shape of a property whose name stayed put. Walk them in name order so the output is + // canonical. + for (typeName in (fromTypes intersect toTypes).sorted()) { + val addedLabels = canonical(to.entityTypeLabels[typeName].orEmpty() - from.entityTypeLabels[typeName].orEmpty()) + val removedLabels = canonical(from.entityTypeLabels[typeName].orEmpty() - to.entityTypeLabels[typeName].orEmpty()) + + val properties = comparePropertiesOf( + typeName = typeName, + from = from.entityTypeProperties[typeName].orEmpty(), + to = to.entityTypeProperties[typeName].orEmpty(), + ) + + if (addedLabels.isNotEmpty() || removedLabels.isNotEmpty() || + properties.added.isNotEmpty() || properties.removed.isNotEmpty() + ) { + changes += MetamodelChange.EntityTypeModified( + typeName = typeName, + addedLabels = addedLabels, + removedLabels = removedLabels, + addedProperties = properties.added, + removedProperties = properties.removed, + ) + } + changes += properties.signatureChanges + } + + val fromRels = from.relationshipNames.toSet() + val toRels = to.relationshipNames.toSet() + + (fromRels - toRels).sorted().mapTo(changes) { MetamodelChange.RelationshipRemoved(it) } + (toRels - fromRels).sorted().mapTo(changes) { MetamodelChange.RelationshipAdded(it) } + + return MetamodelDiff(fromVersion = from, toVersion = to, changes = changes) + } + + override fun diffAgainstObserved(declared: DeclaredSchema, observed: ObservedSchema): DeclaredObservedDiff { + val declaredTypes = declared.version.entityTypeNames.toSet() + val observedTypes = observed.entityTypeNames + + // What a graph reports is labels, and a type carries every label in its hierarchy: declare + // `Person` with parent `Agent` and every Person node comes back carrying both. Comparing + // observed labels against type names alone would call `Agent` undeclared drift on a schema + // nobody had touched, so the declared side of the drift check is the type names plus every + // label those types declare. + val declaredLabels = declaredTypes + declared.version.entityTypeLabels.values.flatten() + + // Drift is observed-but-never-declared: orphaned data whose declaring integration is gone, + // or was never registered. The opposite direction is not drift — a declared type with zero + // instances is a perfectly ordinary state, so it gets its own, purely informational bucket. + // That direction stays on the type names: "declared but with no data" is a statement about + // types, and a parent label listed as an unobserved type would be noise about something + // that was never a type in its own right. + // + // Relationships compare on the bare type name, because that is all a graph can report (a + // `db.relationshipTypes()`-style query knows the type, not which node types an instance + // actually connected). The bare names come off the declaration, which carried them + // un-rendered; we never recover one by parsing a `From-[name]->To` descriptor, since these + // names are free text and can contain a `-[...]->`-shaped substring themselves. + val declaredRels = declared.relationshipTypeNames + val observedRels = observed.relationshipTypeNames + + return DeclaredObservedDiff( + declared = declared, + observedSchema = observed, + driftedEntityTypes = canonical(observedTypes - declaredLabels), + driftedRelationshipTypes = canonical(observedRels - declaredRels), + unobservedEntityTypes = canonical(declaredTypes - observedTypes), + unobservedRelationshipTypes = canonical(declaredRels - observedRels), + ) + } + + /** The three ways a type's property set can differ, gathered in one pass. */ + private data class PropertyDelta( + val added: Set, + val removed: Set, + val signatureChanges: List, + ) + + /** + * Compare one type's properties, matching them up by name. + * + * Matching by name is what lets a reshaped property read as a reshape rather than as a deletion + * next to an unrelated addition: `age: string` becoming `age: integer` is one change with a + * before and an after, not two changes a caller has to pair back up. + * + * A name can legitimately map to more than one signature on either side — a `DataDictionary` + * may hold two same-named domain types whose properties get unioned into a single stamp. There + * is no single before/after to pair in that case, so the differing signatures are reported as + * added and removed instead of a guessed pairing. + */ + private fun comparePropertiesOf( + typeName: String, + from: Set, + to: Set, + ): PropertyDelta { + val fromByName = from.groupBy { it.name } + val toByName = to.groupBy { it.name } + + val added = mutableListOf() + val removed = mutableListOf() + val signatureChanges = mutableListOf() + + for (propertyName in (fromByName.keys + toByName.keys).sorted()) { + val before = fromByName[propertyName].orEmpty().toSet() + val after = toByName[propertyName].orEmpty().toSet() + when { + before == after -> Unit + before.isEmpty() -> added += after + after.isEmpty() -> removed += before + before.size == 1 && after.size == 1 -> signatureChanges += MetamodelChange.PropertySignatureChanged( + typeName = typeName, + propertyName = propertyName, + before = before.single(), + after = after.single(), + ) + + else -> { + added += after - before + removed += before - after + } + } + } + + return PropertyDelta( + added = canonical(added), + removed = canonical(removed), + signatureChanges = signatureChanges, + ) + } + + private companion object { + + /** + * Sort into a set that iterates in that order. + * + * The sets inside a [MetamodelVersion] are JVM-immutable copies, whose iteration order is + * deliberately unspecified and varies between JVM runs. Set equality doesn't care, but a + * change list that gets logged or rendered does, so anything leaving here is sorted first. + */ + private fun > canonical(values: Collection): Set = + values.sorted().toCollection(LinkedHashSet()) + } +} diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt new file mode 100644 index 00000000..bb80a320 --- /dev/null +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt @@ -0,0 +1,870 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.metamodel + +import com.embabel.agent.core.Cardinality +import com.embabel.agent.core.DataDictionary +import com.embabel.agent.core.DynamicType +import com.embabel.agent.core.ValuePropertyDefinition +import com.embabel.dice.metamodel.support.StructuralMetamodelDiffer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.time.Instant + +class MetamodelDifferTest { + + private lateinit var differ: MetamodelDiffer + private lateinit var declaredObservedDiffer: DeclaredObservedDiffer + + @BeforeEach + fun setUp() { + val structural = StructuralMetamodelDiffer() + differ = structural + declaredObservedDiffer = structural + } + + private fun schemaWith(name: String = "test", vararg typeNames: String): DataDictionary = + DataDictionary.fromDomainTypes(name, typeNames.map { DynamicType(name = it) }) + + @Nested + inner class NoChanges { + + @Test + fun `identical schemas produce an empty diff`() { + val old = schemaWith(typeNames = arrayOf("Person", "Company")) + val new = schemaWith(typeNames = arrayOf("Person", "Company")) + val diff = differ.diff(old, new) + assertTrue(diff.isEmpty) + assertTrue(diff.changes.isEmpty()) + } + + @Test + fun `empty schemas produce an empty diff`() { + val diff = differ.diff(schemaWith(), schemaWith()) + assertTrue(diff.isEmpty) + } + + @Test + fun `schemas with identical property signatures produce an empty diff`() { + val old = personWith(ValuePropertyDefinition("age", type = "integer", cardinality = Cardinality.ONE)) + val new = personWith(ValuePropertyDefinition("age", type = "integer", cardinality = Cardinality.ONE)) + val diff = differ.diff(old, new) + assertTrue(diff.isEmpty, "no change means no entries: ${diff.changes}") + assertTrue(diff.propertySignatureChanges.isEmpty()) + } + + @Test + fun `an empty diff agrees with the content hash`() { + val old = personWith(ValuePropertyDefinition("age", type = "integer")) + val new = personWith(ValuePropertyDefinition("age", type = "integer")) + assertTrue(MetamodelVersion.from(old).hasSameContentAs(MetamodelVersion.from(new))) + assertTrue(differ.diff(old, new).isEmpty) + } + } + + @Nested + inner class AddedTypes { + + @Test + fun `added entity type is reported`() { + val old = schemaWith(typeNames = arrayOf("Person")) + val new = schemaWith(typeNames = arrayOf("Person", "Company")) + val diff = differ.diff(old, new) + assertFalse(diff.isEmpty) + val added = diff.addedEntityTypes + assertTrue(added.contains("Company"), "Expected Company in added: $added") + assertFalse(diff.removedEntityTypes.contains("Company")) + } + + @Test + fun `multiple added types are all reported`() { + val old = schemaWith(typeNames = arrayOf("Person")) + val new = schemaWith(typeNames = arrayOf("Person", "Company", "Technology")) + val diff = differ.diff(old, new) + assertEquals(setOf("Company", "Technology"), diff.addedEntityTypes) + } + } + + @Nested + inner class RemovedTypes { + + @Test + fun `removed entity type is reported`() { + val old = schemaWith(typeNames = arrayOf("Person", "LegacyType")) + val new = schemaWith(typeNames = arrayOf("Person")) + val diff = differ.diff(old, new) + assertFalse(diff.isEmpty) + assertTrue( + diff.removedEntityTypes.contains("LegacyType"), + "Expected LegacyType in removed: ${diff.removedEntityTypes}", + ) + } + + @Test + fun `multiple removed types are all reported`() { + val old = schemaWith(typeNames = arrayOf("Person", "Foo", "Bar")) + val new = schemaWith(typeNames = arrayOf("Person")) + val diff = differ.diff(old, new) + assertEquals(setOf("Foo", "Bar"), diff.removedEntityTypes) + } + + @Test + fun `removed type does not appear in added set`() { + val old = schemaWith(typeNames = arrayOf("Person", "OldType")) + val new = schemaWith(typeNames = arrayOf("Person")) + val diff = differ.diff(old, new) + assertFalse(diff.addedEntityTypes.contains("OldType")) + } + } + + @Nested + inner class MixedChanges { + + @Test + fun `simultaneous add and remove are both captured`() { + val old = schemaWith(typeNames = arrayOf("Person", "LegacyType")) + val new = schemaWith(typeNames = arrayOf("Person", "NewType")) + val diff = differ.diff(old, new) + assertTrue(diff.removedEntityTypes.contains("LegacyType")) + assertTrue(diff.addedEntityTypes.contains("NewType")) + } + + @Test + fun `touchedEntityTypes gathers every type the diff says anything about`() { + val old = DataDictionary.fromDomainTypes( + "test", + listOf( + DynamicType(name = "Person", ownProperties = listOf(ValuePropertyDefinition("age", type = "string"))), + DynamicType(name = "LegacyType"), + ), + ) + val new = DataDictionary.fromDomainTypes( + "test", + listOf( + DynamicType(name = "Person", ownProperties = listOf(ValuePropertyDefinition("age", type = "integer"))), + DynamicType(name = "NewType"), + ), + ) + val diff = differ.diff(old, new) + // Person only shows up via a signature change; LegacyType and NewType via add/remove. + assertEquals(setOf("Person", "LegacyType", "NewType"), diff.touchedEntityTypes) + } + } + + @Nested + inner class ModifiedTypes { + + /** A schema with a single `Person` type whose labels include the given parent's label. */ + private fun personWithParent(parent: String): DataDictionary = + DataDictionary.fromDomainTypes( + "test", + listOf(DynamicType(name = "Person", parents = listOf(DynamicType(name = parent)))), + ) + + @Test + fun `a label change on a same-named type is reported as modified, not add or remove`() { + val old = personWithParent("Agent") // labels: {Person, Agent} + val new = personWithParent("Actor") // labels: {Person, Actor} + val diff = differ.diff(old, new) + + assertFalse(diff.isEmpty) + assertTrue(diff.addedEntityTypes.isEmpty(), "no type was added: ${diff.addedEntityTypes}") + assertTrue(diff.removedEntityTypes.isEmpty(), "no type was removed: ${diff.removedEntityTypes}") + + val modified = diff.modifiedEntityTypes + assertEquals(1, modified.size, "expected exactly one modified type: $modified") + val change = modified.single() + assertEquals("Person", change.typeName) + assertEquals(setOf("Actor"), change.addedLabels) + assertEquals(setOf("Agent"), change.removedLabels) + } + + @Test + fun `unchanged labels yield no modified entry`() { + val diff = differ.diff(personWithParent("Agent"), personWithParent("Agent")) + assertTrue(diff.isEmpty) + assertTrue(diff.modifiedEntityTypes.isEmpty()) + } + + @Test + fun `a wholly new property is reported as modified, with its full signature`() { + val old = personWith(ValuePropertyDefinition("age", type = "integer")) + val new = personWith( + ValuePropertyDefinition("age", type = "integer"), + ValuePropertyDefinition("email", type = "string", cardinality = Cardinality.OPTIONAL), + ) + val diff = differ.diff(old, new) + + assertFalse(diff.isEmpty) + assertTrue(diff.addedEntityTypes.isEmpty()) + assertTrue(diff.removedEntityTypes.isEmpty()) + + val change = diff.modifiedEntityTypes.single() + assertEquals("Person", change.typeName) + assertEquals(setOf("email"), change.addedPropertyNames) + assertEquals( + setOf(PropertySignature("email", PropertySignature.Kind.VALUE, "string", Cardinality.OPTIONAL)), + change.addedProperties, + "an added property carries its shape, not just its name", + ) + assertTrue(change.removedProperties.isEmpty()) + // labels are unchanged, so their deltas stay empty + assertTrue(change.addedLabels.isEmpty()) + assertTrue(change.removedLabels.isEmpty()) + assertTrue(diff.propertySignatureChanges.isEmpty(), "nothing was reshaped, only added") + } + + @Test + fun `a dropped property is reported as modified, with its full signature`() { + val old = personWith( + ValuePropertyDefinition("age", type = "integer"), + ValuePropertyDefinition("nickname", type = "string"), + ) + val new = personWith(ValuePropertyDefinition("age", type = "integer")) + val change = differ.diff(old, new).modifiedEntityTypes.single() + assertEquals(setOf("nickname"), change.removedPropertyNames) + assertEquals( + setOf(PropertySignature("nickname", PropertySignature.Kind.VALUE, "string", Cardinality.ONE)), + change.removedProperties, + ) + assertTrue(change.addedProperties.isEmpty()) + } + + @Test + fun `simultaneous label and property change are captured in one modified entry`() { + val old = DataDictionary.fromDomainTypes( + "test", + listOf( + DynamicType( + name = "Person", + ownProperties = listOf(ValuePropertyDefinition("age")), + parents = listOf(DynamicType(name = "Agent")), + ), + ), + ) + val new = DataDictionary.fromDomainTypes( + "test", + listOf( + DynamicType( + name = "Person", + ownProperties = listOf(ValuePropertyDefinition("email")), + parents = listOf(DynamicType(name = "Actor")), + ), + ), + ) + val change = differ.diff(old, new).modifiedEntityTypes.single() + assertEquals("Person", change.typeName) + assertEquals(setOf("Actor"), change.addedLabels) + assertEquals(setOf("Agent"), change.removedLabels) + assertEquals(setOf("email"), change.addedPropertyNames) + assertEquals(setOf("age"), change.removedPropertyNames) + } + } + + /** + * The reason this module stamps property *signatures* rather than property names. Every case + * below leaves both schemas with exactly the same type names and the same property names, so a + * name-only diff would report nothing at all — while what the graph can actually hold has + * changed. + */ + @Nested + inner class PropertySignatureChanges { + + @Test + fun `narrowing a property type is reported as a signature change, not an add and a remove`() { + val old = personWith(ValuePropertyDefinition("age", type = "string")) + val new = personWith(ValuePropertyDefinition("age", type = "integer")) + val diff = differ.diff(old, new) + + assertFalse(diff.isEmpty, "string -> integer is a real change to what the graph can hold") + val change = diff.propertySignatureChanges.single() + assertEquals("Person", change.typeName) + assertEquals("age", change.propertyName) + assertEquals("string", change.before.type) + assertEquals("integer", change.after.type) + assertTrue(change.typeChanged) + assertFalse(change.cardinalityChanged) + assertFalse(change.kindChanged) + + // Matched by name, so it must not double-report as an add plus a remove. + assertTrue( + diff.modifiedEntityTypes.isEmpty(), + "a reshaped property is one change, not an add and a remove: ${diff.modifiedEntityTypes}", + ) + assertEquals(1, diff.changes.size, "exactly one entry: ${diff.changes}") + } + + @Test + fun `changing cardinality is reported as a signature change`() { + val old = personWith(ValuePropertyDefinition("nickname", type = "string", cardinality = Cardinality.ONE)) + val new = personWith(ValuePropertyDefinition("nickname", type = "string", cardinality = Cardinality.LIST)) + val diff = differ.diff(old, new) + + val change = diff.propertySignatureChanges.single() + assertEquals("nickname", change.propertyName) + assertEquals(Cardinality.ONE, change.before.cardinality) + assertEquals(Cardinality.LIST, change.after.cardinality) + assertTrue(change.cardinalityChanged) + assertFalse(change.typeChanged) + assertTrue(diff.modifiedEntityTypes.isEmpty()) + } + + @Test + fun `turning a value into a reference is reported as a kind change`() { + val old = stampOf( + "Person" to setOf( + PropertySignature("worksAt", PropertySignature.Kind.VALUE, "string", Cardinality.ONE), + ), + ) + val new = stampOf( + "Person" to setOf( + PropertySignature("worksAt", PropertySignature.Kind.REFERENCE, "Company", Cardinality.ONE), + ), + ) + val change = differ.diff(old, new).propertySignatureChanges.single() + assertEquals("worksAt", change.propertyName) + assertTrue(change.kindChanged, "VALUE -> REFERENCE is a kind change") + assertTrue(change.typeChanged, "and the target moved from 'string' to 'Company'") + assertFalse(change.cardinalityChanged) + } + + @Test + fun `one type change and one cardinality change on the same type both appear, in name order`() { + val old = personWith( + ValuePropertyDefinition("age", type = "string"), + ValuePropertyDefinition("nickname", type = "string", cardinality = Cardinality.ONE), + ) + val new = personWith( + ValuePropertyDefinition("age", type = "integer"), + ValuePropertyDefinition("nickname", type = "string", cardinality = Cardinality.SET), + ) + val changes = differ.diff(old, new).propertySignatureChanges + assertEquals(listOf("age", "nickname"), changes.map { it.propertyName }) + assertTrue(changes[0].typeChanged) + assertTrue(changes[1].cardinalityChanged) + } + + @Test + fun `a signature change on one type leaves an untouched type out of the diff`() { + val old = stampOf( + "Person" to setOf(PropertySignature("age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE)), + "Company" to setOf(PropertySignature("name", PropertySignature.Kind.VALUE, "string", Cardinality.ONE)), + ) + val new = stampOf( + "Person" to setOf(PropertySignature("age", PropertySignature.Kind.VALUE, "integer", Cardinality.ONE)), + "Company" to setOf(PropertySignature("name", PropertySignature.Kind.VALUE, "string", Cardinality.ONE)), + ) + val diff = differ.diff(old, new) + assertEquals(setOf("Person"), diff.touchedEntityTypes) + } + + @Test + fun `a signature change moves the content hash, so hash equality and an empty diff agree`() { + val old = personWith(ValuePropertyDefinition("age", type = "string")) + val new = personWith(ValuePropertyDefinition("age", type = "integer")) + assertFalse(MetamodelVersion.from(old).hasSameContentAs(MetamodelVersion.from(new))) + assertFalse(differ.diff(old, new).isEmpty) + } + + /** + * A `DataDictionary` may hold two same-named domain types, whose properties get unioned into + * one stamp — so a single property name can carry two signatures at once. There is no + * before/after pair to report then, and guessing one would be a fiction, so the differing + * signatures come back as added and removed instead. + */ + @Test + fun `a property name carrying two signatures falls back to added and removed`() { + val old = stampOf( + "Person" to setOf( + PropertySignature("age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE), + PropertySignature("age", PropertySignature.Kind.VALUE, "integer", Cardinality.ONE), + ), + ) + val new = stampOf( + "Person" to setOf( + PropertySignature("age", PropertySignature.Kind.VALUE, "integer", Cardinality.ONE), + ), + ) + val diff = differ.diff(old, new) + assertTrue(diff.propertySignatureChanges.isEmpty(), "no honest pairing exists here") + val change = diff.modifiedEntityTypes.single() + assertEquals( + setOf(PropertySignature("age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE)), + change.removedProperties, + ) + assertTrue(change.addedProperties.isEmpty()) + } + + @Test + fun `signature changes on a type that was itself removed are not reported`() { + val old = stampOf( + "Person" to setOf(PropertySignature("age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE)), + ) + val new = stampOf() + val diff = differ.diff(old, new) + assertEquals(setOf("Person"), diff.removedEntityTypes) + assertTrue(diff.propertySignatureChanges.isEmpty(), "the whole type went; the property isn't news") + } + } + + @Nested + inner class VersionOverload { + + @Test + fun `MetamodelVersion overload produces same result as DataDictionary overload`() { + val old = schemaWith(typeNames = arrayOf("Person", "Removed")) + val new = schemaWith(typeNames = arrayOf("Person", "Added")) + val fromVersions = differ.diff(MetamodelVersion.from(old), MetamodelVersion.from(new)) + val fromDicts = differ.diff(old, new) + assertEquals(fromVersions.removedEntityTypes, fromDicts.removedEntityTypes) + assertEquals(fromVersions.addedEntityTypes, fromDicts.addedEntityTypes) + } + } + + @Nested + inner class DelimiterSafetyInNames { + + /** + * A label name that contains a comma — possible when names come from LLM extraction. + * The diff compares label sets directly, so punctuation in a name is just a character; + * the change is still detected and the label comes back as one entry, not split. + */ + @Test + fun `label containing a comma is treated as a single label`() { + val commaParent = DynamicType(name = "foo,bar") + val old = DataDictionary.fromDomainTypes( + "test", + listOf(DynamicType(name = "Person", parents = listOf(commaParent))), + ) + val new = DataDictionary.fromDomainTypes("test", listOf(DynamicType(name = "Person"))) + val diff = differ.diff(old, new) + + val change = diff.modifiedEntityTypes.single() + assertEquals(1, change.removedLabels.size) + assertTrue(change.removedLabels.single().contains(",")) + assertTrue(change.addedLabels.isEmpty()) + } + + /** + * Property names can contain spaces when they come from free-text / LLM extraction. + * The two sets `{"a", "b c"}` and `{"a b", "c"}` are genuinely different, yet both + * collapse to the string "a b c" if you compare a space-joined projection instead of + * the sets themselves. A real property change must never be hidden by that collision. + */ + @Test + fun `property sets that collide under a space delimiter are still reported as modified`() { + val old = personWith(ValuePropertyDefinition("a"), ValuePropertyDefinition("b c")) + val new = personWith(ValuePropertyDefinition("a b"), ValuePropertyDefinition("c")) + val diff = differ.diff(old, new) + + assertFalse(diff.isEmpty, "a real property change must not be hidden by a delimiter collision") + val change = diff.modifiedEntityTypes.single() + assertEquals("Person", change.typeName) + assertEquals(setOf("a b", "c"), change.addedPropertyNames) + assertEquals(setOf("a", "b c"), change.removedPropertyNames) + } + } + + @Nested + inner class DeclaredVsObserved { + + private fun declared(vararg typeNames: String, relationshipTypeNames: Set = emptySet()): DeclaredSchema = + DeclaredSchema( + version = MetamodelVersion( + schemaName = "test", + entityTypeNames = typeNames.toList(), + entityTypeLabels = typeNames.associateWith { setOf(it) }, + entityTypeProperties = typeNames.associateWith { emptySet() }, + relationshipNames = relationshipTypeNames.map { "From-[$it]->To" }, + ), + relationshipTypeNames = relationshipTypeNames, + ) + + private fun observed( + entityTypeNames: Set, + relationshipTypeNames: Set = emptySet(), + ): ObservedSchema = ObservedSchema( + entityTypeNames = entityTypeNames, + relationshipTypeNames = relationshipTypeNames, + capturedAt = Instant.parse("2026-01-01T00:00:00Z"), + ) + + @Test + fun `an observed type with no declaration is reported as drift`() { + val diff = declaredObservedDiffer.diffAgainstObserved( + declared("Person"), + observed(entityTypeNames = setOf("Person", "GhostIntegrationType")), + ) + assertTrue(diff.hasDrift) + assertEquals(setOf("GhostIntegrationType"), diff.driftedEntityTypes) + } + + @Test + fun `a declared type with zero observed instances is not drift`() { + val diff = declaredObservedDiffer.diffAgainstObserved( + declared("Person", "NeverSeenYet"), + observed(entityTypeNames = setOf("Person")), + ) + assertFalse(diff.hasDrift, "absence of a declared type must not count as drift") + assertTrue(diff.driftedEntityTypes.isEmpty()) + assertEquals(setOf("NeverSeenYet"), diff.unobservedEntityTypes) + } + + @Test + fun `matching declared and observed types produce no drift and nothing unobserved`() { + val diff = declaredObservedDiffer.diffAgainstObserved( + declared("Person", "Company"), + observed(entityTypeNames = setOf("Person", "Company")), + ) + assertFalse(diff.hasDrift) + assertTrue(diff.driftedEntityTypes.isEmpty()) + assertTrue(diff.unobservedEntityTypes.isEmpty()) + } + + @Test + fun `drift and unobserved can both be present at once and don't overlap`() { + val diff = declaredObservedDiffer.diffAgainstObserved( + declared("Person", "NeverSeenYet"), + observed(entityTypeNames = setOf("Person", "GhostIntegrationType")), + ) + assertEquals(setOf("GhostIntegrationType"), diff.driftedEntityTypes) + assertEquals(setOf("NeverSeenYet"), diff.unobservedEntityTypes) + } + + @Test + fun `relationship drift is compared on the bare relationship type name, not the full descriptor`() { + // Declared descriptors carry from/to node types; an observed graph only reports bare + // relationship type names (e.g. via a `db.relationshipTypes()`-style query). + val diff = declaredObservedDiffer.diffAgainstObserved( + declared("Person", "Company", relationshipTypeNames = setOf("WORKS_AT")), + observed(entityTypeNames = setOf("Person", "Company"), relationshipTypeNames = setOf("WORKS_AT")), + ) + assertFalse(diff.hasDrift) + assertTrue(diff.driftedRelationshipTypes.isEmpty()) + assertTrue(diff.unobservedRelationshipTypes.isEmpty()) + } + + @Test + fun `an observed relationship type with no declaration is reported as drift`() { + val diff = declaredObservedDiffer.diffAgainstObserved( + declared("Person"), + observed(entityTypeNames = setOf("Person"), relationshipTypeNames = setOf("GHOST_REL")), + ) + assertTrue(diff.hasDrift) + assertEquals(setOf("GHOST_REL"), diff.driftedRelationshipTypes) + } + + @Test + fun `diff carries the declaration and observed schema unchanged`() { + val declaration = declared("Person") + val observedSchema = observed(entityTypeNames = setOf("Person")) + val diff = declaredObservedDiffer.diffAgainstObserved(declaration, observedSchema) + assertEquals(declaration, diff.declared) + assertEquals(declaration.version, diff.declaredVersion) + assertEquals(observedSchema, diff.observedSchema) + } + + /** + * The observed side has names and nothing else, so a declared property's shape is never + * part of this comparison. Two declarations differing only in a property signature produce + * an identical declared/observed diff — the graph simply cannot answer that question, and + * this comparison doesn't pretend it can. + */ + @Test + fun `a property signature difference is invisible to the declared-observed comparison`() { + val observedSchema = observed(entityTypeNames = setOf("Person")) + val asString = DeclaredSchema( + version = MetamodelVersion( + schemaName = "test", + entityTypeNames = listOf("Person"), + entityTypeLabels = mapOf("Person" to setOf("Person")), + entityTypeProperties = mapOf( + "Person" to setOf( + PropertySignature("age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE), + ), + ), + relationshipNames = emptyList(), + ), + relationshipTypeNames = emptySet(), + ) + val asInteger = DeclaredSchema( + version = MetamodelVersion( + schemaName = "test", + entityTypeNames = listOf("Person"), + entityTypeLabels = mapOf("Person" to setOf("Person")), + entityTypeProperties = mapOf( + "Person" to setOf( + PropertySignature("age", PropertySignature.Kind.VALUE, "integer", Cardinality.ONE), + ), + ), + relationshipNames = emptyList(), + ), + relationshipTypeNames = emptySet(), + ) + + val stringDiff = declaredObservedDiffer.diffAgainstObserved(asString, observedSchema) + val integerDiff = declaredObservedDiffer.diffAgainstObserved(asInteger, observedSchema) + + assertFalse(stringDiff.hasDrift) + assertFalse(integerDiff.hasDrift) + assertEquals(stringDiff.driftedEntityTypes, integerDiff.driftedEntityTypes) + assertEquals(stringDiff.unobservedEntityTypes, integerDiff.unobservedEntityTypes) + // Declared-vs-declared is where that difference does show up. + assertEquals(1, differ.diff(asString.version, asInteger.version).propertySignatureChanges.size) + } + + /** + * A declared type carries every label in its hierarchy, and a graph reports all of them: + * declaring `Person` with parent `Agent` puts both labels on every Person node. Comparing + * observed labels against type names alone would call `Agent` undeclared drift on a schema + * nobody had touched. + */ + @Test + fun `an inherited label on a declared type is not drift`() { + val personIsAnAgent = DeclaredSchema.from( + DataDictionary.fromDomainTypes( + "test", + listOf(DynamicType(name = "Person", parents = listOf(DynamicType(name = "Agent")))), + ), + ) + val diff = declaredObservedDiffer.diffAgainstObserved( + personIsAnAgent, + observed(entityTypeNames = setOf("Person", "Agent")), + ) + assertFalse(diff.hasDrift, "an inherited label is declared: ${diff.driftedEntityTypes}") + assertTrue(diff.driftedEntityTypes.isEmpty()) + } + + @Test + fun `a label matching no declared type or label is still drift`() { + val personIsAnAgent = DeclaredSchema.from( + DataDictionary.fromDomainTypes( + "test", + listOf(DynamicType(name = "Person", parents = listOf(DynamicType(name = "Agent")))), + ), + ) + val diff = declaredObservedDiffer.diffAgainstObserved( + personIsAnAgent, + observed(entityTypeNames = setOf("Person", "Agent", "GhostIntegrationType")), + ) + assertTrue(diff.hasDrift) + assertEquals(setOf("GhostIntegrationType"), diff.driftedEntityTypes) + } + + /** + * The other direction stays on type names. "Declared but with no data" is a statement about + * types, so a parent label must not be listed as an unobserved type of its own. + */ + @Test + fun `an inherited label is not reported as an unobserved type`() { + val personIsAnAgent = DeclaredSchema.from( + DataDictionary.fromDomainTypes( + "test", + listOf(DynamicType(name = "Person", parents = listOf(DynamicType(name = "Agent")))), + ), + ) + val diff = declaredObservedDiffer.diffAgainstObserved( + personIsAnAgent, + observed(entityTypeNames = emptySet()), + ) + assertEquals(setOf("Person"), diff.unobservedEntityTypes) + } + + /** + * A relationship name that embeds a `-[...]->`-shaped substring — the exact case a + * regex-based implementation got wrong, where a greedy `.*-\[(.+)]->.*` backtracks to the + * *last* such substring and parses `"A-[X]->B"` down to `"X"`. Since these names are + * free-text and LLM-derived, that shape is realistic rather than contrived. Bare names + * travel with the declaration and are never recovered from a descriptor, so the match holds + * whatever the name looks like. + */ + @Test + fun `a relationship name shaped like a full descriptor still matches by its bare name`() { + val trickyRelName = "A-[X]->B" + val diff = declaredObservedDiffer.diffAgainstObserved( + declared("Foo", "Bar", relationshipTypeNames = setOf(trickyRelName)), + observed(entityTypeNames = setOf("Foo", "Bar"), relationshipTypeNames = setOf(trickyRelName)), + ) + assertFalse(diff.hasDrift, "the declared relationship must match the observed one by its full bare name") + assertTrue(diff.driftedRelationshipTypes.isEmpty()) + assertTrue(diff.unobservedRelationshipTypes.isEmpty()) + } + + /** + * The inverse check: observing the substring a greedy parse would have wrongly extracted + * must not be treated as a match. It has to show up as both drift and unobserved. + */ + @Test + fun `an observed relationship equal to a substring of a tricky declared name is not a match`() { + val trickyRelName = "A-[X]->B" + val diff = declaredObservedDiffer.diffAgainstObserved( + declared("Foo", "Bar", relationshipTypeNames = setOf(trickyRelName)), + observed(entityTypeNames = setOf("Foo", "Bar"), relationshipTypeNames = setOf("X")), + ) + assertTrue(diff.hasDrift, "observed 'X' must not be confused with the declared '$trickyRelName'") + assertEquals(setOf("X"), diff.driftedRelationshipTypes) + assertEquals(setOf(trickyRelName), diff.unobservedRelationshipTypes) + } + } + + /** + * A diff is a result, and results don't change. Mirrors `MetamodelVersionTest.Immutability`: + * Kotlin's read-only collection types are a compile-time promise only — a Java caller sees plain + * `java.util` collections through the getters — so these have to refuse at runtime. + */ + @Nested + inner class Immutability { + + @Test + fun `the change list a diff hands back cannot be mutated`() { + val diff = differ.diff( + personWith(ValuePropertyDefinition("age", type = "string")), + personWith(ValuePropertyDefinition("age", type = "integer"), ValuePropertyDefinition("email")), + ) + assertFalse(diff.isEmpty) + + @Suppress("UNCHECKED_CAST") + assertThrows { + (diff.changes as MutableList).clear() + } + } + + @Test + fun `mutating the change list passed in does not change the diff`() { + val changes = mutableListOf(MetamodelChange.EntityTypeAdded("Person")) + val version = stampOf() + val diff = MetamodelDiff(fromVersion = version, toVersion = version, changes = changes) + + changes += MetamodelChange.EntityTypeAdded("Sneaky") + changes.clear() + + assertEquals(listOf(MetamodelChange.EntityTypeAdded("Person")), diff.changes) + } + + @Test + fun `the collections inside a modified-type change cannot be mutated`() { + val change = differ.diff( + personWith(ValuePropertyDefinition("age")), + personWith(ValuePropertyDefinition("email")), + ).modifiedEntityTypes.single() + + @Suppress("UNCHECKED_CAST") + assertThrows { + (change.addedProperties as MutableSet).clear() + } + + @Suppress("UNCHECKED_CAST") + assertThrows { + (change.removedProperties as MutableSet).clear() + } + + @Suppress("UNCHECKED_CAST") + assertThrows { + (change.addedLabels as MutableSet).add("Sneaky") + } + + @Suppress("UNCHECKED_CAST") + assertThrows { + (change.removedLabels as MutableSet).add("Sneaky") + } + } + + @Test + fun `the sets a declared-observed diff hands back cannot be mutated`() { + val diff = StructuralMetamodelDiffer().diffAgainstObserved( + DeclaredSchema.from(DataDictionary.fromDomainTypes("test", listOf(DynamicType(name = "Person")))), + ObservedSchema( + entityTypeNames = setOf("Ghost"), + relationshipTypeNames = setOf("GHOST_REL"), + capturedAt = Instant.parse("2026-01-01T00:00:00Z"), + ), + ) + + @Suppress("UNCHECKED_CAST") + assertThrows { + (diff.driftedEntityTypes as MutableSet).clear() + } + + @Suppress("UNCHECKED_CAST") + assertThrows { + (diff.driftedRelationshipTypes as MutableSet).clear() + } + + @Suppress("UNCHECKED_CAST") + assertThrows { + (diff.unobservedEntityTypes as MutableSet).clear() + } + + @Suppress("UNCHECKED_CAST") + assertThrows { + (diff.unobservedRelationshipTypes as MutableSet).add("Sneaky") + } + } + + @Test + fun `an observed snapshot cannot be mutated, and mutating what built it changes nothing`() { + // A backend typically fills a mutable set as it walks query results, then hands it over. + val names = mutableSetOf("Person") + val relationships = mutableSetOf("WORKS_AT") + val snapshot = ObservedSchema(names, relationships, Instant.parse("2026-01-01T00:00:00Z")) + + names += "Sneaky" + relationships.clear() + + assertEquals(setOf("Person"), snapshot.entityTypeNames) + assertEquals(setOf("WORKS_AT"), snapshot.relationshipTypeNames) + + @Suppress("UNCHECKED_CAST") + assertThrows { + (snapshot.entityTypeNames as MutableSet).add("Sneaky") + } + + @Suppress("UNCHECKED_CAST") + assertThrows { + (snapshot.relationshipTypeNames as MutableSet).clear() + } + } + } + + companion object { + + /** A one-type dictionary whose `Person` carries exactly the given properties. */ + private fun personWith(vararg properties: ValuePropertyDefinition): DataDictionary = + DataDictionary.fromDomainTypes( + "test", + listOf(DynamicType(name = "Person", ownProperties = properties.toList())), + ) + + /** + * A stamp built directly from property signatures, for shapes a `DataDictionary` makes + * awkward to express — a `VALUE` and a `REFERENCE` under one name, or two signatures for + * the same property name. + */ + private fun stampOf(vararg types: Pair>): MetamodelVersion = + MetamodelVersion( + schemaName = "test", + entityTypeNames = types.map { it.first }, + entityTypeLabels = types.associate { it.first to setOf(it.first) }, + entityTypeProperties = types.toMap(), + relationshipNames = emptyList(), + ) + } +} diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 01c0a4d8..64ef24c7 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -73,6 +73,10 @@ you need. - [metamodel-versioning.md](metamodel-versioning.md) — stamping a schema with a content hash so it can be compared later: per-type governance, the declared-schema opt-in seam, and the version store's accumulating history. +- [metamodel-diff.md](metamodel-diff.md) — comparing stamps: the change taxonomy, including + property-signature changes where a property keeps its name but changes type or cardinality, + and the two comparisons it supports, declared against declared and declared against a live + graph. ## Modules @@ -86,6 +90,6 @@ dependency map. Quick pointer to where each is documented: | `dice-storage-autoconfigure` | [durable-storage.md](durable-storage.md) | | `dice-ingestion` | [ingestion.md](ingestion.md) | | `dice-report` | [report.md](report.md) | -| `dice-metamodel` | [metamodel-versioning.md](metamodel-versioning.md) | +| `dice-metamodel` | [metamodel-versioning.md](metamodel-versioning.md), [metamodel-diff.md](metamodel-diff.md) | | `dice-integration-tests` | not separately documented — exercises the above end-to-end | diff --git a/docs/design/metamodel-diff.md b/docs/design/metamodel-diff.md new file mode 100644 index 00000000..d465b91b --- /dev/null +++ b/docs/design/metamodel-diff.md @@ -0,0 +1,174 @@ +# Metamodel diffing: what moved, and against what + +A version stamp answers one question: *is this the same schema as last time?* That's a yes or a no, +and it's enough to notice something happened, but not enough to do anything about it. Knowing that +`Person` lost a property, or that a live graph is full of a type nobody declares any more, needs a +comparison that says **what** moved. + +That's what this note covers. Two comparisons live in `dice-metamodel`, and they're separate on +purpose because they answer different questions. + +```mermaid +flowchart LR + v1["MetamodelVersion
(older stamp)"] + v2["MetamodelVersion
(newer stamp)"] + decl["DeclaredSchema
(stamp + bare rel names)"] + obs["ObservedSchema
(what the graph holds)"] + d1["MetamodelDiffer"] + d2["DeclaredObservedDiffer"] + changes["MetamodelDiff
ordered change list"] + drift["DeclaredObservedDiff
drift vs unobserved"] + + v1 --> d1 + v2 --> d1 + d1 --> changes + decl --> d2 + obs --> d2 + d2 --> drift +``` + +**Declared vs. declared** (`MetamodelDiffer`) compares two stamps — two things somebody decided. It +is symmetric: every difference is a change, and the result is an ordered list of them. + +**Declared vs. observed** (`DeclaredObservedDiffer`) compares a declaration against a snapshot of a +live graph. It is asymmetric, because one side is a decision and the other is reality, and the two +disagree in two very different ways. + +Both are contracts plus one shipped implementation, `StructuralMetamodelDiffer`, which does both. +No LLM, no heuristics, no database: it walks the same fields the content hash is built from, which +is what makes "empty diff" and "same hash" mean the same thing. + +## The change taxonomy + +`MetamodelChange` is a sealed interface, so a `when` over it is exhaustive and the compiler speaks +up when a new kind lands. That matters more than usual here, because the next slice decides which +changes are lossy enough to quarantine data over — a change kind nobody noticed would be silently +treated as harmless. + +| Kind | What it means | +| --- | --- | +| `EntityTypeAdded` / `EntityTypeRemoved` | A type appeared or disappeared. | +| `EntityTypeModified` | A type present in both versions gained or lost labels, or gained or lost whole properties (matched by name), with the full signature of each. | +| `PropertySignatureChanged` | A property kept its name but changed shape: its type, its cardinality, or whether it holds a value or points at another type. Carries `before` and `after`. | +| `RelationshipAdded` / `RelationshipRemoved` | An allowed relationship descriptor appeared or disappeared. | + +The kinds don't overlap. Each difference is reported exactly once, by whichever kind describes it +most precisely. + +`PropertySignatureChanged` is the one worth dwelling on, because it is the whole reason the stamp +carries signatures rather than property names. `age` turning from a string into an integer, or a +single `worksAt` becoming a list of them, is a real change to what the graph can hold — data +extracted under the old shape may simply not fit the new one. Under a name-only taxonomy both +schemas have a property called `age`, nothing moved, and nothing gets reported. So the differ +matches properties **by name** and pairs the two signatures: one change with a before and an after, +rather than a deletion sitting next to an unrelated-looking addition that a caller has to pair back +up. `typeChanged`, `cardinalityChanged` and `kindChanged` say which part moved. + +There is one honest gap. A `DataDictionary` may hold two same-named domain types whose properties +get unioned into a single stamp, so one property name can carry two signatures at once. There is no +single before and after to pair then, and inventing one would be a fiction, so the differing +signatures come back as `addedProperties` and `removedProperties` on `EntityTypeModified` instead. +Rare, and better than a guessed pairing. + +What the diff deliberately does *not* do is judge. It says `age` went from `string` to `integer`; it +does not say that this strands existing values while `integer` → `string` wouldn't. That's a policy +question, and it belongs to the quarantine slice. + +Output is canonical. Type names, property names and signatures all come out sorted, so the same two +stamps always produce the same list in the same order — the sets inside a `MetamodelVersion` are +JVM-immutable copies whose iteration order is deliberately unspecified and varies between runs, so +anything leaving the differ is sorted first. And sets are compared as sets, never as a +delimiter-joined projection: these names come from free text and LLM extraction and routinely +contain commas and spaces, so `{"a", "b c"}` and `{"a b", "c"}` must not collapse into the same +thing. + +## Declared vs. observed, and the asymmetry + +`ObservedSchema` is what a live graph actually holds: entity labels, relationship types, and when +the snapshot was taken. A storage layer builds one by querying its database; `ObservedSchemaSource` +is the SPI it implements, and this module has no graph driver, so it can't provide a default. Tests +hand in a canned snapshot and never touch a database. `observe(contextId)` scopes the snapshot to a +single context; `observe()` takes the whole graph. + +Comparing that against a declaration gives two clearly separated buckets, not a change list: + +- **Drift** — observed in the graph, never declared. This is the actionable case. Concretely, data + is sitting in the graph whose declaring integration has since been switched off, or never + registered one, so nothing can tell that data apart as valid or explain its shape. +- **Unobserved** — declared, but with zero instances in the graph right now. Purely informational. A + declared type with no data yet is a completely normal state, not a problem. + +Folding these into one symmetric change list would force every caller to sift it to tell an +emergency from a shrug. + +**A declared label counts as declared.** What a graph reports is labels, and a type carries every +label in its hierarchy — declare `Person` with parent `Agent` and every Person node comes back +carrying both. So the declared side of the drift check is every entity type name *plus* every label +those types declare; comparing against type names alone would report `Agent` as undeclared drift on +a schema nobody had touched. The unobserved direction stays on type names, because "declared but +with no data" is a statement about types, and listing a parent label as an unobserved type would be +noise about something that was never a type in its own right. + +**The observed side is names only, and that is a real limit, not an oversight.** A graph can tell +you which labels and relationship types exist in it. It cannot tell you what a property was +*declared* to be: two nodes with the same label can carry different property sets, a property can be +absent on most of them, and a value that looks like an integer today may be a string tomorrow. +Anything richer than a name would be a sample, not a schema — and a diff built on a sample reads as +authoritative when it isn't. So `DeclaredObservedDiff` stops at type and relationship names and says +nothing about property shape. Signatures are compared where both sides genuinely have them: +declared against declared. + +One more asymmetry, smaller but sharp. `MetamodelVersion.relationshipNames` holds rendered +`From-[name]->To` descriptors; a graph only knows the bare type name, because a +`db.relationshipTypes()`-style query knows the type, not which node types an instance actually +connected. So the comparison happens on the bare name — and that name is never recovered by parsing +a descriptor. Relationship names are free text, so a name can itself contain a `-[...]->`-shaped +substring, and a greedy parse of `Foo-[A-[X]->B]->Bar` picks `X` rather than `A-[X]->B`. That's why +`DeclaredObservedDiffer` takes a whole `DeclaredSchema`: the declaration carries the bare names +alongside the stamp, un-rendered, built under the same governance rule so the two halves can't drift +apart. + +## Using it + +```kotlin +val differ = StructuralMetamodelDiffer() + +// Declared vs. declared: has the schema moved since the stamp we recorded? +val previous = versionStore.latestVersion("my-schema") +val current = DeclaredSchema.from(dataDictionary, governed) +val diff = previous?.let { differ.diff(it, current.version) } + +diff?.changes?.forEach { change -> + when (change) { + is MetamodelChange.PropertySignatureChanged -> + log.warn("{}.{}: {} -> {}", change.typeName, change.propertyName, change.before, change.after) + else -> log.info("{}", change) + } +} + +// Declared vs. observed: is anything in the graph undeclared? +val observed = observedSchemaSource.observe() +val drift = differ.diffAgainstObserved(current, observed) +if (drift.hasDrift) { + log.warn("undeclared in graph: {} {}", drift.driftedEntityTypes, drift.driftedRelationshipTypes) +} +``` + +`MetamodelDiffer` also has a convenience overload taking two `DataDictionary` instances. It stamps +both with everything governed, the closed-world reading — fine for a domain that's closed-world +throughout, wrong for one that isn't, where exploratory types nobody committed to would show up as +schema changes. If governance is partial, stamp with the `GovernedTypeSelector` first (or take both +stamps off `DeclaredSchema.from(...)`) and use the `MetamodelVersion` overload. + +`StructuralMetamodelDiffer` is stateless and thread-safe; one shared instance is fine. There is no +Spring wiring in this module — it's an ordinary constructor call until the autoconfigure slice +arrives. + +## What comes next + +Diffing is the comparison half of the middle tier described in +[metamodel-versioning.md](metamodel-versioning.md#the-tiers-ahead). The other half is *doing* +something with the result: a drift check that sequences observe → declare → diff → record, a store +for the reports it produces so a drift you saw last week is still answerable, and then quarantine — +marking propositions stale when a change actually strands them, never deleting. Those land in the +next slices, on top of these contracts. diff --git a/docs/design/metamodel-versioning.md b/docs/design/metamodel-versioning.md index 48f8c697..2281e245 100644 --- a/docs/design/metamodel-versioning.md +++ b/docs/design/metamodel-versioning.md @@ -10,7 +10,8 @@ integration that used to declare a type can be switched off while its data stays This note covers turning a mutable `DataDictionary` into an immutable stamp, deciding which types the stamp is about, and keeping the stamps so history is answerable. Comparing two stamps, or a -stamp against a live graph, comes later — see [the tiers ahead](#the-tiers-ahead). +stamp against a live graph, is [metamodel-diff.md](metamodel-diff.md); [the tiers +ahead](#the-tiers-ahead) says how the two fit together. The types live in `dice-metamodel`, a small pure-JVM module: `MetamodelVersion`, `GovernedTypeSelector`, `DeclaredSchema`/`DeclaredSchemaSource`, `SchemaAliases`, and the @@ -412,8 +413,11 @@ Versioning is the first of three escalating tiers, shipped in that order. **Stamp and observe** is this slice: identity and history. -**Detect and report** comes next: comparing two declared stamps, comparing a declaration against -what a live graph contains, and recording the result. +**Detect and report** is underway. The comparison half has landed, in +[metamodel-diff.md](metamodel-diff.md): `MetamodelDiffer` compares two declared stamps and reports +a typed change list, and `DeclaredObservedDiffer` compares a declaration against an +`ObservedSchema` snapshot of a live graph. Still to come on this tier: a drift check that sequences +observe, declare, diff, and a store for the reports it produces. **Quarantine** is last: acting on a lossy change by marking affected propositions stale rather than deleting them. From 0143bb3e5bacc9b4b558ece0a8b62e82e17fb656 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:49:00 -0400 Subject: [PATCH 2/8] docs(metamodel): voice pass on diff docs and KDoc Comment and doc text only; no code change. --- CHANGELOG.md | 26 +-- .../embabel/dice/metamodel/MetamodelDiff.kt | 135 +++++++------- .../embabel/dice/metamodel/MetamodelDiffer.kt | 39 ++-- .../embabel/dice/metamodel/ObservedSchema.kt | 41 ++--- .../dice/metamodel/ObservedSchemaSource.kt | 28 +-- .../support/StructuralMetamodelDiffer.kt | 56 +++--- .../dice/metamodel/MetamodelDifferTest.kt | 57 +++--- docs/design/metamodel-diff.md | 173 +++++++++--------- 8 files changed, 266 insertions(+), 289 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b04a1b12..834c113d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,18 +77,18 @@ and the consumer PRs that deliver it). property that existed before keeps its name, meaning, and encoding, and the two new alias fields are absent when nothing declares them. - Schema diffing contracts in `dice-metamodel`: `MetamodelDiffer` compares two declared - stamps and returns a `MetamodelDiff` — an ordered, canonically sorted list of sealed - `MetamodelChange` entries. The taxonomy covers property *signatures*, not just names: - alongside `EntityTypeAdded`/`Removed`/`Modified` and `RelationshipAdded`/`Removed`, a + stamps and returns a `MetamodelDiff`, an ordered, canonically sorted list of sealed + `MetamodelChange` entries. The taxonomy covers property *signatures* as well as names. + Alongside `EntityTypeAdded`/`Removed`/`Modified` and `RelationshipAdded`/`Removed`, a `PropertySignatureChanged` pairs `before` and `after` for a property that kept its name - but changed value type, cardinality, or value-vs-reference kind — the case a name-only - diff reports as nothing at all. `DeclaredObservedDiffer` answers the other question, - comparing a `DeclaredSchema` against an `ObservedSchema` snapshot of a live graph and - separating drift (observed but undeclared, actionable) from unobserved (declared but - empty, normal). That comparison is names-only on the observed side, deliberately: a graph - reports labels and relationship types, never declared property shapes. - `ObservedSchemaSource` is the SPI a storage backend implements later; `ObservedSchema` is - a plain value type, so tests drive the whole comparison from a canned snapshot. - `StructuralMetamodelDiffer` implements both interfaces — deterministic, stateless, no - database and no LLM. No drift runner, no quarantine, no Spring, and no new dependency. + and changed value type, cardinality, or value-vs-reference kind, which a name-only diff + reports as no change at all. `DeclaredObservedDiffer` compares a `DeclaredSchema` against + an `ObservedSchema` snapshot of a live graph, separating drift (observed but undeclared, + actionable) from unobserved (declared but empty, normal). That comparison is names-only + on the observed side: a graph reports labels and relationship types, and cannot report + declared property shapes. `ObservedSchemaSource` is the SPI a storage backend implements + in a later slice; `ObservedSchema` is a plain value type, so tests drive the whole + comparison from a canned snapshot. `StructuralMetamodelDiffer` implements both + interfaces: deterministic, stateless, no database and no LLM. This slice adds no drift + runner, no quarantine, no Spring wiring, and no new dependency. **Compatibility: additive.** New types in an existing module; no existing API touched. diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt index 3c54b5d5..d537abb8 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt @@ -18,31 +18,30 @@ package com.embabel.dice.metamodel import java.util.Objects /** - * Copy into a collection nothing can change afterwards — not even a Java caller mutating what a - * getter handed back, or the builder still holding the original. + * Copy into a set that nothing can change afterwards, including a Java caller mutating what a + * getter returned and a builder that still holds the original. * - * `java.util.Set.copyOf` would also be genuinely immutable, but its iteration order is deliberately - * randomised, and these results are sorted on the way out so a diff reads and logs the same way - * every run. Wrapping a fresh `LinkedHashSet` keeps that order and is just as unmodifiable: the - * mutable copy inside never escapes. + * `java.util.Set.copyOf` is immutable too, but it randomises iteration order. Results here are + * sorted on the way out so a diff reads and logs the same way every run, so this wraps a fresh + * `LinkedHashSet`, which keeps that order and is equally unmodifiable: the mutable copy inside + * never escapes. */ private fun immutableCopy(values: Set): Set = java.util.Collections.unmodifiableSet(LinkedHashSet(values)) -/** The list equivalent: a genuinely immutable copy that keeps its order. */ +/** Same for lists: an immutable copy that keeps its order. */ private fun immutableCopy(values: List): List = java.util.List.copyOf(values) /** * One structural change between two metamodel versions. * - * Sealed, so a caller can handle every change kind in a `when` and have the compiler tell them when - * a new kind arrives. That matters more than usual here: the next slice decides which changes are - * lossy enough to quarantine data over, and a change kind nobody noticed would quietly be treated - * as harmless. + * Sealed, so a caller can handle every change kind in a `when` and the compiler flags them when a + * new kind arrives. The next slice decides which changes are lossy enough to quarantine data over, + * where an unhandled change kind would be treated as harmless. * * The kinds don't overlap. A given difference is reported exactly once, by whichever kind describes - * it most precisely — a property that keeps its name but changes shape is a - * [PropertySignatureChanged], never an add plus a remove. + * it most precisely: a property that keeps its name and changes shape is a + * [PropertySignatureChanged]. */ sealed interface MetamodelChange { @@ -54,8 +53,8 @@ sealed interface MetamodelChange { data class EntityTypeAdded(val typeName: String) : MetamodelChange /** - * An entity type present in the older schema but gone from the newer one. Propositions whose - * entity mentions reference it are the obvious quarantine candidates later. + * An entity type present in the older schema and gone from the newer one. Propositions whose + * entity mentions reference it are the quarantine candidates in a later slice. * * @property typeName The name of the removed entity type. */ @@ -67,15 +66,13 @@ sealed interface MetamodelChange { * * Properties are matched by **name**. A property that appears or disappears lands here, with * its full signature so a reader can see what was gained or lost. A property whose name - * survives but whose shape moved is a [PropertySignatureChanged] instead — it isn't a removal - * and an addition, and reporting it as one would lose the before/after pairing that makes the - * change readable. + * survives while its shape moves is a [PropertySignatureChanged], which pairs the before and + * after signatures in one entry. * * The one exception is a type that declares the same property name more than once with * different signatures, which a `DataDictionary` permits when two same-named domain types are - * merged into one stamp. There is no single before and after to pair up, so the differing - * signatures are reported here as added and removed instead. Rare, and better than inventing a - * pairing. + * merged into one stamp. There is no single before and after to pair up then, so the differing + * signatures are reported here as added and removed. * * @property typeName The entity type name (unchanged). * @property addedLabels Labels present in the new version but not the old. @@ -128,14 +125,14 @@ sealed interface MetamodelChange { * value type narrowed or widened, its cardinality moved, or it turned from a plain value into a * reference to another type (or back). * - * This is the change the name-only taxonomy used to miss entirely. Turning `age` from a string - * into an integer, or a single `worksAt` into a list of them, is a real change to what the - * graph can hold, and data extracted under the old shape may no longer fit the new one — but - * with only names on both sides, nothing moved and nothing was reported. + * Matching properties by name alone misses this case. Turning `age` from a string into an + * integer, or a single `worksAt` into a list of them, changes what the graph can hold, and data + * extracted under the old shape may no longer fit the new one, while both versions still have a + * property called `age`. * - * Whether a particular move is *lossy* is deliberately not decided here. A diff states what - * changed; deciding that string→integer strands existing values while integer→string doesn't is - * a policy question, and it belongs to the quarantine slice that comes next. + * Whether a move is *lossy* is decided elsewhere. A diff states what changed; deciding that + * string→integer strands existing values while integer→string doesn't is a policy question for + * the quarantine slice. * * @property typeName The entity type carrying the property. * @property propertyName The property name, the same on both sides. @@ -187,13 +184,13 @@ sealed interface MetamodelChange { /** * What changed between two declared [MetamodelVersion]s. * - * Both sides are declarations — two things somebody decided — so the comparison is symmetric and - * every difference is a change. Comparing a declaration against a live graph is a different - * question with a different answer shape; that's [DeclaredObservedDiff]. + * Both sides are declarations, so the comparison is symmetric and every difference is a change. + * Comparing a declaration against a live graph has a different answer shape; that is + * [DeclaredObservedDiff]. * - * A diff is **empty** when the two schemas are structurally equivalent. That is exactly the - * condition [MetamodelVersion.hasSameContentAs] reports, and the two agree by construction: the - * differ walks the same fields the content hash is built from. + * A diff is **empty** when the two schemas are structurally equivalent, the same condition + * [MetamodelVersion.hasSameContentAs] reports. The two agree by construction: the differ walks the + * same fields the content hash is built from. * * @property fromVersion The baseline (older) version. * @property toVersion The target (newer) version. @@ -207,18 +204,18 @@ class MetamodelDiff( ) { /** - * Copied into a genuinely immutable list. A diff is a result, and results don't change: a Java - * caller doing `getChanges().clear()`, or the differ still holding the builder list, must not be - * able to reshape one after the fact. Kotlin's `List` is a compile-time promise only, so this - * has to refuse at runtime. Plain class rather than a `data class` for the same reason — a - * generated `copy()` would hand its argument straight to the field and skip the copying. + * Copied into an immutable list, so a Java caller calling `getChanges().clear()`, or the differ + * still holding its builder list, cannot reshape a finished diff. Kotlin's `List` is a + * compile-time promise only, so the refusal has to happen at runtime. This is a plain class for + * the same reason: a generated `data class` `copy()` would hand its argument straight to the + * field and skip the copying. */ val changes: List = immutableCopy(changes) /** `true` when nothing changed. */ val isEmpty: Boolean get() = changes.isEmpty() - /** Names from every [MetamodelChange.EntityTypeRemoved] — the quarantine candidates. */ + /** Names from every [MetamodelChange.EntityTypeRemoved]: the quarantine candidates. */ val removedEntityTypes: Set get() = changes .filterIsInstance() @@ -239,10 +236,10 @@ class MetamodelDiff( get() = changes.filterIsInstance() /** - * Every entity type this diff says something about — added, removed, modified, or holding a + * Every entity type this diff says something about: added, removed, modified, or holding a * property whose signature moved. A reshaped type shows up in both [modifiedEntityTypes] and - * [propertySignatureChanges], so answering "did anything about `Person` change?" otherwise - * means checking several lists and forgetting one. + * [propertySignatureChanges], so answering "did anything about `Person` change?" from those + * lists means checking each one in turn. */ val touchedEntityTypes: Set get() = changes.mapNotNullTo(mutableSetOf()) { change -> @@ -272,35 +269,33 @@ class MetamodelDiff( /** * What a declared schema and a live graph disagree about. * - * A different question from [MetamodelDiff], which compares two declarations to each other. Here - * one side is what was decided and the other is a snapshot of reality, and the two can legitimately - * disagree in either direction — so the result isn't a symmetric change list, it's two clearly - * separated buckets: + * [MetamodelDiff] compares two declarations to each other. Here one side is what was declared and + * the other is a snapshot of reality, and the two can legitimately disagree in either direction, so + * the result is two separate buckets rather than a symmetric change list: * * - **Drift** ([driftedEntityTypes] / [driftedRelationshipTypes]): observed in the graph, never - * declared. This is the actionable case. Concretely it means data is sitting in the graph whose - * declaring integration has since been removed, or never registered one, so nothing here can tell - * that data apart as valid or explain its shape. - * - **Unobserved** ([unobservedEntityTypes] / [unobservedRelationshipTypes]): declared, but with - * zero instances in the graph right now. Purely informational — a declared type with no data yet - * is a completely normal state, not drift. + * declared. This is the actionable case: data is sitting in the graph whose declaring integration + * has since been removed, or never registered one, so nothing here can confirm that data as valid + * or explain its shape. + * - **Unobserved** ([unobservedEntityTypes] / [unobservedRelationshipTypes]): declared, with zero + * instances in the graph right now. Informational; a declared type with no data yet is a normal + * state. * - * **Names only, both ways.** The declaration knows each property's kind, type and cardinality; the - * graph doesn't, and can't be asked. So this comparison stops at type and relationship names, and - * says nothing about whether a declared property's shape matches what the graph stores. Pretending - * otherwise would mean sampling nodes and calling the sample a schema. Property signatures are - * compared where both sides genuinely have them: declared against declared, in [MetamodelDiff]. + * **Names only, both ways.** The declaration knows each property's kind, type and cardinality. The + * graph doesn't, and can't be asked, so anything richer than a name would be a sample of the data + * rather than the schema. This comparison stops at type and relationship names and says nothing + * about whether a declared property's shape matches what the graph stores. Property signatures are + * compared where both sides have them: declared against declared, in [MetamodelDiff]. * - * **A declared label counts as declared.** What a graph reports is labels, and a type usually - * carries more than one: declaring `Person` with parent `Agent` puts both labels on every `Person` - * node. So the declared side of the drift comparison is every entity type name *plus* every label - * those types declare — otherwise an inherited label would be reported as undeclared drift on a - * schema nobody had changed. Going the other way, [unobservedEntityTypes] stays on the type names - * alone: "declared but with no data" is a statement about types, and listing a parent label as an - * unobserved type would be noise about something that was never a type in its own right. + * **A declared label counts as declared.** A graph reports labels, and a type usually carries more + * than one: declaring `Person` with parent `Agent` puts both labels on every `Person` node. So the + * declared side of the drift comparison is every entity type name *plus* every label those types + * declare. Without the labels, an inherited label would be reported as undeclared drift on a schema + * nobody had changed. [unobservedEntityTypes] stays on the type names alone, because "declared but + * with no data" is a statement about types, and a parent label was never a type in its own right. * * @property declared The schema as declared at snapshot time, stamp and bare relationship names. - * @property observedSchema What the live graph actually held at snapshot time. + * @property observedSchema What the live graph held at snapshot time. * @property driftedEntityTypes Observed labels matching neither a declared type name nor a declared * label. * @property driftedRelationshipTypes Relationship type names observed with no matching declaration. @@ -316,9 +311,9 @@ class DeclaredObservedDiff( unobservedRelationshipTypes: Set, ) { - // Copied into genuinely immutable sets, and a plain class rather than a `data class`, for the - // same reason as MetamodelDiff: a result must not be reshapeable after the fact, and a - // generated copy() would hand its arguments straight to the fields and skip the copying. + // Copied into immutable sets, and a plain class rather than a `data class`, for the same reason + // as MetamodelDiff: a finished result must not be reshapeable, and a generated copy() would hand + // its arguments straight to the fields and skip the copying. val driftedEntityTypes: Set = immutableCopy(driftedEntityTypes) @@ -328,7 +323,7 @@ class DeclaredObservedDiff( val unobservedRelationshipTypes: Set = immutableCopy(unobservedRelationshipTypes) - /** The stamp inside [declared] — its hash is what a drift report would record. */ + /** The stamp inside [declared]; its hash is what a drift report would record. */ val declaredVersion: MetamodelVersion get() = declared.version /** `true` when the graph holds any type or relationship that was never declared. */ diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiffer.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiffer.kt index 18644f71..3e07674a 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiffer.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiffer.kt @@ -20,9 +20,9 @@ import com.embabel.agent.core.DataDictionary /** * Compares two declared schemas and says what changed. * - * A version stamp answers "is this the same schema as before?"; a differ answers "and what moved?" - * — which is what you need to decide whether stored knowledge is still described by the schema it - * was extracted under. + * A version stamp answers "is this the same schema as before?". A differ answers "what moved?", + * which is what you need to decide whether stored knowledge is still described by the schema it was + * extracted under. * * Take the [MetamodelVersion] overload when you already have stamps, which is the normal case: an * application stamps at ingestion time and stores the stamp, so the older side comes back out of a @@ -44,8 +44,8 @@ interface MetamodelDiffer { * * This governs everything in both dictionaries, the closed-world reading. If the application * governs only part of its domain, stamp with its [GovernedTypeSelector] first (or take the two - * stamps off `DeclaredSchema.from(...)`) and use the other overload — otherwise exploratory - * types nobody committed to will show up as schema changes. + * stamps off `DeclaredSchema.from(...)`) and use the other overload. Otherwise exploratory types + * nobody committed to show up as schema changes. * * @param from The baseline (older) [DataDictionary]. * @param to The target (newer) [DataDictionary]. @@ -56,23 +56,20 @@ interface MetamodelDiffer { } /** - * Compares a [DeclaredSchema] against an [ObservedSchema] — what was declared against what a live - * graph actually holds — and says where the two disagree. + * Compares a [DeclaredSchema] against an [ObservedSchema] and says where what was declared and what + * a live graph holds disagree. * - * Its own interface rather than another overload on [MetamodelDiffer], because it answers a - * different question. [MetamodelDiffer] compares two declarations, both of them decisions somebody - * made; this compares a decision to an observation. The answer shape differs too: a - * [MetamodelDiff] is a symmetric list of changes, while a [DeclaredObservedDiff] separates drift - * (observed but undeclared, actionable) from merely-unobserved (declared but empty, normal). - * Folding them together would force every caller to sift a generic change list to tell those apart. + * A separate interface from [MetamodelDiffer] because the answer shape differs. A [MetamodelDiff] is + * a symmetric list of changes over two declarations. A [DeclaredObservedDiff] separates drift + * (observed but undeclared, actionable) from unobserved (declared but empty, normal), which a single + * change list would leave every caller to sift apart. * - * It takes a whole [DeclaredSchema] rather than a bare stamp because the comparison needs the bare - * relationship type names, and those can only travel alongside the stamp — never be recovered from - * it. [MetamodelVersion.relationshipNames] holds rendered `From-[name]->To` descriptors, and these - * names come from free text and LLM extraction, so a name can itself contain a `-[...]->`-shaped - * substring; reverse-parsing a descriptor is ambiguous and silently picks the wrong segment. - * `DeclaredSchema.from(dictionary, selector)` builds both halves under the same governance rule, so - * they can't drift apart. + * It takes a whole [DeclaredSchema] because the comparison needs the bare relationship type names, + * and those have to travel alongside the stamp. [MetamodelVersion.relationshipNames] holds rendered + * `From-[name]->To` descriptors, and relationship names come from free text and LLM extraction, so a + * name can itself contain a `-[...]->`-shaped substring; reverse-parsing a descriptor is ambiguous + * and silently picks the wrong segment. `DeclaredSchema.from(dictionary, selector)` builds both + * halves under the same governance rule, so they can't drift apart. */ interface DeclaredObservedDiffer { @@ -80,7 +77,7 @@ interface DeclaredObservedDiffer { * Compare [declared] against [observed]. * * @param declared The schema as declared, stamp plus bare relationship type names. - * @param observed A snapshot of what the live graph actually holds. + * @param observed A snapshot of what the live graph holds. * @return An immutable diff separating drift from unobserved-but-declared types. */ fun diffAgainstObserved(declared: DeclaredSchema, observed: ObservedSchema): DeclaredObservedDiff diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchema.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchema.kt index 4549a3f7..fbe6df4b 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchema.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchema.kt @@ -19,26 +19,23 @@ import java.time.Instant import java.util.Objects /** - * A snapshot of what a live graph actually holds, as opposed to what a [DeclaredSchema] says it - * should hold. + * A snapshot of what a live graph holds, where a [DeclaredSchema] says what it should hold. * - * Names only, and deliberately so. A graph can tell you which labels and relationship types exist - * in it; it cannot tell you what a property was *declared* to be. Two nodes with the same label can - * carry different property sets, a property can be absent on most of them, and a value that looks - * like an integer today may be a string tomorrow — so anything richer than a name would be a - * sample, not a schema, and would make a diff read as authoritative when it isn't. The declared - * side keeps full [PropertySignature]s; the observed side stays at names, and - * [DeclaredObservedDiff] is honest about the asymmetry. + * Names only. A graph can report which labels and relationship types exist in it. It cannot report + * what a property was *declared* to be: two nodes with the same label can carry different property + * sets, a property can be absent on most of them, and a value that looks like an integer today may + * be a string tomorrow. Anything richer than a name would be a sample of the data rather than the + * schema. The declared side keeps full [PropertySignature]s, the observed side stays at names, and + * [DeclaredObservedDiff] states that asymmetry. * - * This is a plain value type — nothing in this module knows how to go and read a graph. A storage - * layer (Neo4j or otherwise) builds one by querying the live database for its distinct labels and - * relationship types, then hands it here to be compared. That split is deliberate: schemas in the - * wild are often partly dynamic, with integrations contributing types that come and go, so what is - * actually in the graph can drift from what was ever declared, and this module needs a way to talk - * about that without depending on any particular graph driver. + * This is a plain value type; nothing in this module knows how to read a graph. A storage layer + * builds one by querying its live database for distinct labels and relationship types, then hands + * it here to be compared. Schemas in the wild are often partly dynamic, with integrations + * contributing types that come and go, so what is in the graph can drift from what was declared, + * and this module needs a way to talk about that without depending on a particular graph driver. * - * @property entityTypeNames Entity type (label) names actually observed in the graph. - * @property relationshipTypeNames Relationship type names actually observed in the graph. + * @property entityTypeNames Entity type (label) names observed in the graph. + * @property relationshipTypeNames Relationship type names observed in the graph. * @property capturedAt When this snapshot was taken. */ class ObservedSchema( @@ -47,11 +44,11 @@ class ObservedSchema( val capturedAt: Instant, ) { - // Both sets are copied into genuinely immutable ones, keeping the order they arrived in. A - // snapshot is a statement about a moment, so it must not change afterwards — and a backend - // typically builds these from a mutable set it keeps filling as it walks query results. Kotlin's - // read-only `Set` is a compile-time promise only; a Java caller sees straight through it. Plain - // class rather than a `data class` because a generated `copy()` would skip the copying. + // Both sets are copied into immutable ones, keeping the order they arrived in. A snapshot + // describes one moment and must not change afterwards, and a backend typically builds these from + // a mutable set it fills as it walks query results. Kotlin's read-only `Set` is a compile-time + // promise only; a Java caller sees straight through it. Plain class rather than a `data class` + // because a generated `copy()` would skip the copying. val entityTypeNames: Set = immutableCopy(entityTypeNames) diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchemaSource.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchemaSource.kt index 1be40983..46f9da95 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchemaSource.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchemaSource.kt @@ -18,20 +18,20 @@ package com.embabel.dice.metamodel import com.embabel.agent.core.ContextId /** - * Takes a fresh snapshot of what a live graph actually holds, so a [DeclaredObservedDiffer] can - * compare it against what was declared. + * Takes a fresh snapshot of what a live graph holds, so a [DeclaredObservedDiffer] can compare it + * against what was declared. * - * This is the storage-side SPI of the pair: [DeclaredSchemaSource] is where an application says - * what it governs, and this is where a backend says what is really there. The module has no graph - * driver dependency and can't implement it — a storage layer (Neo4j via Drivine, or anything else) - * provides the real implementation by querying its live database for distinct labels and - * relationship types. Tests supply a canned [ObservedSchema] instead and never touch a database. + * This is the storage-side SPI of a pair: [DeclaredSchemaSource] is where an application says what + * it governs, and this is where a backend says what is there. This module has no graph driver + * dependency, so a storage layer (Neo4j via Drivine, or anything else) provides the implementation + * by querying its live database for distinct labels and relationship types. Tests supply a canned + * [ObservedSchema] and never touch a database. * - * An ordinary interface, not a `fun interface`: it carries two entry points — the scoped [observe] - * an implementation provides, and the no-argument [observe] convenience it gets for free — and a - * SAM lambda can only ever supply the first. The no-argument form is a real method with a body - * rather than a Kotlin default argument so Java callers can write `observe()` too; Java cannot see - * a Kotlin default argument. It is also all Java gets: `ContextId` is a Kotlin value class, so the + * An ordinary interface rather than a `fun interface`, because it carries two entry points: the + * scoped [observe] an implementation provides, and the no-argument [observe] convenience it gets for + * free. A SAM lambda can only supply the first. The no-argument form is a real method with a body + * rather than a Kotlin default argument so Java callers can write `observe()`; Java cannot see a + * Kotlin default argument. It is also all Java gets: `ContextId` is a Kotlin value class, so the * scoped form compiles to a mangled JVM name Java can't call. */ interface ObservedSchemaSource { @@ -39,8 +39,8 @@ interface ObservedSchemaSource { /** * @param contextId `null` snapshots the whole graph. Non-null scopes the snapshot to that one * context: only that context's own data is consulted. What "scoped" means concretely is up - * to the implementation — a graph-backed one typically walks the context's own nodes, and - * may return an empty relationship-type set when it can only reach node labels that way. + * to the implementation. A graph-backed one typically walks the context's own nodes, and may + * return an empty relationship-type set when it can only reach node labels that way. * @return a fresh [ObservedSchema] snapshot, captured at call time. */ fun observe(contextId: ContextId?): ObservedSchema diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt index 268f4921..3904435c 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt @@ -26,19 +26,18 @@ import com.embabel.dice.metamodel.ObservedSchema import com.embabel.dice.metamodel.PropertySignature /** - * The shipped differ: a deterministic, structural comparison, with no heuristics and no LLM - * anywhere near it. + * The shipped differ: a deterministic structural comparison, with no heuristics and no LLM. * - * It works on exactly the data a [MetamodelVersion] already carries — entity type names, per-type - * label sets, per-type property *signatures*, and relationship descriptors. Those are the same - * fields the content hash is built from, which is what makes an empty diff and an equal hash mean - * the same thing. + * It works on the data a [MetamodelVersion] already carries: entity type names, per-type label sets, + * per-type property *signatures*, and relationship descriptors. Those are the same fields the + * content hash is built from, which is what makes an empty diff and an equal hash mean the same + * thing. * * Two rules it keeps throughout. Sets are compared as sets, never as a delimiter-joined projection, - * so a label or property name containing a comma or a space — routine when names come from LLM - * extraction — can't collapse two genuinely different sets into a false "unchanged". And output is - * canonical: type names, property names and signatures all come out sorted, so the same pair of - * versions always produces the same change list, in the same order, and a stored diff can be + * because a label or property name can contain a comma or a space, which is routine when names come + * from LLM extraction, and joining would collapse two different sets into a false "unchanged". + * Output is canonical: type names, property names and signatures all come out sorted, so the same + * pair of versions always produces the same change list in the same order, and a stored diff can be * compared with another one. * * Stateless and thread-safe; one shared instance is fine. @@ -101,18 +100,18 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { // label those types declare. val declaredLabels = declaredTypes + declared.version.entityTypeLabels.values.flatten() - // Drift is observed-but-never-declared: orphaned data whose declaring integration is gone, - // or was never registered. The opposite direction is not drift — a declared type with zero - // instances is a perfectly ordinary state, so it gets its own, purely informational bucket. - // That direction stays on the type names: "declared but with no data" is a statement about - // types, and a parent label listed as an unobserved type would be noise about something - // that was never a type in its own right. + // Drift is observed and never declared: orphaned data whose declaring integration is gone, + // or was never registered. The opposite direction gets its own informational bucket, since a + // declared type with zero instances is an ordinary state. That direction stays on the type + // names: "declared but with no data" is a statement about types, and a parent label listed + // as an unobserved type would be noise about something that was never a type in its own + // right. // - // Relationships compare on the bare type name, because that is all a graph can report (a + // Relationships compare on the bare type name, because that is all a graph can report: a // `db.relationshipTypes()`-style query knows the type, not which node types an instance - // actually connected). The bare names come off the declaration, which carried them - // un-rendered; we never recover one by parsing a `From-[name]->To` descriptor, since these - // names are free text and can contain a `-[...]->`-shaped substring themselves. + // connected. The bare names come off the declaration, which carried them un-rendered. We + // never recover one by parsing a `From-[name]->To` descriptor, since these names are free + // text and can contain a `-[...]->`-shaped substring themselves. val declaredRels = declared.relationshipTypeNames val observedRels = observed.relationshipTypeNames @@ -136,14 +135,15 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { /** * Compare one type's properties, matching them up by name. * - * Matching by name is what lets a reshaped property read as a reshape rather than as a deletion - * next to an unrelated addition: `age: string` becoming `age: integer` is one change with a - * before and an after, not two changes a caller has to pair back up. + * Matching by name turns a reshaped property into one change carrying a before and an after: + * `age: string` becoming `age: integer` is a single + * [MetamodelChange.PropertySignatureChanged], which saves a caller pairing up a deletion and an + * addition. * - * A name can legitimately map to more than one signature on either side — a `DataDictionary` - * may hold two same-named domain types whose properties get unioned into a single stamp. There - * is no single before/after to pair in that case, so the differing signatures are reported as - * added and removed instead of a guessed pairing. + * A name can legitimately map to more than one signature on either side, because a + * `DataDictionary` may hold two same-named domain types whose properties get unioned into a + * single stamp. There is no single before/after to pair in that case, so the differing + * signatures are reported as added and removed. */ private fun comparePropertiesOf( typeName: String, @@ -191,7 +191,7 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { * Sort into a set that iterates in that order. * * The sets inside a [MetamodelVersion] are JVM-immutable copies, whose iteration order is - * deliberately unspecified and varies between JVM runs. Set equality doesn't care, but a + * unspecified and varies between JVM runs. Set equality doesn't care, but a * change list that gets logged or rendered does, so anything leaving here is sorted first. */ private fun > canonical(values: Collection): Set = diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt index bb80a320..5ca656f9 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt @@ -280,10 +280,9 @@ class MetamodelDifferTest { } /** - * The reason this module stamps property *signatures* rather than property names. Every case - * below leaves both schemas with exactly the same type names and the same property names, so a - * name-only diff would report nothing at all — while what the graph can actually hold has - * changed. + * Why this module stamps property *signatures* rather than property names. Every case below + * leaves both schemas with the same type names and the same property names, so a name-only diff + * would report nothing, while what the graph can hold has changed. */ @Nested inner class PropertySignatureChanges { @@ -385,10 +384,10 @@ class MetamodelDifferTest { } /** - * A `DataDictionary` may hold two same-named domain types, whose properties get unioned into - * one stamp — so a single property name can carry two signatures at once. There is no - * before/after pair to report then, and guessing one would be a fiction, so the differing - * signatures come back as added and removed instead. + * A `DataDictionary` may hold two same-named domain types whose properties get unioned into + * one stamp, so a single property name can carry two signatures at once. There is no + * before/after pair to report then, so the differing signatures come back as added and + * removed. */ @Test fun `a property name carrying two signatures falls back to added and removed`() { @@ -443,9 +442,9 @@ class MetamodelDifferTest { inner class DelimiterSafetyInNames { /** - * A label name that contains a comma — possible when names come from LLM extraction. - * The diff compares label sets directly, so punctuation in a name is just a character; - * the change is still detected and the label comes back as one entry, not split. + * A label name that contains a comma, which happens when names come from LLM extraction. + * The diff compares label sets directly, so punctuation in a name is just a character: the + * change is still detected and the label comes back as one entry. */ @Test fun `label containing a comma is treated as a single label`() { @@ -464,10 +463,10 @@ class MetamodelDifferTest { } /** - * Property names can contain spaces when they come from free-text / LLM extraction. - * The two sets `{"a", "b c"}` and `{"a b", "c"}` are genuinely different, yet both - * collapse to the string "a b c" if you compare a space-joined projection instead of - * the sets themselves. A real property change must never be hidden by that collision. + * Property names can contain spaces when they come from free-text or LLM extraction. The + * two sets `{"a", "b c"}` and `{"a b", "c"}` are different, yet both collapse to the string + * "a b c" under a space-joined projection. Comparing the sets themselves keeps a real + * property change visible. */ @Test fun `property sets that collide under a space delimiter are still reported as modified`() { @@ -583,10 +582,9 @@ class MetamodelDifferTest { } /** - * The observed side has names and nothing else, so a declared property's shape is never - * part of this comparison. Two declarations differing only in a property signature produce - * an identical declared/observed diff — the graph simply cannot answer that question, and - * this comparison doesn't pretend it can. + * The observed side has names and nothing else, so a declared property's shape is never part + * of this comparison. Two declarations differing only in a property signature produce an + * identical declared/observed diff, because the graph cannot answer that question. */ @Test fun `a property signature difference is invisible to the declared-observed comparison`() { @@ -689,12 +687,11 @@ class MetamodelDifferTest { } /** - * A relationship name that embeds a `-[...]->`-shaped substring — the exact case a - * regex-based implementation got wrong, where a greedy `.*-\[(.+)]->.*` backtracks to the - * *last* such substring and parses `"A-[X]->B"` down to `"X"`. Since these names are - * free-text and LLM-derived, that shape is realistic rather than contrived. Bare names - * travel with the declaration and are never recovered from a descriptor, so the match holds - * whatever the name looks like. + * A relationship name that embeds a `-[...]->`-shaped substring: the case a regex-based + * implementation got wrong, where a greedy `.*-\[(.+)]->.*` backtracks to the *last* such + * substring and parses `"A-[X]->B"` down to `"X"`. These names are free-text and + * LLM-derived, so that shape occurs. Bare names travel with the declaration and are never + * recovered from a descriptor, so the match holds whatever the name looks like. */ @Test fun `a relationship name shaped like a full descriptor still matches by its bare name`() { @@ -726,9 +723,9 @@ class MetamodelDifferTest { } /** - * A diff is a result, and results don't change. Mirrors `MetamodelVersionTest.Immutability`: - * Kotlin's read-only collection types are a compile-time promise only — a Java caller sees plain - * `java.util` collections through the getters — so these have to refuse at runtime. + * A finished diff must not be reshapeable. Mirrors `MetamodelVersionTest.Immutability`: Kotlin's + * read-only collection types are a compile-time promise only, and a Java caller sees plain + * `java.util` collections through the getters, so these have to refuse at runtime. */ @Nested inner class Immutability { @@ -855,8 +852,8 @@ class MetamodelDifferTest { /** * A stamp built directly from property signatures, for shapes a `DataDictionary` makes - * awkward to express — a `VALUE` and a `REFERENCE` under one name, or two signatures for - * the same property name. + * awkward to express: a `VALUE` and a `REFERENCE` under one name, or two signatures for the + * same property name. */ private fun stampOf(vararg types: Pair>): MetamodelVersion = MetamodelVersion( diff --git a/docs/design/metamodel-diff.md b/docs/design/metamodel-diff.md index d465b91b..4c02190d 100644 --- a/docs/design/metamodel-diff.md +++ b/docs/design/metamodel-diff.md @@ -1,12 +1,12 @@ -# Metamodel diffing: what moved, and against what +# Metamodel diffing: the change taxonomy and drift -A version stamp answers one question: *is this the same schema as last time?* That's a yes or a no, -and it's enough to notice something happened, but not enough to do anything about it. Knowing that -`Person` lost a property, or that a live graph is full of a type nobody declares any more, needs a -comparison that says **what** moved. +DICE compares schema stamps to say what changed between them, and compares a declared schema against +a live graph to say where the two disagree. -That's what this note covers. Two comparisons live in `dice-metamodel`, and they're separate on -purpose because they answer different questions. +A version stamp answers "is this the same schema as last time?", a yes or a no. Acting on the answer +needs more: which property `Person` lost, or which type a live graph is full of that nobody declares +any more. Two comparisons in `dice-metamodel` cover those, and they are separate because they answer +different questions. ```mermaid flowchart LR @@ -27,23 +27,22 @@ flowchart LR d2 --> drift ``` -**Declared vs. declared** (`MetamodelDiffer`) compares two stamps — two things somebody decided. It -is symmetric: every difference is a change, and the result is an ordered list of them. +**Declared vs. declared** (`MetamodelDiffer`) compares two stamps, both of them declarations. It is +symmetric: every difference is a change, and the result is an ordered list of them. **Declared vs. observed** (`DeclaredObservedDiffer`) compares a declaration against a snapshot of a -live graph. It is asymmetric, because one side is a decision and the other is reality, and the two -disagree in two very different ways. +live graph. It is asymmetric, because one side is a decision and the other is an observation, and +the two disagree in two different ways. -Both are contracts plus one shipped implementation, `StructuralMetamodelDiffer`, which does both. +Both are contracts, with one shipped implementation, `StructuralMetamodelDiffer`, which does both. No LLM, no heuristics, no database: it walks the same fields the content hash is built from, which is what makes "empty diff" and "same hash" mean the same thing. ## The change taxonomy `MetamodelChange` is a sealed interface, so a `when` over it is exhaustive and the compiler speaks -up when a new kind lands. That matters more than usual here, because the next slice decides which -changes are lossy enough to quarantine data over — a change kind nobody noticed would be silently -treated as harmless. +up when a new kind lands. The next slice decides which changes are lossy enough to quarantine data +over, where an unhandled change kind would be treated as harmless. | Kind | What it means | | --- | --- | @@ -55,78 +54,71 @@ treated as harmless. The kinds don't overlap. Each difference is reported exactly once, by whichever kind describes it most precisely. -`PropertySignatureChanged` is the one worth dwelling on, because it is the whole reason the stamp -carries signatures rather than property names. `age` turning from a string into an integer, or a -single `worksAt` becoming a list of them, is a real change to what the graph can hold — data -extracted under the old shape may simply not fit the new one. Under a name-only taxonomy both -schemas have a property called `age`, nothing moved, and nothing gets reported. So the differ -matches properties **by name** and pairs the two signatures: one change with a before and an after, -rather than a deletion sitting next to an unrelated-looking addition that a caller has to pair back -up. `typeChanged`, `cardinalityChanged` and `kindChanged` say which part moved. - -There is one honest gap. A `DataDictionary` may hold two same-named domain types whose properties -get unioned into a single stamp, so one property name can carry two signatures at once. There is no -single before and after to pair then, and inventing one would be a fiction, so the differing -signatures come back as `addedProperties` and `removedProperties` on `EntityTypeModified` instead. -Rare, and better than a guessed pairing. - -What the diff deliberately does *not* do is judge. It says `age` went from `string` to `integer`; it -does not say that this strands existing values while `integer` → `string` wouldn't. That's a policy -question, and it belongs to the quarantine slice. +`PropertySignatureChanged` is why the stamp carries signatures rather than property names. `age` +turning from a string into an integer, or a single `worksAt` becoming a list of them, changes what +the graph can hold, and data extracted under the old shape may not fit the new one. Under a +name-only taxonomy both schemas have a property called `age` and nothing gets reported. So the +differ matches properties **by name** and pairs the two signatures into one change with a before and +an after, which saves a caller pairing up a deletion and an unrelated-looking addition. +`typeChanged`, `cardinalityChanged` and `kindChanged` say which part moved. + +One gap. A `DataDictionary` may hold two same-named domain types whose properties get unioned into a +single stamp, so one property name can carry two signatures at once. There is no single before and +after to pair then, so the differing signatures come back as `addedProperties` and +`removedProperties` on `EntityTypeModified`. + +The diff doesn't judge. It says `age` went from `string` to `integer`. Deciding that this strands +existing values while `integer` → `string` wouldn't is a policy question for the quarantine slice. Output is canonical. Type names, property names and signatures all come out sorted, so the same two -stamps always produce the same list in the same order — the sets inside a `MetamodelVersion` are -JVM-immutable copies whose iteration order is deliberately unspecified and varies between runs, so -anything leaving the differ is sorted first. And sets are compared as sets, never as a -delimiter-joined projection: these names come from free text and LLM extraction and routinely -contain commas and spaces, so `{"a", "b c"}` and `{"a b", "c"}` must not collapse into the same -thing. +stamps always produce the same list in the same order. The sets inside a `MetamodelVersion` are +JVM-immutable copies whose iteration order is unspecified and varies between runs, so anything +leaving the differ is sorted first. Sets are compared as sets, never as a delimiter-joined +projection: these names come from free text and LLM extraction and routinely contain commas and +spaces, so `{"a", "b c"}` and `{"a b", "c"}` must not collapse into the same thing. ## Declared vs. observed, and the asymmetry -`ObservedSchema` is what a live graph actually holds: entity labels, relationship types, and when -the snapshot was taken. A storage layer builds one by querying its database; `ObservedSchemaSource` -is the SPI it implements, and this module has no graph driver, so it can't provide a default. Tests +`ObservedSchema` is what a live graph holds: entity labels, relationship types, and when the +snapshot was taken. A storage layer builds one by querying its database; `ObservedSchemaSource` is +the SPI it implements, and this module has no graph driver, so it can't provide a default. Tests hand in a canned snapshot and never touch a database. `observe(contextId)` scopes the snapshot to a single context; `observe()` takes the whole graph. -Comparing that against a declaration gives two clearly separated buckets, not a change list: - -- **Drift** — observed in the graph, never declared. This is the actionable case. Concretely, data - is sitting in the graph whose declaring integration has since been switched off, or never - registered one, so nothing can tell that data apart as valid or explain its shape. -- **Unobserved** — declared, but with zero instances in the graph right now. Purely informational. A - declared type with no data yet is a completely normal state, not a problem. - -Folding these into one symmetric change list would force every caller to sift it to tell an -emergency from a shrug. - -**A declared label counts as declared.** What a graph reports is labels, and a type carries every -label in its hierarchy — declare `Person` with parent `Agent` and every Person node comes back -carrying both. So the declared side of the drift check is every entity type name *plus* every label -those types declare; comparing against type names alone would report `Agent` as undeclared drift on -a schema nobody had touched. The unobserved direction stays on type names, because "declared but -with no data" is a statement about types, and listing a parent label as an unobserved type would be -noise about something that was never a type in its own right. - -**The observed side is names only, and that is a real limit, not an oversight.** A graph can tell -you which labels and relationship types exist in it. It cannot tell you what a property was -*declared* to be: two nodes with the same label can carry different property sets, a property can be -absent on most of them, and a value that looks like an integer today may be a string tomorrow. -Anything richer than a name would be a sample, not a schema — and a diff built on a sample reads as -authoritative when it isn't. So `DeclaredObservedDiff` stops at type and relationship names and says -nothing about property shape. Signatures are compared where both sides genuinely have them: -declared against declared. - -One more asymmetry, smaller but sharp. `MetamodelVersion.relationshipNames` holds rendered -`From-[name]->To` descriptors; a graph only knows the bare type name, because a -`db.relationshipTypes()`-style query knows the type, not which node types an instance actually -connected. So the comparison happens on the bare name — and that name is never recovered by parsing -a descriptor. Relationship names are free text, so a name can itself contain a `-[...]->`-shaped -substring, and a greedy parse of `Foo-[A-[X]->B]->Bar` picks `X` rather than `A-[X]->B`. That's why -`DeclaredObservedDiffer` takes a whole `DeclaredSchema`: the declaration carries the bare names -alongside the stamp, un-rendered, built under the same governance rule so the two halves can't drift -apart. +Comparing that against a declaration gives two separate buckets rather than a change list: + +- **Drift**: observed in the graph, never declared. This is the actionable case. Data is sitting in + the graph whose declaring integration has since been switched off, or never registered one, so + nothing can confirm that data as valid or explain its shape. +- **Unobserved**: declared, with zero instances in the graph right now. Informational. A declared + type with no data yet is a normal state. + +A single symmetric change list would leave every caller to sift the actionable case out of the +informational one. + +**A declared label counts as declared.** A graph reports labels, and a type carries every label in +its hierarchy: declare `Person` with parent `Agent` and every Person node comes back carrying both. +So the declared side of the drift check is every entity type name *plus* every label those types +declare. Comparing against type names alone would report `Agent` as undeclared drift on a schema +nobody had touched. The unobserved direction stays on type names, because "declared but with no +data" is a statement about types, and a parent label was never a type in its own right. + +**The observed side is names only.** A graph can report which labels and relationship types exist in +it. It cannot report what a property was *declared* to be: two nodes with the same label can carry +different property sets, a property can be absent on most of them, and a value that looks like an +integer today may be a string tomorrow. Anything richer than a name would be a sample of the data +rather than the schema. So `DeclaredObservedDiff` stops at type and relationship names and says +nothing about property shape. Signatures are compared where both sides have them: declared against +declared. + +One more asymmetry. `MetamodelVersion.relationshipNames` holds rendered `From-[name]->To` +descriptors; a graph only knows the bare type name, because a `db.relationshipTypes()`-style query +knows the type, not which node types an instance connected. So the comparison happens on the bare +name, and that name is never recovered by parsing a descriptor. Relationship names are free text, so +a name can itself contain a `-[...]->`-shaped substring, and a greedy parse of `Foo-[A-[X]->B]->Bar` +picks `X` where the name is `A-[X]->B`. That is why `DeclaredObservedDiffer` takes a whole +`DeclaredSchema`: the declaration carries the bare names alongside the stamp, un-rendered, built +under the same governance rule so the two halves can't drift apart. ## Using it @@ -155,20 +147,19 @@ if (drift.hasDrift) { ``` `MetamodelDiffer` also has a convenience overload taking two `DataDictionary` instances. It stamps -both with everything governed, the closed-world reading — fine for a domain that's closed-world -throughout, wrong for one that isn't, where exploratory types nobody committed to would show up as -schema changes. If governance is partial, stamp with the `GovernedTypeSelector` first (or take both -stamps off `DeclaredSchema.from(...)`) and use the `MetamodelVersion` overload. +both with everything governed, the closed-world reading, which suits a domain that is closed-world +throughout. Where governance is partial, exploratory types nobody committed to would show up as +schema changes, so stamp with the `GovernedTypeSelector` first (or take both stamps off +`DeclaredSchema.from(...)`) and use the `MetamodelVersion` overload. -`StructuralMetamodelDiffer` is stateless and thread-safe; one shared instance is fine. There is no -Spring wiring in this module — it's an ordinary constructor call until the autoconfigure slice -arrives. +`StructuralMetamodelDiffer` is stateless and thread-safe; one shared instance is fine. This module +has no Spring wiring, so it's an ordinary constructor call until the autoconfigure slice arrives. ## What comes next Diffing is the comparison half of the middle tier described in -[metamodel-versioning.md](metamodel-versioning.md#the-tiers-ahead). The other half is *doing* -something with the result: a drift check that sequences observe → declare → diff → record, a store -for the reports it produces so a drift you saw last week is still answerable, and then quarantine — -marking propositions stale when a change actually strands them, never deleting. Those land in the -next slices, on top of these contracts. +[metamodel-versioning.md](metamodel-versioning.md#the-tiers-ahead). The other half acts on the +result: a drift check that sequences observe, declare, diff, record; a store for the reports it +produces, so a drift seen last week is still answerable; and quarantine, which marks propositions +stale when a change strands them rather than deleting them. Those land in the next slices, on top of +these contracts. From 30fa074ddcf9dfe78139215c4c1237195e0aa922 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:49:14 -0400 Subject: [PATCH 3/8] Pair declared renames in the metamodel diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new sealed changes: EntityTypeRenamed, PropertyRenamed, and EntityTypeAliasesChanged. Pairing is sorted and one-to-one at both levels, runs on the residual sets after name matching, and folds a rename's own propagation — the name-implied label swap, the implied alias entry, and referrer signatures and child labels compared modulo the paired rename map, REFERENCE kinds only — into the rename itself, so a pure rename reads as one change and nothing else. Alias-only deltas keep the empty-diff-iff-equal-hash contract at both levels, and a renamed type's accumulated former names join the declared side of the observed diff. --- CHANGELOG.md | 37 + .../embabel/dice/metamodel/MetamodelDiff.kt | 180 ++++- .../support/StructuralMetamodelDiffer.kt | 211 +++++- .../dice/metamodel/MetamodelDifferTest.kt | 658 ++++++++++++++++++ docs/design/metamodel-diff.md | 149 +++- 5 files changed, 1199 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 834c113d..56421f65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,3 +92,40 @@ and the consumer PRs that deliver it). interfaces: deterministic, stateless, no database and no LLM. This slice adds no drift runner, no quarantine, no Spring wiring, and no new dependency. **Compatibility: additive.** New types in an existing module; no existing API touched. + +- Declared renames in the diff, **EXPERIMENTAL** (shape may change before 1.0): three new + `MetamodelChange` members — `PropertyRenamed(typeName, before, after)`, + `EntityTypeRenamed(before, after)` and `EntityTypeAliasesChanged(typeName, before, after)`. + A rename declared through `SchemaAliases` now pairs instead of reading as a removal and an + addition. Pairing runs on what is left after the ordinary name matching, walks removed + names in sorted order against added entries in sorted order, and is one-to-one: one added + entry claiming two removed names takes the first and leaves the other an ordinary removal, + one removed name claimed by two added entries goes to the first and leaves the second an + ordinary addition, and a property name the type-merge path holds two signatures for falls + back to a removal and an addition. Paired properties are excluded from + `EntityTypeModified.addedProperties`/`removedProperties`, and an entry left empty by that + is not emitted. Type pairing matches the whole accumulated alias set, so a type renamed + twice still pairs across stamps that aren't adjacent, and it suppresses the + `EntityTypeAdded`/`EntityTypeRemoved` pair, reporting the type's other deltas under the new + name. After pairing, the older version is compared modulo the renames the diff found: old + name for new is substituted in `Kind.REFERENCE` signature targets and in label sets, and + nowhere else. A delta that vanishes under the substitution folds into `EntityTypeRenamed`, + and one that survives reports in substituted form (a referrer that moved `A → D` while `A` + was renamed to `B` reports `B → D`). `Kind.VALUE` type strings are left alone, so an entity + type named `Date` renaming to `Timestamp` does not rewrite every property declared as + holding a `Date` value. Rendered relationship descriptors are left alone too, so a + relationship touching a renamed endpoint still churns as a removal plus an addition; the + names inside a descriptor are free text and are never parsed. Alias-only edits have + representations, so an empty diff and an equal `contentHash` keep meaning the same thing: a + property whose aliases alone moved is an ordinary `PropertySignatureChanged`, and a type's + is an `EntityTypeAliasesChanged`. Declared former names join the declared side of + `diffAgainstObserved`, so data still carrying a renamed type's old label is not drift. + `MetamodelDiff.touchedEntityTypes` covers the new kinds, and contributes both names of a + rename. + **Compatibility: breaking for external exhaustive `when` expressions.** `MetamodelChange` + is a sealed interface, and three new members make any `when` over it outside this repo + non-exhaustive until it handles them. Stated and accepted: the taxonomy is designed to be + exhausted, and a consumer that silently treated a rename as an unhandled kind would treat + it as harmless. Nothing else changes for a schema that declares no aliases — pairing and + substitution are no-ops with an empty alias map, and the existing diff behavior, ordering + and output are unchanged. Binary compatibility is untouched; consumers recompile. diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt index d537abb8..633f8fce 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt @@ -41,7 +41,11 @@ private fun immutableCopy(values: List): List = java.util.List.copyOf( * * The kinds don't overlap. A given difference is reported exactly once, by whichever kind describes * it most precisely: a property that keeps its name and changes shape is a - * [PropertySignatureChanged]. + * [PropertySignatureChanged], and one that changes name under a declared alias is a + * [PropertyRenamed]. The exception is relationships, whose rendered descriptors embed type names the + * differ compares as atoms: a relationship touching a renamed type churns as a + * [RelationshipRemoved] plus a [RelationshipAdded] alongside the [EntityTypeRenamed] that already + * described the same move. See `docs/design/metamodel-diff.md`. */ sealed interface MetamodelChange { @@ -60,6 +64,86 @@ sealed interface MetamodelChange { */ data class EntityTypeRemoved(val typeName: String) : MetamodelChange + /** + * An entity type that changed name, paired up because the newer version declares the old name as + * one of the type's former names. + * + * Without the alias the same move reads as [EntityTypeRemoved] plus [EntityTypeAdded], which + * says the type's data has nothing describing it any more. With it, this entry replaces that + * pair, and whatever else moved on the type is reported under [after] as an + * [EntityTypeModified] or a property change. + * + * The label swap the rename itself implies rides here: a type's own name is one of its labels, + * so `Person` becoming `Human` mechanically loses the label `Person` and gains `Human`. That + * pair is folded into this entry, along with the same swap wherever it propagates — a child + * type's inherited label, another type's reference to this one. Parent and other label changes + * still report on the paired [EntityTypeModified]. + * + * Experimental: shape may change before 1.0. + * + * @property before The name in the older version. + * @property after The name in the newer version. + */ + data class EntityTypeRenamed(val before: String, val after: String) : MetamodelChange { + + init { + require(before != after) { + "EntityTypeRenamed pairs two names for one type, but got '$before' twice." + } + } + } + + /** + * An entity type present in both versions whose declared former names changed: an alias was + * added or retired. + * + * Aliases are hashed, so an alias-only edit moves [MetamodelVersion.contentHash] and has to + * surface as a change for an empty diff and an equal hash to keep meaning the same thing. It + * says nothing about the data: no label, property or relationship moved. + * + * A rename's own alias entry is not reported here. Declaring `Human` with the former name + * `Person` is the rename, and it rides in [EntityTypeRenamed]; this entry covers what the + * declaration says about former names beyond that. + * + * Experimental: shape may change before 1.0. + * + * @property typeName The entity type name in the newer version. + * @property before The former names declared in the older version. + * @property after The former names declared in the newer version. + */ + class EntityTypeAliasesChanged( + typeName: String, + before: Set, + after: Set, + ) : MetamodelChange { + + val typeName: String = typeName + + // Copied, and a plain class rather than a `data class`, for the same reason as + // EntityTypeModified: a generated copy() would hand its argument straight to the field. + val before: Set = immutableCopy(before) + + val after: Set = immutableCopy(after) + + init { + require(this.before != this.after) { + "EntityTypeAliasesChanged requires an actual change, but before and after are " + + "identical for '$typeName': ${this.before}" + } + } + + override fun equals(other: Any?): Boolean = + other is EntityTypeAliasesChanged && + typeName == other.typeName && + before == other.before && + after == other.after + + override fun hashCode(): Int = Objects.hash(typeName, before, after) + + override fun toString(): String = + "EntityTypeAliasesChanged(typeName=$typeName, before=$before, after=$after)" + } + /** * An entity type that exists in both versions but gained or lost labels or whole properties. * One entry carries both deltas for the type; at least one of the four sets is non-empty. @@ -74,7 +158,12 @@ sealed interface MetamodelChange { * merged into one stamp. There is no single before and after to pair up then, so the differing * signatures are reported here as added and removed. * - * @property typeName The entity type name (unchanged). + * A property whose declaration pairs an old name with a new one is a [PropertyRenamed] and is + * left out of [addedProperties] and [removedProperties]. An entry with nothing left in it after + * that is not emitted at all, so the at-least-one-set-non-empty contract holds. + * + * @property typeName The entity type name. For a type that also changed name, this is the newer + * name, and the change of name itself is an [EntityTypeRenamed]. * @property addedLabels Labels present in the new version but not the old. * @property removedLabels Labels present in the old version but not the new. * @property addedProperties Full signatures of properties whose names are new in this version. @@ -120,6 +209,49 @@ sealed interface MetamodelChange { "addedProperties=$addedProperties, removedProperties=$removedProperties)" } + /** + * A property that changed name on a type present in both versions, paired up because the newer + * signature declares the old name as one of its former names. + * + * Without the alias the same move reads as a removal and an unrelated-looking addition on + * [EntityTypeModified], which a quarantine policy has to treat as loss. The pair is + * one-to-one: each old name pairs with at most one new signature, and each new signature claims + * at most one old name. + * + * The two signatures can differ in more than the name. A property renamed and retyped in one + * step carries its whole delta here, and [typeChanged], [cardinalityChanged] and [kindChanged] + * describe it the same way [PropertySignatureChanged] does, so a policy judging the shape move + * applies one rule to both. + * + * Experimental: shape may change before 1.0. + * + * @property typeName The entity type carrying the property, under its name in the newer version. + * @property before The signature in the older version, under its old name. + * @property after The signature in the newer version, under its new name. + */ + data class PropertyRenamed( + val typeName: String, + val before: PropertySignature, + val after: PropertySignature, + ) : MetamodelChange { + + init { + require(before.name != after.name) { + "PropertyRenamed pairs two names for one property on '$typeName', but got " + + "'${before.name}' twice." + } + } + + /** `true` when the value type or reference target moved as well as the name. */ + val typeChanged: Boolean get() = before.type != after.type + + /** `true` when the property went from one value to many, or the reverse. */ + val cardinalityChanged: Boolean get() = before.cardinality != after.cardinality + + /** `true` when the property flipped between holding a value and pointing at another type. */ + val kindChanged: Boolean get() = before.kind != after.kind + } + /** * A property that kept its name on a type present in both versions, but changed shape: its * value type narrowed or widened, its cardinality moved, or it turned from a plain value into a @@ -134,6 +266,11 @@ sealed interface MetamodelChange { * string→integer strands existing values while integer→string doesn't is a policy question for * the quarantine slice. * + * Declared former names are part of a signature, so adding or retiring one on a property that + * kept its name lands here too, with [typeChanged], [cardinalityChanged] and [kindChanged] all + * `false`. The entry exists so an empty diff and an equal content hash keep meaning the same + * thing. + * * @property typeName The entity type carrying the property. * @property propertyName The property name, the same on both sides. * @property before The signature in the older version. @@ -195,7 +332,11 @@ sealed interface MetamodelChange { * @property fromVersion The baseline (older) version. * @property toVersion The target (newer) version. * @property changes Every change, in a deterministic order: entity-type changes first (by type - * name), then relationship changes. The same pair of versions always produces the same list. + * name, a renamed type filed under its newer name), then relationship changes. Within one type + * the order is [MetamodelChange.EntityTypeRenamed], [MetamodelChange.EntityTypeAliasesChanged], + * [MetamodelChange.EntityTypeModified], [MetamodelChange.PropertyRenamed] by new property name, + * then [MetamodelChange.PropertySignatureChanged] by property name. The same pair of versions + * always produces the same list. */ class MetamodelDiff( val fromVersion: MetamodelVersion, @@ -215,7 +356,10 @@ class MetamodelDiff( /** `true` when nothing changed. */ val isEmpty: Boolean get() = changes.isEmpty() - /** Names from every [MetamodelChange.EntityTypeRemoved]: the quarantine candidates. */ + /** + * Names from every [MetamodelChange.EntityTypeRemoved]: the quarantine candidates. A type that + * was renamed under a declared alias is a [MetamodelChange.EntityTypeRenamed] and is not here. + */ val removedEntityTypes: Set get() = changes .filterIsInstance() @@ -236,20 +380,26 @@ class MetamodelDiff( get() = changes.filterIsInstance() /** - * Every entity type this diff says something about: added, removed, modified, or holding a - * property whose signature moved. A reshaped type shows up in both [modifiedEntityTypes] and - * [propertySignatureChanges], so answering "did anything about `Person` change?" from those - * lists means checking each one in turn. + * Every entity type this diff says something about: added, removed, renamed, modified, or + * holding a property that was renamed or reshaped. A reshaped type shows up in both + * [modifiedEntityTypes] and [propertySignatureChanges], so answering "did anything about + * `Person` change?" from those lists means checking each one in turn. + * + * A rename contributes both names. A caller asking about `Person` after `Person` became `Human` + * is asking about data written under the old name, and the diff does say something about it. */ val touchedEntityTypes: Set - get() = changes.mapNotNullTo(mutableSetOf()) { change -> + get() = changes.flatMapTo(mutableSetOf()) { change -> when (change) { - is MetamodelChange.EntityTypeAdded -> change.typeName - is MetamodelChange.EntityTypeRemoved -> change.typeName - is MetamodelChange.EntityTypeModified -> change.typeName - is MetamodelChange.PropertySignatureChanged -> change.typeName - is MetamodelChange.RelationshipAdded -> null - is MetamodelChange.RelationshipRemoved -> null + is MetamodelChange.EntityTypeAdded -> listOf(change.typeName) + is MetamodelChange.EntityTypeRemoved -> listOf(change.typeName) + is MetamodelChange.EntityTypeRenamed -> listOf(change.before, change.after) + is MetamodelChange.EntityTypeAliasesChanged -> listOf(change.typeName) + is MetamodelChange.EntityTypeModified -> listOf(change.typeName) + is MetamodelChange.PropertyRenamed -> listOf(change.typeName) + is MetamodelChange.PropertySignatureChanged -> listOf(change.typeName) + is MetamodelChange.RelationshipAdded -> emptyList() + is MetamodelChange.RelationshipRemoved -> emptyList() } } diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt index 3904435c..307d4a94 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt @@ -40,6 +40,14 @@ import com.embabel.dice.metamodel.PropertySignature * pair of versions always produces the same change list in the same order, and a stored diff can be * compared with another one. * + * Declared former names make a rename pair up instead of reading as a removal and an addition. The + * pairing runs first, and everything after it compares the older version modulo the renames it + * found: a label or a reference target still spelled with an old name is the rename propagating, so + * it folds into the rename entry rather than reporting as loss on every referrer and every child. + * Rendered relationship descriptors are left alone, so a relationship touching a renamed type still + * churns; the names inside a descriptor are free text and are never parsed. See + * `docs/design/metamodel-diff.md`. + * * Stateless and thread-safe; one shared instance is fine. */ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { @@ -50,33 +58,58 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { val fromTypes = from.entityTypeNames.toSet() val toTypes = to.entityTypeNames.toSet() - (fromTypes - toTypes).sorted().mapTo(changes) { MetamodelChange.EntityTypeRemoved(it) } - (toTypes - fromTypes).sorted().mapTo(changes) { MetamodelChange.EntityTypeAdded(it) } + val renamedTypes = pairRenamedTypes( + removed = fromTypes - toTypes, + added = toTypes - fromTypes, + aliases = to.entityTypeAliases, + ) + + (fromTypes - toTypes - renamedTypes.keys).sorted() + .mapTo(changes) { MetamodelChange.EntityTypeRemoved(it) } + (toTypes - fromTypes - renamedTypes.values.toSet()).sorted() + .mapTo(changes) { MetamodelChange.EntityTypeAdded(it) } + + // The types to compare member by member: those named the same in both versions, plus each + // paired rename, which compares the old name's members against the new name's. Both are + // reported under the newer name and walked in that order, so the output is canonical. + val comparable = ((fromTypes intersect toTypes).map { it to it } + renamedTypes.toList()) + .sortedBy { (_, afterName) -> afterName } + + for ((beforeName, afterName) in comparable) { + if (beforeName != afterName) { + changes += MetamodelChange.EntityTypeRenamed(before = beforeName, after = afterName) + } + + val aliasesChange = compareAliasesOf(beforeName, afterName, from, to) + if (aliasesChange != null) changes += aliasesChange - // A type present in both versions can still have moved: labels, whole properties, or the - // shape of a property whose name stayed put. Walk them in name order so the output is - // canonical. - for (typeName in (fromTypes intersect toTypes).sorted()) { - val addedLabels = canonical(to.entityTypeLabels[typeName].orEmpty() - from.entityTypeLabels[typeName].orEmpty()) - val removedLabels = canonical(from.entityTypeLabels[typeName].orEmpty() - to.entityTypeLabels[typeName].orEmpty()) + // The before side is read modulo the renames this diff already found: a label or a + // reference target spelled with an old name is the rename propagating, and comparing it + // against the new spelling would report the rename again as loss. + val beforeLabels = substitute(from.entityTypeLabels[beforeName].orEmpty(), renamedTypes) + val afterLabels = to.entityTypeLabels[afterName].orEmpty() + val addedLabels = canonical(afterLabels - beforeLabels) + val removedLabels = canonical(beforeLabels - afterLabels) val properties = comparePropertiesOf( - typeName = typeName, - from = from.entityTypeProperties[typeName].orEmpty(), - to = to.entityTypeProperties[typeName].orEmpty(), + typeName = afterName, + from = from.entityTypeProperties[beforeName].orEmpty() + .mapTo(mutableSetOf()) { substitute(it, renamedTypes) }, + to = to.entityTypeProperties[afterName].orEmpty(), ) if (addedLabels.isNotEmpty() || removedLabels.isNotEmpty() || properties.added.isNotEmpty() || properties.removed.isNotEmpty() ) { changes += MetamodelChange.EntityTypeModified( - typeName = typeName, + typeName = afterName, addedLabels = addedLabels, removedLabels = removedLabels, addedProperties = properties.added, removedProperties = properties.removed, ) } + changes += properties.renames changes += properties.signatureChanges } @@ -98,7 +131,13 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { // observed labels against type names alone would call `Agent` undeclared drift on a schema // nobody had touched, so the declared side of the drift check is the type names plus every // label those types declare. - val declaredLabels = declaredTypes + declared.version.entityTypeLabels.values.flatten() + // + // Declared former names count as declared too. Nodes written before a type was renamed keep + // the old label, and the rename was declared, so the old label is known. Leaving it out + // would report a declared rename as drift on every check from then on. + val declaredLabels = declaredTypes + + declared.version.entityTypeLabels.values.flatten() + + declared.version.entityTypeAliases.values.flatten() // Drift is observed and never declared: orphaned data whose declaring integration is gone, // or was never registered. The opposite direction gets its own informational bucket, since a @@ -125,13 +164,139 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { ) } - /** The three ways a type's property set can differ, gathered in one pass. */ + /** The four ways a type's property set can differ, gathered in one pass. */ private data class PropertyDelta( val added: Set, val removed: Set, + val renames: List, val signatureChanges: List, ) + /** + * Pair each removed type with an added type that declares the removed name as a former name. + * + * Removed names are walked in sorted order and matched against added names in sorted order, + * which makes the pairing one-to-one and the same every run. An added type claiming two removed + * names takes the first of them, and the other stays an ordinary removal; a removed name + * claimed by two added types goes to the first of those, and the second stays an ordinary + * addition. + * + * Matching is against the whole declared alias set, so a type renamed twice still pairs across + * stamps that aren't adjacent. + * + * @return Old name to new name, empty when nothing paired. + */ + private fun pairRenamedTypes( + removed: Set, + added: Set, + aliases: Map>, + ): Map { + if (removed.isEmpty() || added.isEmpty() || aliases.isEmpty()) return emptyMap() + + val candidates = added.sorted() + val claimed = mutableSetOf() + val paired = LinkedHashMap() + for (removedName in removed.sorted()) { + val match = candidates.firstOrNull { addedName -> + addedName !in claimed && removedName in aliases[addedName].orEmpty() + } ?: continue + claimed += match + paired[removedName] = match + } + return paired + } + + /** + * Compare what two versions declare as one type's former names. + * + * When the type was itself renamed, the alias naming the old name is the rename and is reported + * as [MetamodelChange.EntityTypeRenamed], so it is left out of the comparison here. What is left + * is an alias added or retired on top of that, which moves the content hash and has to surface + * as something. + * + * @return The change, or `null` when the declarations agree. + */ + private fun compareAliasesOf( + beforeName: String, + afterName: String, + from: MetamodelVersion, + to: MetamodelVersion, + ): MetamodelChange.EntityTypeAliasesChanged? { + val before = from.entityTypeAliases[beforeName].orEmpty() + val after = to.entityTypeAliases[afterName].orEmpty() + val implied = if (beforeName == afterName) emptySet() else setOf(beforeName) + if (before - implied == after - implied) return null + return MetamodelChange.EntityTypeAliasesChanged( + typeName = afterName, + before = canonical(before), + after = canonical(after), + ) + } + + /** Rewrite any label that is a renamed type's old name into its new one. */ + private fun substitute(labels: Set, renames: Map): Set = + if (renames.isEmpty()) labels else labels.mapTo(mutableSetOf()) { renames[it] ?: it } + + /** + * Rewrite a reference property's target when it points at a renamed type. + * + * Only [PropertySignature.Kind.REFERENCE] targets are rewritten. A `VALUE` property's type is a + * free-text rendering of a JVM type, so an entity type named `Date` must not rewrite every + * property declared as holding a `Date` value. + */ + private fun substitute(signature: PropertySignature, renames: Map): PropertySignature { + if (renames.isEmpty() || signature.kind != PropertySignature.Kind.REFERENCE) return signature + val renamedTarget = renames[signature.type] ?: return signature + return signature.copy(type = renamedTarget) + } + + /** + * Pair each removed property name with an added signature that declares it as a former name. + * + * Runs on what is left after the name matching in [comparePropertiesOf], so a name present on + * both sides is never a candidate: an alias naming a property that still exists says nothing. + * Removed names are walked in sorted order against added signatures in sorted order, the same + * one-to-one discipline the type pairing uses, with the same outcome when one signature claims + * two old names or two signatures claim one. + * + * A name carrying more than one signature is left out. That is the type-merge path, where two + * same-named domain types each declare the property, and there is no single before or after to + * pair; it falls back to a removal and an addition, as it does for a shape change. + */ + private fun pairRenamedProperties( + typeName: String, + added: List, + removed: List, + fromNames: Set, + toNames: Set, + ): List { + val candidates = added + .groupBy { it.name } + .filter { (name, signatures) -> signatures.size == 1 && name !in fromNames } + .values + .map { it.single() } + .sorted() + if (candidates.isEmpty()) return emptyList() + + val goneNames = removed + .groupBy { it.name } + .filter { (name, signatures) -> signatures.size == 1 && name !in toNames } + .mapValues { (_, signatures) -> signatures.single() } + + val claimed = mutableSetOf() + val renames = mutableListOf() + for (removedName in goneNames.keys.sorted()) { + val match = candidates.firstOrNull { it.name !in claimed && removedName in it.aliases } ?: continue + claimed += match.name + renames += MetamodelChange.PropertyRenamed( + typeName = typeName, + before = goneNames.getValue(removedName), + after = match, + ) + } + return renames.sortedBy { it.after.name } + } + /** * Compare one type's properties, matching them up by name. * @@ -144,6 +309,9 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { * `DataDictionary` may hold two same-named domain types whose properties get unioned into a * single stamp. There is no single before/after to pair in that case, so the differing * signatures are reported as added and removed. + * + * Names that match nothing are then run through [pairRenamedProperties], which pairs a + * disappearing name with an arriving signature that declares it as a former name. */ private fun comparePropertiesOf( typeName: String, @@ -178,9 +346,20 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { } } + // Renames are paired off what is left, and the pair is then excluded from the added and + // removed sets, so one rename is one entry rather than a removal plus an addition too. + val renames = pairRenamedProperties( + typeName = typeName, + added = added, + removed = removed, + fromNames = fromByName.keys, + toNames = toByName.keys, + ) + return PropertyDelta( - added = canonical(added), - removed = canonical(removed), + added = canonical(added - renames.mapTo(mutableSetOf()) { it.after }), + removed = canonical(removed - renames.mapTo(mutableSetOf()) { it.before }), + renames = renames, signatureChanges = signatureChanges, ) } diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt index 5ca656f9..8bb18d3b 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt @@ -722,6 +722,600 @@ class MetamodelDifferTest { } } + /** + * A declared rename pairs an old name with a new one. Without the alias the same move is a + * removal plus an addition, which reads as loss on data nobody stranded. + */ + @Nested + inner class TypeRenames { + + @Test + fun `a renamed type with an alias is one change, not a removal and an addition`() { + val old = versionOf(listOf("Person")) + val new = versionOf(listOf("Human"), aliases = mapOf("Human" to setOf("Person"))) + val diff = differ.diff(old, new) + + assertEquals(listOf(MetamodelChange.EntityTypeRenamed("Person", "Human")), diff.changes) + assertTrue(diff.removedEntityTypes.isEmpty()) + assertTrue(diff.addedEntityTypes.isEmpty()) + assertTrue( + diff.modifiedEntityTypes.isEmpty(), + "the own-name label swap rides in the rename: ${diff.modifiedEntityTypes}", + ) + } + + @Test + fun `a rename without an alias is still a removal and an addition`() { + val diff = differ.diff(versionOf(listOf("Person")), versionOf(listOf("Human"))) + assertEquals(setOf("Person"), diff.removedEntityTypes) + assertEquals(setOf("Human"), diff.addedEntityTypes) + } + + /** + * `A` became `B` became `C`, and the stamp for `B` was never diffed against. Pairing matches + * the whole alias set, so `A` still pairs with `C`. The intermediate name comes with it: `C` + * claims a name the older stamp knew nothing about, which is an alias change on top of the + * rename. Diffing either hop on its own emits the rename alone. + */ + @Test + fun `an alias carried across two renames still pairs on a non-adjacent stamp`() { + val first = versionOf(listOf("A")) + val third = versionOf(listOf("C"), aliases = mapOf("C" to setOf("A", "B"))) + val diff = differ.diff(first, third) + + assertEquals( + listOf( + MetamodelChange.EntityTypeRenamed("A", "C"), + MetamodelChange.EntityTypeAliasesChanged("C", emptySet(), setOf("A", "B")), + ), + diff.changes, + ) + assertTrue(diff.removedEntityTypes.isEmpty()) + assertTrue(diff.addedEntityTypes.isEmpty()) + } + + @Test + fun `an added type claiming two removed names pairs with the first and leaves the other removed`() { + val old = versionOf(listOf("A", "B")) + val new = versionOf(listOf("C"), aliases = mapOf("C" to setOf("A", "B"))) + val diff = differ.diff(old, new) + + assertEquals( + listOf( + MetamodelChange.EntityTypeRemoved("B"), + MetamodelChange.EntityTypeRenamed("A", "C"), + MetamodelChange.EntityTypeAliasesChanged("C", emptySet(), setOf("A", "B")), + ), + diff.changes, + ) + } + + @Test + fun `a removed name claimed by two added types pairs with the first and leaves the other added`() { + val old = versionOf(listOf("A")) + val new = versionOf( + listOf("B", "C"), + aliases = mapOf("B" to setOf("A"), "C" to setOf("A")), + ) + val diff = differ.diff(old, new) + + assertEquals( + listOf( + MetamodelChange.EntityTypeAdded("C"), + MetamodelChange.EntityTypeRenamed("A", "B"), + ), + diff.changes, + ) + } + + @Test + fun `a renamed type reports everything else that moved under its new name`() { + val old = versionOf( + listOf("Person"), + labels = mapOf("Person" to setOf("Person", "Agent")), + properties = mapOf("Person" to setOf(valueProperty("age", "integer"))), + ) + val new = versionOf( + listOf("Human"), + labels = mapOf("Human" to setOf("Human", "Actor")), + properties = mapOf("Human" to setOf(valueProperty("age", "string"))), + aliases = mapOf("Human" to setOf("Person")), + ) + val diff = differ.diff(old, new) + + assertEquals( + listOf("EntityTypeRenamed", "EntityTypeModified", "PropertySignatureChanged"), + diff.changes.map { it::class.simpleName }, + ) + val modified = diff.modifiedEntityTypes.single() + assertEquals("Human", modified.typeName) + assertEquals(setOf("Actor"), modified.addedLabels) + assertEquals(setOf("Agent"), modified.removedLabels, "the parent label change is still reported") + assertEquals("Human", diff.propertySignatureChanges.single().typeName) + } + + @Test + fun `every change kind for one type comes out in canonical order`() { + val old = versionOf( + listOf("Person"), + labels = mapOf("Person" to setOf("Person", "Agent")), + properties = mapOf( + "Person" to setOf( + valueProperty("age", "integer"), + valueProperty("nickname", "string"), + ), + ), + ) + val new = versionOf( + listOf("Human"), + labels = mapOf("Human" to setOf("Human", "Actor")), + properties = mapOf( + "Human" to setOf( + valueProperty("years", "integer", aliases = setOf("age")), + valueProperty("nickname", "text"), + ), + ), + aliases = mapOf("Human" to setOf("Person", "Individual")), + ) + val diff = differ.diff(old, new) + + assertEquals( + listOf( + "EntityTypeRenamed", + "EntityTypeAliasesChanged", + "EntityTypeModified", + "PropertyRenamed", + "PropertySignatureChanged", + ), + diff.changes.map { it::class.simpleName }, + ) + val renamedProperty = diff.changes.filterIsInstance().single() + assertEquals("Human", renamedProperty.typeName, "reported under the type's new name") + assertEquals("age", renamedProperty.before.name) + assertEquals("years", renamedProperty.after.name) + } + + @Test + fun `touchedEntityTypes carries both names of a rename`() { + val diff = differ.diff( + versionOf(listOf("Person")), + versionOf(listOf("Human"), aliases = mapOf("Human" to setOf("Person"))), + ) + assertEquals(setOf("Person", "Human"), diff.touchedEntityTypes) + } + + /** + * Relationship descriptors embed type names as free text the differ compares as atoms and + * must never parse, so a relationship touching a renamed endpoint churns as a removal plus + * an addition alongside the rename. Known exception to reporting each difference once; + * quarantine ignores relationship changes, so it has no drift-policy effect. + */ + @Test + fun `a relationship touching a renamed type churns as a removal and an addition`() { + val old = versionOf( + listOf("Company", "Person"), + relationships = listOf("Person-[worksAt]->Company"), + ) + val new = versionOf( + listOf("Company", "Human"), + aliases = mapOf("Human" to setOf("Person")), + relationships = listOf("Human-[worksAt]->Company"), + ) + val diff = differ.diff(old, new) + + assertEquals( + listOf( + MetamodelChange.EntityTypeRenamed("Person", "Human"), + MetamodelChange.RelationshipRemoved("Person-[worksAt]->Company"), + MetamodelChange.RelationshipAdded("Human-[worksAt]->Company"), + ), + diff.changes, + ) + } + + @Test + fun `the same two versions produce the same ordered change list every run`() { + val old = versionOf( + listOf("A", "Person", "Referrer"), + labels = mapOf("Referrer" to setOf("Referrer", "Person")), + properties = mapOf( + "Referrer" to setOf( + referenceProperty("link", "A"), + valueProperty("age", "integer"), + ), + ), + ) + val new = versionOf( + listOf("B", "Human", "Referrer"), + labels = mapOf("Referrer" to setOf("Referrer", "Human")), + properties = mapOf( + "Referrer" to setOf( + referenceProperty("link", "B"), + valueProperty("years", "integer", aliases = setOf("age")), + ), + ), + aliases = mapOf("B" to setOf("A"), "Human" to setOf("Person")), + ) + + val first = differ.diff(old, new).changes + repeat(20) { + assertEquals(first, differ.diff(old, new).changes) + assertEquals(first, differ.diff(rebuilt(old), rebuilt(new)).changes) + } + } + } + + @Nested + inner class PropertyRenames { + + @Test + fun `a renamed property with an alias is one change, not an add and a remove`() { + val old = versionOf(listOf("Person"), properties = mapOf("Person" to setOf(valueProperty("age", "integer")))) + val new = versionOf( + listOf("Person"), + properties = mapOf("Person" to setOf(valueProperty("years", "integer", aliases = setOf("age")))), + ) + val diff = differ.diff(old, new) + + assertEquals( + listOf( + MetamodelChange.PropertyRenamed( + typeName = "Person", + before = valueProperty("age", "integer"), + after = valueProperty("years", "integer", aliases = setOf("age")), + ), + ), + diff.changes, + ) + assertTrue( + diff.modifiedEntityTypes.isEmpty(), + "an EntityTypeModified emptied by pairing is not emitted: ${diff.modifiedEntityTypes}", + ) + } + + @Test + fun `a property renamed and retyped in one step carries the shape move inside the rename`() { + val old = versionOf(listOf("Person"), properties = mapOf("Person" to setOf(valueProperty("age", "string")))) + val new = versionOf( + listOf("Person"), + properties = mapOf( + "Person" to setOf( + valueProperty("years", "integer", cardinality = Cardinality.LIST, aliases = setOf("age")), + ), + ), + ) + val renamed = differ.diff(old, new).changes.filterIsInstance().single() + + assertTrue(renamed.typeChanged) + assertTrue(renamed.cardinalityChanged) + assertFalse(renamed.kindChanged) + } + + @Test + fun `an added signature claiming two removed names pairs with the first and leaves the other removed`() { + val old = versionOf( + listOf("Person"), + properties = mapOf("Person" to setOf(valueProperty("a"), valueProperty("b"))), + ) + val new = versionOf( + listOf("Person"), + properties = mapOf("Person" to setOf(valueProperty("c", aliases = setOf("a", "b")))), + ) + val diff = differ.diff(old, new) + + val renamed = diff.changes.filterIsInstance().single() + assertEquals("a", renamed.before.name) + assertEquals("c", renamed.after.name) + val modified = diff.modifiedEntityTypes.single() + assertEquals(setOf("b"), modified.removedPropertyNames) + assertTrue(modified.addedProperties.isEmpty()) + } + + @Test + fun `a removed name claimed by two added signatures pairs with the first and leaves the other added`() { + val old = versionOf(listOf("Person"), properties = mapOf("Person" to setOf(valueProperty("a")))) + val new = versionOf( + listOf("Person"), + properties = mapOf( + "Person" to setOf( + valueProperty("b", aliases = setOf("a")), + valueProperty("c", aliases = setOf("a")), + ), + ), + ) + val diff = differ.diff(old, new) + + val renamed = diff.changes.filterIsInstance().single() + assertEquals("b", renamed.after.name) + val modified = diff.modifiedEntityTypes.single() + assertEquals(setOf("c"), modified.addedPropertyNames) + assertTrue(modified.removedProperties.isEmpty()) + } + + /** + * The type-merge path: two same-named domain types each declare `age` with a different + * shape, so the name carries two signatures and there is no single before to pair. + */ + @Test + fun `a removed name carrying two signatures falls back to a removal and an addition`() { + val old = versionOf( + listOf("Person"), + properties = mapOf( + "Person" to setOf( + valueProperty("age", "string"), + valueProperty("age", "integer"), + ), + ), + ) + val new = versionOf( + listOf("Person"), + properties = mapOf("Person" to setOf(valueProperty("years", "integer", aliases = setOf("age")))), + ) + val diff = differ.diff(old, new) + + assertTrue( + diff.changes.filterIsInstance().isEmpty(), + "no honest pairing exists for a name with two signatures: ${diff.changes}", + ) + val modified = diff.modifiedEntityTypes.single() + assertEquals(setOf("age"), modified.removedPropertyNames) + assertEquals(setOf("years"), modified.addedPropertyNames) + } + + @Test + fun `an alias naming a property that still exists pairs nothing`() { + val old = versionOf(listOf("Person"), properties = mapOf("Person" to setOf(valueProperty("age", "integer")))) + val new = versionOf( + listOf("Person"), + properties = mapOf( + "Person" to setOf( + valueProperty("age", "integer"), + valueProperty("years", "integer", aliases = setOf("age")), + ), + ), + ) + val diff = differ.diff(old, new) + + assertTrue(diff.changes.filterIsInstance().isEmpty()) + assertEquals(setOf("years"), diff.modifiedEntityTypes.single().addedPropertyNames) + } + } + + /** + * A rename propagates: referrers point at the new name, children inherit the new label. Those + * are the rename showing up again, so the before side is read modulo the renames the diff + * already paired, and what vanishes under that substitution folds into the rename. + */ + @Nested + inner class ComparisonModuloRenames { + + @Test + fun `a reference to a renamed type and an inherited label both fold into the rename`() { + val old = versionOf( + listOf("Employee", "Person"), + labels = mapOf("Employee" to setOf("Employee", "Person")), + properties = mapOf("Employee" to setOf(referenceProperty("boss", "Person"))), + ) + val new = versionOf( + listOf("Employee", "Human"), + labels = mapOf("Employee" to setOf("Employee", "Human")), + properties = mapOf("Employee" to setOf(referenceProperty("boss", "Human"))), + aliases = mapOf("Human" to setOf("Person")), + ) + val diff = differ.diff(old, new) + + assertEquals(listOf(MetamodelChange.EntityTypeRenamed("Person", "Human")), diff.changes) + assertTrue( + diff.modifiedEntityTypes.isEmpty(), + "an EntityTypeModified emptied by substitution is not emitted: ${diff.modifiedEntityTypes}", + ) + } + + @Test + fun `a referrer that moved to a third type reports the residual in substituted-before form`() { + val old = versionOf( + listOf("A", "D", "Referrer"), + properties = mapOf("Referrer" to setOf(referenceProperty("link", "A"))), + ) + val new = versionOf( + listOf("B", "D", "Referrer"), + properties = mapOf("Referrer" to setOf(referenceProperty("link", "D"))), + aliases = mapOf("B" to setOf("A")), + ) + val diff = differ.diff(old, new) + + assertEquals( + listOf("EntityTypeRenamed", "PropertySignatureChanged"), + diff.changes.map { it::class.simpleName }, + ) + val change = diff.propertySignatureChanges.single() + assertEquals("B", change.before.type, "the A->B half already rides in EntityTypeRenamed") + assertEquals("D", change.after.type) + } + + /** + * A `VALUE` property's type is a free-text rendering of a JVM type. An entity type named + * `Date` renaming to `Timestamp` must not rewrite every property that holds a `Date` value, + * which is why substitution is scoped to `REFERENCE` targets. + */ + @Test + fun `a value type spelled like a renamed entity type is left alone`() { + val old = versionOf( + listOf("Date", "Person"), + properties = mapOf( + "Person" to setOf( + valueProperty("birthday", "Date"), + referenceProperty("meetsOn", "Date"), + ), + ), + ) + val new = versionOf( + listOf("Person", "Timestamp"), + properties = mapOf( + "Person" to setOf( + valueProperty("birthday", "Date"), + referenceProperty("meetsOn", "Timestamp"), + ), + ), + aliases = mapOf("Timestamp" to setOf("Date")), + ) + val diff = differ.diff(old, new) + + assertEquals(listOf(MetamodelChange.EntityTypeRenamed("Date", "Timestamp")), diff.changes) + assertFalse(diff.touchedEntityTypes.contains("Person"), "nothing on Person moved") + } + } + + /** + * Aliases are part of the hash, so declaring or retiring one has to surface as a change for an + * empty diff and an equal content hash to keep meaning the same thing. + */ + @Nested + inner class AliasOnlyChanges { + + @Test + fun `a property whose aliases alone changed is an ordinary signature change`() { + val old = versionOf(listOf("Person"), properties = mapOf("Person" to setOf(valueProperty("age", "integer")))) + val new = versionOf( + listOf("Person"), + properties = mapOf("Person" to setOf(valueProperty("age", "integer", aliases = setOf("yearsOld")))), + ) + val diff = differ.diff(old, new) + + val change = diff.propertySignatureChanges.single() + assertEquals("age", change.propertyName) + assertEquals(emptySet(), change.before.aliases) + assertEquals(setOf("yearsOld"), change.after.aliases) + assertFalse(change.typeChanged) + assertFalse(change.cardinalityChanged) + assertFalse(change.kindChanged) + } + + @Test + fun `a type whose aliases alone changed is an EntityTypeAliasesChanged`() { + val old = versionOf(listOf("Human"), aliases = mapOf("Human" to setOf("Person"))) + val new = versionOf(listOf("Human"), aliases = mapOf("Human" to setOf("Person", "Individual"))) + val diff = differ.diff(old, new) + + val change = diff.changes.filterIsInstance().single() + assertEquals("Human", change.typeName) + assertEquals(setOf("Person"), change.before) + assertEquals(setOf("Individual", "Person"), change.after) + assertEquals(1, diff.changes.size, "nothing about the data moved: ${diff.changes}") + } + + @Test + fun `a retired type alias is reported the same way`() { + val old = versionOf(listOf("Human"), aliases = mapOf("Human" to setOf("Person"))) + val new = versionOf(listOf("Human")) + val change = differ.diff(old, new).changes + .filterIsInstance() + .single() + assertEquals(setOf("Person"), change.before) + assertTrue(change.after.isEmpty()) + } + + @Test + fun `a pure rename emits no alias change beside it`() { + val diff = differ.diff( + versionOf(listOf("Person")), + versionOf(listOf("Human"), aliases = mapOf("Human" to setOf("Person"))), + ) + assertTrue( + diff.changes.filterIsInstance().isEmpty(), + "the rename's own alias entry rides in EntityTypeRenamed: ${diff.changes}", + ) + } + + @Test + fun `a rename that also declares an unrelated former name reports both`() { + val old = versionOf(listOf("Person")) + val new = versionOf(listOf("Human"), aliases = mapOf("Human" to setOf("Person", "Individual"))) + val diff = differ.diff(old, new) + + assertEquals( + listOf("EntityTypeRenamed", "EntityTypeAliasesChanged"), + diff.changes.map { it::class.simpleName }, + ) + } + + @Test + fun `an alias carried through a rename is not reported as an alias change`() { + val old = versionOf(listOf("B"), aliases = mapOf("B" to setOf("A"))) + val new = versionOf(listOf("C"), aliases = mapOf("C" to setOf("A", "B"))) + assertEquals(listOf(MetamodelChange.EntityTypeRenamed("B", "C")), differ.diff(old, new).changes) + } + + @Test + fun `equal content hashes still mean an empty diff when both kinds of alias are declared`() { + val old = aliasedStamp() + val new = aliasedStamp() + assertTrue(old.hasSameContentAs(new)) + assertTrue(differ.diff(old, new).isEmpty, "equal hash, empty diff: ${differ.diff(old, new).changes}") + } + + @Test + fun `an alias-only difference of either kind moves the hash and produces a change`() { + val base = aliasedStamp() + + val typeAliasMoved = aliasedStamp(typeAliases = setOf("Person", "Individual")) + assertFalse(base.hasSameContentAs(typeAliasMoved)) + assertFalse(differ.diff(base, typeAliasMoved).isEmpty) + + val propertyAliasMoved = aliasedStamp(propertyAliases = setOf("yearsOld", "howOld")) + assertFalse(base.hasSameContentAs(propertyAliasMoved)) + assertFalse(differ.diff(base, propertyAliasMoved).isEmpty) + } + + /** A stamp carrying both kinds of alias, so a rebuild of it hashes and diffs identically. */ + private fun aliasedStamp( + typeAliases: Set = setOf("Person"), + propertyAliases: Set = setOf("yearsOld"), + ): MetamodelVersion = versionOf( + listOf("Human"), + properties = mapOf("Human" to setOf(valueProperty("age", "integer", aliases = propertyAliases))), + aliases = mapOf("Human" to typeAliases), + ) + } + + @Nested + inner class RenamesAgainstObserved { + + private val declared = DeclaredSchema( + version = versionOf(listOf("Human"), aliases = mapOf("Human" to setOf("Person"))), + relationshipTypeNames = emptySet(), + ) + + private fun observed(vararg entityTypeNames: String): ObservedSchema = ObservedSchema( + entityTypeNames = entityTypeNames.toSet(), + relationshipTypeNames = emptySet(), + capturedAt = Instant.parse("2026-01-01T00:00:00Z"), + ) + + @Test + fun `data still labelled with a renamed type's old name is not drift`() { + val diff = declaredObservedDiffer.diffAgainstObserved(declared, observed("Human", "Person")) + assertFalse(diff.hasDrift, "the rename was declared, so the old label is known: ${diff.driftedEntityTypes}") + assertTrue(diff.driftedEntityTypes.isEmpty()) + } + + @Test + fun `a label matching neither a declared type nor a declared former name is still drift`() { + val diff = declaredObservedDiffer.diffAgainstObserved(declared, observed("Human", "GhostIntegrationType")) + assertTrue(diff.hasDrift) + assertEquals(setOf("GhostIntegrationType"), diff.driftedEntityTypes) + } + + @Test + fun `a former name is not reported as an unobserved type`() { + val diff = declaredObservedDiffer.diffAgainstObserved(declared, observed("Human")) + assertTrue( + diff.unobservedEntityTypes.isEmpty(), + "a former name was never a type of its own: ${diff.unobservedEntityTypes}", + ) + } + } + /** * A finished diff must not be reshapeable. Mirrors `MetamodelVersionTest.Immutability`: Kotlin's * read-only collection types are a compile-time promise only, and a Java caller sees plain @@ -784,6 +1378,25 @@ class MetamodelDifferTest { } } + @Test + fun `the alias sets inside an alias change cannot be mutated`() { + val declaredNames = mutableSetOf("Person") + val change = MetamodelChange.EntityTypeAliasesChanged("Human", declaredNames, setOf("Person", "Individual")) + + declaredNames += "Sneaky" + assertEquals(setOf("Person"), change.before) + + @Suppress("UNCHECKED_CAST") + assertThrows { + (change.before as MutableSet).add("Sneaky") + } + + @Suppress("UNCHECKED_CAST") + assertThrows { + (change.after as MutableSet).clear() + } + } + @Test fun `the sets a declared-observed diff hands back cannot be mutated`() { val diff = StructuralMetamodelDiffer().diffAgainstObserved( @@ -863,5 +1476,50 @@ class MetamodelDifferTest { entityTypeProperties = types.toMap(), relationshipNames = emptyList(), ) + + /** + * A stamp assembled field by field, for the rename cases: declared former names, references + * between types, and label sets that carry a parent. A type's own name is one of its labels + * unless [labels] says otherwise, which is what a real stamp holds and what makes the + * rename's own-name label swap show up. + */ + private fun versionOf( + types: List, + labels: Map> = emptyMap(), + properties: Map> = emptyMap(), + aliases: Map> = emptyMap(), + relationships: List = emptyList(), + ): MetamodelVersion = MetamodelVersion( + schemaName = "test", + entityTypeNames = types, + entityTypeLabels = types.associateWith { labels[it] ?: setOf(it) }, + entityTypeProperties = types.associateWith { properties[it].orEmpty() }, + relationshipNames = relationships, + entityTypeAliases = aliases, + ) + + /** A structurally identical rebuild, so a determinism check doesn't ride on instance reuse. */ + private fun rebuilt(version: MetamodelVersion): MetamodelVersion = MetamodelVersion( + schemaName = version.schemaName, + entityTypeNames = version.entityTypeNames, + entityTypeLabels = version.entityTypeLabels, + entityTypeProperties = version.entityTypeProperties, + relationshipNames = version.relationshipNames, + entityTypeAliases = version.entityTypeAliases, + ) + + private fun valueProperty( + name: String, + type: String = "string", + cardinality: Cardinality = Cardinality.ONE, + aliases: Set = emptySet(), + ): PropertySignature = PropertySignature(name, PropertySignature.Kind.VALUE, type, cardinality, aliases) + + private fun referenceProperty( + name: String, + target: String, + cardinality: Cardinality = Cardinality.ONE, + aliases: Set = emptySet(), + ): PropertySignature = PropertySignature(name, PropertySignature.Kind.REFERENCE, target, cardinality, aliases) } } diff --git a/docs/design/metamodel-diff.md b/docs/design/metamodel-diff.md index 4c02190d..e04b500a 100644 --- a/docs/design/metamodel-diff.md +++ b/docs/design/metamodel-diff.md @@ -47,12 +47,16 @@ over, where an unhandled change kind would be treated as harmless. | Kind | What it means | | --- | --- | | `EntityTypeAdded` / `EntityTypeRemoved` | A type appeared or disappeared. | +| `EntityTypeRenamed` | A type changed name, paired up through a former name the newer declaration carries. | +| `EntityTypeAliasesChanged` | A type's declared former names changed, with nothing else about the type moving. | | `EntityTypeModified` | A type present in both versions gained or lost labels, or gained or lost whole properties (matched by name), with the full signature of each. | +| `PropertyRenamed` | A property changed name, paired up through a former name its new signature carries. Carries `before` and `after`. | | `PropertySignatureChanged` | A property kept its name but changed shape: its type, its cardinality, or whether it holds a value or points at another type. Carries `before` and `after`. | | `RelationshipAdded` / `RelationshipRemoved` | An allowed relationship descriptor appeared or disappeared. | The kinds don't overlap. Each difference is reported exactly once, by whichever kind describes it -most precisely. +most precisely. Rendered relationship descriptors are the exception, and +[Where exactly-once stops](#where-exactly-once-stops) says why. `PropertySignatureChanged` is why the stamp carries signatures rather than property names. `age` turning from a string into an integer, or a single `worksAt` becoming a list of them, changes what @@ -77,6 +81,139 @@ leaving the differ is sorted first. Sets are compared as sets, never as a delimi projection: these names come from free text and LLM extraction and routinely contain commas and spaces, so `{"a", "b c"}` and `{"a b", "c"}` must not collapse into the same thing. +Entity-type changes come first, by type name, then relationship changes. Within one type the order +is `EntityTypeRenamed`, `EntityTypeAliasesChanged`, `EntityTypeModified`, `PropertyRenamed` by new +property name, then `PropertySignatureChanged` by property name. A renamed type is filed under its +new name. + +## Declared renames + +Names are identity in a stamp, so a property or type that changes name disappears and reappears. +That reads as loss on data nobody stranded, and the quarantine slice acts on that reading. Iceberg +and Delta avoid it with stable field ids minted when a column is created; DICE extracts its schema +from LLM output and has no id to mint, so the declaration carries the old name instead. +`SchemaAliases` puts former names into the stamp — `MetamodelVersion.entityTypeAliases` per type, +`PropertySignature.aliases` per property — and the differ pairs on them. + +Aliases accumulate. A type renamed `A` to `B` to `C` declares `{A, B}`, and pairing matches the +whole set, so a diff across stamps that aren't adjacent still pairs. Names are exact and +case-sensitive. + +### The pairing rule + +Pairing runs on what is left after the ordinary name matching, so a name present on both sides is +never a candidate: an alias naming a property that still exists says nothing, and matches nothing. +Removed names are walked in sorted order against the added entries in sorted order, which makes the +result one-to-one and identical every run. + +```mermaid +flowchart TD + start["a removed name
(walked in sorted order)"] + dup{"a property name carrying
more than one signature?"} + scan{"an unclaimed added entry, in
sorted order, declaring this
name as a former name?"} + pair["pair them: EntityTypeRenamed
or PropertyRenamed"] + exclude["exclude both from the added
and removed sets"] + empty{"anything left in the
EntityTypeModified?"} + emit["emit EntityTypeModified"] + drop["emit nothing"] + plain["ordinary removal:
EntityTypeRemoved, or removedProperties"] + + start --> dup + dup -- "yes: the type-merge path" --> plain + dup -- "no" --> scan + scan -- "yes" --> pair + scan -- "no" --> plain + pair --> exclude + exclude --> empty + empty -- "yes" --> emit + empty -- "no" --> drop +``` + +Two edge cases the sorted walk settles. One added entry declaring two removed names pairs with the +first of them, and the other stays an ordinary removal. One removed name claimed by two added +entries pairs with the first of those, and the second stays an ordinary addition. Either way the +rename is reported once. + +The type-merge branch is the same exception `EntityTypeModified` already documents: a +`DataDictionary` may hold two same-named domain types whose properties get unioned, so one property +name can carry two signatures, and there is no single before to pair. Declaring an alias on such a +name is refused at declaration time, in `DeclaredSchema.from` and `MetamodelVersion.from`; the +comparison falls back to a removal and an addition. + +Paired properties are excluded from `EntityTypeModified.addedProperties` and `removedProperties`, +and an entry left with nothing in it is not emitted, so its at-least-one-set-non-empty contract +holds. +When paired signatures differ in more than the name, the whole delta rides inside `PropertyRenamed`, +whose `typeChanged`, `cardinalityChanged` and `kindChanged` read the same way they do on +`PropertySignatureChanged`. + +A paired type rename suppresses the `EntityTypeAdded`/`EntityTypeRemoved` pair. Whatever else moved +on the type is diffed between the two paired types and reported under the new name. + +### Comparison modulo renames + +A type's own name is one of its labels, children inherit it, and other types point at it. Renaming +`Person` to `Human` therefore shows up again on every referrer's reference target and every child's +label set. Reported literally, a declared rename becomes label loss and signature loss across the +schema, and the quarantine policy sweeps the rename's own ripples. + +So after pairing, the older version is read modulo the renames this diff found. The differ +substitutes old name for new in exactly two places: + +1. the `type` field of `Kind.REFERENCE` property signatures, and +2. label sets. + +A delta that vanishes under the substitution is the rename propagating and folds into +`EntityTypeRenamed`. A delta that survives is reported and judged normally, stated in substituted +form: a referrer that moved from `A` to `D` while `A` was renamed to `B` reports `B → D`, because +the `A → B` half already rides in the rename. An `EntityTypeModified` emptied by substitution is +not emitted, the same as one emptied by pairing. + +`Kind.VALUE` type strings are never substituted. A value type is a free-text rendering of a JVM type +copied verbatim from the dictionary, so a schema holding an entity type named `Date` that renames to +`Timestamp` would otherwise rewrite every `birthday: Date` value property in the older version and +report a change on each of them. + +Substitution goes by name, so a label or reference target that merely shares a renamed type's old +name without deriving from it — reachable when an unselected parent contributes its name as a label +while a governed type of the same name renames — is rewritten too, and can surface as label churn +on a type nothing touched. The same-name merge corner it takes to get there is already documented +as its own hazard; this is one more reason a schema should not lean on it. + +### Where exactly-once stops + +Relationships are a knowing exception. `MetamodelVersion.relationshipNames` holds rendered +`From-[name]->To` descriptors, and these names are free text that can contain a `-[...]->`-shaped +substring, so the differ compares them as atoms and never parses one. A relationship touching a +renamed endpoint therefore churns as a `RelationshipRemoved` plus a `RelationshipAdded` beside the +`EntityTypeRenamed` that already described the move. Exactly-once holds for entity-type and property +changes. Quarantine ignores relationship changes, so this costs a duplicated line in a report and +nothing else. + +### Alias-only changes + +Aliases are hashed, so declaring or retiring one moves `contentHash`, and an empty diff has to keep +meaning the same thing as an equal hash. A property whose signature moved only in its aliases is an +ordinary `PropertySignatureChanged` with `typeChanged`, `cardinalityChanged` and `kindChanged` all +false. A type whose declared former names moved is an `EntityTypeAliasesChanged`. Neither says +anything about stored data. + +The alias a rename implies rides in `EntityTypeRenamed`. `Human` declaring `Person` as a former name +is what made the pairing, so a rename emits `EntityTypeRenamed` alone and no alias change beside it. +What the declaration says beyond that still reports. Diffing across stamps that aren't adjacent is +where this shows: comparing the stamp for `A` against the stamp for `C`, where `C` declares +`{A, B}`, pairs `A` with `C` and reports the intermediate name `B` as an alias change, because the +older stamp never knew `C` claims it. Each hop diffed on its own emits the rename alone. + +### Renames and the observed side + +Declared former names join the declared side of the drift comparison, alongside type names and +labels. Nodes written before a rename keep the old label, and the rename was declared, so the old +label is known and is not drift. The alternative — keeping it red as a migration signal — makes +`hasDrift` permanently true on a schema whose rename was explicit, which trains operators to ignore +the report; migration progress belongs to run lineage. Former names stay out of the unobserved +direction, for the same reason parent labels do: a former name was never a type of its own. + ## Declared vs. observed, and the asymmetry `ObservedSchema` is what a live graph holds: entity labels, relationship types, and when the @@ -98,10 +235,12 @@ informational one. **A declared label counts as declared.** A graph reports labels, and a type carries every label in its hierarchy: declare `Person` with parent `Agent` and every Person node comes back carrying both. -So the declared side of the drift check is every entity type name *plus* every label those types -declare. Comparing against type names alone would report `Agent` as undeclared drift on a schema -nobody had touched. The unobserved direction stays on type names, because "declared but with no -data" is a statement about types, and a parent label was never a type in its own right. +So the declared side of the drift check is every entity type name, every label those types declare, +and every former name they declare. Comparing against type names alone would report `Agent` as +undeclared drift on a schema nobody had touched, and dropping the former names would do the same to +data written before a declared rename. The unobserved direction stays on type names, because +"declared but with no data" is a statement about types, and neither a parent label nor a former name +was a type in its own right. **The observed side is names only.** A graph can report which labels and relationship types exist in it. It cannot report what a property was *declared* to be: two nodes with the same label can carry From 8c58e08f1a3e9d990f6468590b6a22e642e8c04a Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:32:28 -0400 Subject: [PATCH 4/8] Report contested rename claims instead of picking one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairing now requires an exclusive claim on both sides. Claims read as a bipartite graph: a connected one-to-one group is a rename, anything larger is contested — reported whole through two new sealed members and fallen back to plain removed and added, never fed to modulo-rename substitution. Report rather than throw, because both sides of a diff are stamped persisted values a caller cannot edit. Typed accessors round out the change list (the ambiguity pair, added and removed relationships), with an eleven-change partition fixture that makes every accessor load-bearing. --- CHANGELOG.md | 35 +- .../embabel/dice/metamodel/MetamodelDiff.kt | 161 ++++- .../support/StructuralMetamodelDiffer.kt | 235 +++++-- .../dice/metamodel/MetamodelDifferTest.kt | 574 ++++++++++++++++-- docs/design/metamodel-diff.md | 80 ++- 5 files changed, 936 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56421f65..9436ce01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,16 +93,26 @@ and the consumer PRs that deliver it). runner, no quarantine, no Spring wiring, and no new dependency. **Compatibility: additive.** New types in an existing module; no existing API touched. -- Declared renames in the diff, **EXPERIMENTAL** (shape may change before 1.0): three new +- Declared renames in the diff, **EXPERIMENTAL** (shape may change before 1.0): five new `MetamodelChange` members — `PropertyRenamed(typeName, before, after)`, - `EntityTypeRenamed(before, after)` and `EntityTypeAliasesChanged(typeName, before, after)`. + `EntityTypeRenamed(before, after)`, `EntityTypeAliasesChanged(typeName, before, after)`, + `AmbiguousEntityTypeRename(formerNames, candidates)` and + `AmbiguousPropertyRename(typeName, formerNames, candidates)`. A rename declared through `SchemaAliases` now pairs instead of reading as a removal and an - addition. Pairing runs on what is left after the ordinary name matching, walks removed - names in sorted order against added entries in sorted order, and is one-to-one: one added - entry claiming two removed names takes the first and leaves the other an ordinary removal, - one removed name claimed by two added entries goes to the first and leaves the second an - ordinary addition, and a property name the type-merge path holds two signatures for falls - back to a removal and an addition. Paired properties are excluded from + addition. Pairing runs on what is left after the ordinary name matching and needs an + exclusive claim on both sides, among the claims in the running: the old name is claimed by + one new name alone, and that new name claims one old name alone. A surviving type's alias on + the removed name, and a property name carrying two signatures, never enter the running and so + contest nothing. Anything else is contested and pairs nothing — two new + types both declaring `Person` as a former name, one new type claiming two old names that + were both live, or claims that chain the two together. The contested names all report as + ordinary additions and removals, and one `AmbiguousEntityTypeRename` or + `AmbiguousPropertyRename` carries the whole group, so a declaration the differ set aside is + visible rather than silent. It reports rather than throws: a schema in this state stamps + cleanly today, and a caller comparing two historical stamps out of a store cannot edit + either side. A property name the type-merge path holds two signatures for is out of the + running before claims are read and falls back to a removal and an addition. Paired + properties are excluded from `EntityTypeModified.addedProperties`/`removedProperties`, and an entry left empty by that is not emitted. Type pairing matches the whole accumulated alias set, so a type renamed twice still pairs across stamps that aren't adjacent, and it suppresses the @@ -121,9 +131,14 @@ and the consumer PRs that deliver it). is an `EntityTypeAliasesChanged`. Declared former names join the declared side of `diffAgainstObserved`, so data still carrying a renamed type's old label is not drift. `MetamodelDiff.touchedEntityTypes` covers the new kinds, and contributes both names of a - rename. + rename and every name in a contested claim. `MetamodelDiff` gains four typed accessors — + `ambiguousEntityTypeRenames`, `ambiguousPropertyRenames`, `addedRelationships` and + `removedRelationships` — so the only kinds still reached through `filterIsInstance` are + `EntityTypeRenamed`, `EntityTypeAliasesChanged` and `PropertyRenamed`, whose accessors land + with the drift slice. A partition test pins the accessors against the change list on a diff + holding every kind they cover. **Compatibility: breaking for external exhaustive `when` expressions.** `MetamodelChange` - is a sealed interface, and three new members make any `when` over it outside this repo + is a sealed interface, and five new members make any `when` over it outside this repo non-exhaustive until it handles them. Stated and accepted: the taxonomy is designed to be exhausted, and a consumer that silently treated a rename as an unhandled kind would treat it as harmless. Nothing else changes for a schema that declares no aliases — pairing and diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt index 633f8fce..1bd29084 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt @@ -46,6 +46,10 @@ private fun immutableCopy(values: List): List = java.util.List.copyOf( * differ compares as atoms: a relationship touching a renamed type churns as a * [RelationshipRemoved] plus a [RelationshipAdded] alongside the [EntityTypeRenamed] that already * described the same move. See `docs/design/metamodel-diff.md`. + * + * [AmbiguousEntityTypeRename] and [AmbiguousPropertyRename] sit outside that accounting. They + * describe a declaration the differ could not act on rather than a structural difference, and the + * names they carry are reported as ordinary removals and additions beside them. */ sealed interface MetamodelChange { @@ -93,6 +97,63 @@ sealed interface MetamodelChange { } } + /** + * Former names that more than one new type lays claim to, or a new type claiming more than one + * former name that was still live. The differ refuses to guess which move was the rename. + * + * Pairing needs an exclusive claim: one old name, one new type, and neither of them wanted by + * anything else. Two new types both declaring `Person` as a former name is a schema saying two + * incompatible things, and choosing one would attach `Person`'s label, property and reference + * history to a type nobody nominated. So nothing pairs here. Every name in [formerNames] is + * reported as an [EntityTypeRemoved], every name in [candidates] as an [EntityTypeAdded], the + * reading a schema with no aliases at all would get, and this entry records that a declaration + * was set aside and why. + * + * A declaration can reach this state honestly, which is why it reports rather than throws. + * `MetamodelVersion` refuses an alias naming a type the same schema still declares, and says + * nothing about two types sharing one former name; and the two sides of a diff are two + * independently stamped versions, so a caller comparing stamps out of a store has no way to + * fix the input. The way out is in the declaration: retire the alias from the types that + * should not carry it, or stamp the moves separately so each rename stands alone. + * + * Experimental: shape may change before 1.0. + * + * @property formerNames The contested old names, all of them gone from the newer version. + * @property candidates The new type names claiming them. + */ + class AmbiguousEntityTypeRename( + formerNames: Set, + candidates: Set, + ) : MetamodelChange { + + // Copied, and a plain class rather than a `data class`, for the same reason as + // EntityTypeModified: a generated copy() would hand its argument straight to the field. + val formerNames: Set = immutableCopy(formerNames) + + val candidates: Set = immutableCopy(candidates) + + init { + require(this.formerNames.isNotEmpty() && this.candidates.isNotEmpty()) { + "AmbiguousEntityTypeRename needs names on both sides, but got " + + "formerNames=${this.formerNames}, candidates=${this.candidates}" + } + require(this.formerNames.size > 1 || this.candidates.size > 1) { + "AmbiguousEntityTypeRename describes a contested claim, but one former name and " + + "one candidate is an ordinary rename: ${this.formerNames} to ${this.candidates}" + } + } + + override fun equals(other: Any?): Boolean = + other is AmbiguousEntityTypeRename && + formerNames == other.formerNames && + candidates == other.candidates + + override fun hashCode(): Int = Objects.hash(formerNames, candidates) + + override fun toString(): String = + "AmbiguousEntityTypeRename(formerNames=$formerNames, candidates=$candidates)" + } + /** * An entity type present in both versions whose declared former names changed: an alias was * added or retired. @@ -252,6 +313,62 @@ sealed interface MetamodelChange { val kindChanged: Boolean get() = before.kind != after.kind } + /** + * The same contested claim as [AmbiguousEntityTypeRename], one level down: property names on + * one type that more than one new signature claims, or one new signature claiming more than one + * old name. + * + * Nothing pairs. Every name in [formerNames] stays in + * [EntityTypeModified.removedProperties] and every name in [candidates] in + * [EntityTypeModified.addedProperties], and this entry records that the declaration was set + * aside. Choosing between the claimants would say a property's data carried over into a + * signature the operator never named. + * + * Experimental: shape may change before 1.0. + * + * @property typeName The entity type carrying the properties, under its name in the newer + * version. + * @property formerNames The contested old property names, all of them gone from the newer + * version. + * @property candidates The new property names claiming them. + */ + class AmbiguousPropertyRename( + typeName: String, + formerNames: Set, + candidates: Set, + ) : MetamodelChange { + + val typeName: String = typeName + + // Copied, and a plain class, for the same reason as AmbiguousEntityTypeRename. + val formerNames: Set = immutableCopy(formerNames) + + val candidates: Set = immutableCopy(candidates) + + init { + require(this.formerNames.isNotEmpty() && this.candidates.isNotEmpty()) { + "AmbiguousPropertyRename needs names on both sides, but got " + + "formerNames=${this.formerNames}, candidates=${this.candidates} on '$typeName'" + } + require(this.formerNames.size > 1 || this.candidates.size > 1) { + "AmbiguousPropertyRename describes a contested claim, but one former name and one " + + "candidate is an ordinary rename: ${this.formerNames} to ${this.candidates} " + + "on '$typeName'" + } + } + + override fun equals(other: Any?): Boolean = + other is AmbiguousPropertyRename && + typeName == other.typeName && + formerNames == other.formerNames && + candidates == other.candidates + + override fun hashCode(): Int = Objects.hash(typeName, formerNames, candidates) + + override fun toString(): String = + "AmbiguousPropertyRename(typeName=$typeName, formerNames=$formerNames, candidates=$candidates)" + } + /** * A property that kept its name on a type present in both versions, but changed shape: its * value type narrowed or widened, its cardinality moved, or it turned from a plain value into a @@ -332,10 +449,13 @@ sealed interface MetamodelChange { * @property fromVersion The baseline (older) version. * @property toVersion The target (newer) version. * @property changes Every change, in a deterministic order: entity-type changes first (by type - * name, a renamed type filed under its newer name), then relationship changes. Within one type - * the order is [MetamodelChange.EntityTypeRenamed], [MetamodelChange.EntityTypeAliasesChanged], + * name, a renamed type filed under its newer name), then relationship changes. Any + * [MetamodelChange.AmbiguousEntityTypeRename] comes after the added and removed types and before + * the per-type blocks, ordered by first contested former name. Within one type the order is + * [MetamodelChange.EntityTypeRenamed], [MetamodelChange.EntityTypeAliasesChanged], * [MetamodelChange.EntityTypeModified], [MetamodelChange.PropertyRenamed] by new property name, - * then [MetamodelChange.PropertySignatureChanged] by property name. The same pair of versions + * [MetamodelChange.AmbiguousPropertyRename] by first contested former name, then + * [MetamodelChange.PropertySignatureChanged] by property name. The same pair of versions * always produces the same list. */ class MetamodelDiff( @@ -359,6 +479,8 @@ class MetamodelDiff( /** * Names from every [MetamodelChange.EntityTypeRemoved]: the quarantine candidates. A type that * was renamed under a declared alias is a [MetamodelChange.EntityTypeRenamed] and is not here. + * A former name too many types claimed to pair with is here, since the differ declined to read + * it as a rename; the [MetamodelChange.AmbiguousEntityTypeRename] beside it says why. */ val removedEntityTypes: Set get() = changes @@ -379,6 +501,37 @@ class MetamodelDiff( val propertySignatureChanges: List get() = changes.filterIsInstance() + /** + * Every [MetamodelChange.AmbiguousEntityTypeRename] entry: the declared type renames this diff + * could not read, because the claim was contested in one direction or the other. Empty for + * almost every diff. Each entry's names also appear as ordinary additions and removals. + */ + val ambiguousEntityTypeRenames: List + get() = changes.filterIsInstance() + + /** The same for properties: every [MetamodelChange.AmbiguousPropertyRename] entry. */ + val ambiguousPropertyRenames: List + get() = changes.filterIsInstance() + + /** + * Descriptors from every [MetamodelChange.RelationshipAdded]: relationships the newer schema + * allows and the older one didn't. + */ + val addedRelationships: Set + get() = changes + .filterIsInstance() + .mapTo(mutableSetOf()) { it.descriptor } + + /** + * Descriptors from every [MetamodelChange.RelationshipRemoved]. A relationship touching a + * renamed type shows up here and in [addedRelationships], since the differ compares rendered + * descriptors as atoms; the [MetamodelChange.EntityTypeRenamed] beside them describes the move. + */ + val removedRelationships: Set + get() = changes + .filterIsInstance() + .mapTo(mutableSetOf()) { it.descriptor } + /** * Every entity type this diff says something about: added, removed, renamed, modified, or * holding a property that was renamed or reshaped. A reshaped type shows up in both @@ -394,9 +547,11 @@ class MetamodelDiff( is MetamodelChange.EntityTypeAdded -> listOf(change.typeName) is MetamodelChange.EntityTypeRemoved -> listOf(change.typeName) is MetamodelChange.EntityTypeRenamed -> listOf(change.before, change.after) + is MetamodelChange.AmbiguousEntityTypeRename -> change.formerNames + change.candidates is MetamodelChange.EntityTypeAliasesChanged -> listOf(change.typeName) is MetamodelChange.EntityTypeModified -> listOf(change.typeName) is MetamodelChange.PropertyRenamed -> listOf(change.typeName) + is MetamodelChange.AmbiguousPropertyRename -> listOf(change.typeName) is MetamodelChange.PropertySignatureChanged -> listOf(change.typeName) is MetamodelChange.RelationshipAdded -> emptyList() is MetamodelChange.RelationshipRemoved -> emptyList() diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt index 307d4a94..b2892717 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt @@ -45,7 +45,12 @@ import com.embabel.dice.metamodel.PropertySignature * found: a label or a reference target still spelled with an old name is the rename propagating, so * it folds into the rename entry rather than reporting as loss on every referrer and every child. * Rendered relationship descriptors are left alone, so a relationship touching a renamed type still - * churns; the names inside a descriptor are free text and are never parsed. See + * churns; the names inside a descriptor are free text and are never parsed. + * + * A rename has to be unmistakable to pair. When two new types both declare `Person` as a former + * name, the declaration is saying two things at once, and the differ pairs neither: both read as + * ordinary additions, `Person` reads as an ordinary removal, and an + * [MetamodelChange.AmbiguousEntityTypeRename] says which claim was set aside. See * `docs/design/metamodel-diff.md`. * * Stateless and thread-safe; one shared instance is fine. @@ -58,17 +63,23 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { val fromTypes = from.entityTypeNames.toSet() val toTypes = to.entityTypeNames.toSet() - val renamedTypes = pairRenamedTypes( + val typePairing = pairRenamedTypes( removed = fromTypes - toTypes, added = toTypes - fromTypes, aliases = to.entityTypeAliases, ) + val renamedTypes = typePairing.renames (fromTypes - toTypes - renamedTypes.keys).sorted() .mapTo(changes) { MetamodelChange.EntityTypeRemoved(it) } (toTypes - fromTypes - renamedTypes.values.toSet()).sorted() .mapTo(changes) { MetamodelChange.EntityTypeAdded(it) } + // A contested claim pairs nothing, so both sides of it have just been reported as an + // ordinary removal and addition. The entry saying which declaration was set aside goes + // here, ahead of the per-type blocks. + changes += typePairing.ambiguities + // The types to compare member by member: those named the same in both versions, plus each // paired rename, which compares the old name's members against the new name's. Both are // reported under the newer name and walked in that order, so the output is canonical. @@ -110,6 +121,7 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { ) } changes += properties.renames + changes += properties.ambiguities changes += properties.signatureChanges } @@ -164,46 +176,70 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { ) } - /** The four ways a type's property set can differ, gathered in one pass. */ + /** The five ways a type's property set can differ, gathered in one pass. */ private data class PropertyDelta( val added: Set, val removed: Set, val renames: List, + val ambiguities: List, val signatureChanges: List, ) + /** What the type-level pairing found: the renames, and the claims it declined to resolve. */ + private class TypeRenamePairing( + val renames: Map, + val ambiguities: List, + ) + + /** The same for one type's properties. */ + private class PropertyRenamePairing( + val renames: List, + val ambiguities: List, + ) + /** - * Pair each removed type with an added type that declares the removed name as a former name. + * Pair each removed type with the added type that declares the removed name as a former name. * - * Removed names are walked in sorted order and matched against added names in sorted order, - * which makes the pairing one-to-one and the same every run. An added type claiming two removed - * names takes the first of them, and the other stays an ordinary removal; a removed name - * claimed by two added types goes to the first of those, and the second stays an ordinary - * addition. + * Pairing needs an exclusive claim on both sides: among the types that are new in this version, + * the removed name is declared by exactly one of them, and that one declares exactly one removed + * name. Anything else is a contested claim, and it pairs nothing — both sides read as an + * ordinary removal and addition, with a [MetamodelChange.AmbiguousEntityTypeRename] naming the + * whole group. See [groupClaims]. * - * Matching is against the whole declared alias set, so a type renamed twice still pairs across - * stamps that aren't adjacent. + * Exclusivity is judged among added types only, since only a removed name arriving on a new type + * is shaped like a rename. A type present in both versions can legally declare the same removed + * name as a former name — that is a merge, not a rename candidate — and it does not contest the + * pairing. It reports where it belongs, as an [MetamodelChange.EntityTypeAliasesChanged] on the + * surviving type. * - * @return Old name to new name, empty when nothing paired. + * Matching is against the whole declared alias set, so a type renamed twice still pairs across + * stamps that aren't adjacent. An alias naming a type that isn't gone from the newer version + * says nothing and is left out before any of this. */ private fun pairRenamedTypes( removed: Set, added: Set, aliases: Map>, - ): Map { - if (removed.isEmpty() || added.isEmpty() || aliases.isEmpty()) return emptyMap() - - val candidates = added.sorted() - val claimed = mutableSetOf() - val paired = LinkedHashMap() - for (removedName in removed.sorted()) { - val match = candidates.firstOrNull { addedName -> - addedName !in claimed && removedName in aliases[addedName].orEmpty() - } ?: continue - claimed += match - paired[removedName] = match + ): TypeRenamePairing { + if (removed.isEmpty() || added.isEmpty() || aliases.isEmpty()) { + return TypeRenamePairing(emptyMap(), emptyList()) } - return paired + + val claims = added + .associateWith { addedName -> aliases[addedName].orEmpty() intersect removed } + .filterValues { it.isNotEmpty() } + if (claims.isEmpty()) return TypeRenamePairing(emptyMap(), emptyList()) + + val grouped = groupClaims(claims) + return TypeRenamePairing( + renames = grouped.paired, + ambiguities = grouped.contested.map { group -> + MetamodelChange.AmbiguousEntityTypeRename( + formerNames = group.formerNames, + candidates = group.candidates, + ) + }, + ) } /** @@ -251,17 +287,18 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { } /** - * Pair each removed property name with an added signature that declares it as a former name. + * Pair each removed property name with the added signature that declares it as a former name. * * Runs on what is left after the name matching in [comparePropertiesOf], so a name present on * both sides is never a candidate: an alias naming a property that still exists says nothing. - * Removed names are walked in sorted order against added signatures in sorted order, the same - * one-to-one discipline the type pairing uses, with the same outcome when one signature claims - * two old names or two signatures claim one. + * The pairing rule is the type pairing's rule one level down — an exclusive claim on both + * sides, and a contested claim pairs nothing and reports as a + * [MetamodelChange.AmbiguousPropertyRename]. * * A name carrying more than one signature is left out. That is the type-merge path, where two * same-named domain types each declare the property, and there is no single before or after to - * pair; it falls back to a removal and an addition, as it does for a shape change. + * pair; it falls back to a removal and an addition, as it does for a shape change. A name left + * out this way is not part of any claim, so a signature naming it never turns up contested. */ private fun pairRenamedProperties( typeName: String, @@ -269,32 +306,45 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { removed: List, fromNames: Set, toNames: Set, - ): List { + ): PropertyRenamePairing { + val empty = PropertyRenamePairing(emptyList(), emptyList()) + val candidates = added .groupBy { it.name } .filter { (name, signatures) -> signatures.size == 1 && name !in fromNames } - .values - .map { it.single() } - .sorted() - if (candidates.isEmpty()) return emptyList() + .mapValues { (_, signatures) -> signatures.single() } + if (candidates.isEmpty()) return empty val goneNames = removed .groupBy { it.name } .filter { (name, signatures) -> signatures.size == 1 && name !in toNames } .mapValues { (_, signatures) -> signatures.single() } - - val claimed = mutableSetOf() - val renames = mutableListOf() - for (removedName in goneNames.keys.sorted()) { - val match = candidates.firstOrNull { it.name !in claimed && removedName in it.aliases } ?: continue - claimed += match.name - renames += MetamodelChange.PropertyRenamed( - typeName = typeName, - before = goneNames.getValue(removedName), - after = match, - ) - } - return renames.sortedBy { it.after.name } + if (goneNames.isEmpty()) return empty + + val claims = candidates + .mapValues { (_, signature) -> signature.aliases intersect goneNames.keys } + .filterValues { it.isNotEmpty() } + if (claims.isEmpty()) return empty + + val grouped = groupClaims(claims) + return PropertyRenamePairing( + renames = grouped.paired + .map { (beforeName, afterName) -> + MetamodelChange.PropertyRenamed( + typeName = typeName, + before = goneNames.getValue(beforeName), + after = candidates.getValue(afterName), + ) + } + .sortedBy { it.after.name }, + ambiguities = grouped.contested.map { group -> + MetamodelChange.AmbiguousPropertyRename( + typeName = typeName, + formerNames = group.formerNames, + candidates = group.candidates, + ) + }, + ) } /** @@ -347,8 +397,9 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { } // Renames are paired off what is left, and the pair is then excluded from the added and - // removed sets, so one rename is one entry rather than a removal plus an addition too. - val renames = pairRenamedProperties( + // removed sets, so one rename is one entry rather than a removal plus an addition too. A + // contested claim pairs nothing, so its names stay in both sets and are reported plainly. + val pairing = pairRenamedProperties( typeName = typeName, added = added, removed = removed, @@ -357,15 +408,93 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { ) return PropertyDelta( - added = canonical(added - renames.mapTo(mutableSetOf()) { it.after }), - removed = canonical(removed - renames.mapTo(mutableSetOf()) { it.before }), - renames = renames, + added = canonical(added - pairing.renames.mapTo(mutableSetOf()) { it.after }), + removed = canonical(removed - pairing.renames.mapTo(mutableSetOf()) { it.before }), + renames = pairing.renames, + ambiguities = pairing.ambiguities, signatureChanges = signatureChanges, ) } private companion object { + /** Old names and the new names claiming them, where no one claim is exclusive. */ + private class ContestedClaim(val formerNames: Set, val candidates: Set) + + /** The outcome of reading a set of declared claims: what paired, and what was contested. */ + private class ClaimGrouping( + val paired: Map, + val contested: List, + ) + + /** + * Read declared former names — each new name against the old names it claims — and decide + * which of them are renames. + * + * A claim is a rename when it is exclusive both ways within [claims]: the old name is + * claimed by that new name alone, and that new name claims that old name alone. Everything + * else is contested and pairs nothing. + * + * Exclusivity is judged over what the caller puts in [claims], and each caller leaves some + * declarations out — a surviving type's alias at the type level, a name carrying two + * signatures at the property level. A declaration left out doesn't contest anything here; + * it reports through whichever change kind covers it. + * + * The way to see the rule is as a graph, old names on one side, new names on the other, an + * edge for each claim. A piece of that graph holding one name on each side is a rename, and + * the walk below pairs it. A larger piece has no answer in the declaration: two new types + * claiming `Person` could each be the one that carries its data, and picking either would + * attach the old type's history to a type nobody nominated. The whole piece comes back as + * one [ContestedClaim] so a caller sees which names are tangled together, and each name in + * it goes on to report as an ordinary addition or removal. + * + * Deterministic: old names are walked in sorted order, so a group is always found at its + * lowest old name, and groups don't share old names. That puts [ClaimGrouping.contested] in + * order of first contested old name, and the names inside each group come out sorted. + * + * @param claims New name to the old names it declares as former names. Every old name here + * is one the comparison already found gone; names on both sides are filtered out earlier. + */ + private fun groupClaims(claims: Map>): ClaimGrouping { + val claimants = mutableMapOf>() + claims.forEach { (newName, oldNames) -> + oldNames.forEach { oldName -> claimants.getOrPut(oldName) { mutableSetOf() } += newName } + } + + val paired = LinkedHashMap() + val contested = mutableListOf() + val visited = mutableSetOf() + + for (startName in claimants.keys.sorted()) { + if (startName in visited) continue + + // Walk out from this old name to every name reachable through a claim. + val groupOld = mutableSetOf(startName) + val groupNew = mutableSetOf() + val pendingOld = ArrayDeque(listOf(startName)) + val pendingNew = ArrayDeque() + while (pendingOld.isNotEmpty() || pendingNew.isNotEmpty()) { + while (pendingOld.isNotEmpty()) { + val oldName = pendingOld.removeFirst() + claimants.getValue(oldName).forEach { if (groupNew.add(it)) pendingNew += it } + } + while (pendingNew.isNotEmpty()) { + val newName = pendingNew.removeFirst() + claims.getValue(newName).forEach { if (groupOld.add(it)) pendingOld += it } + } + } + visited += groupOld + + if (groupOld.size == 1 && groupNew.size == 1) { + paired[groupOld.first()] = groupNew.first() + } else { + contested += ContestedClaim(canonical(groupOld), canonical(groupNew)) + } + } + + return ClaimGrouping(paired = paired, contested = contested) + } + /** * Sort into a set that iterates in that order. * diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt index 8bb18d3b..99a9ea9d 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt @@ -774,40 +774,6 @@ class MetamodelDifferTest { assertTrue(diff.addedEntityTypes.isEmpty()) } - @Test - fun `an added type claiming two removed names pairs with the first and leaves the other removed`() { - val old = versionOf(listOf("A", "B")) - val new = versionOf(listOf("C"), aliases = mapOf("C" to setOf("A", "B"))) - val diff = differ.diff(old, new) - - assertEquals( - listOf( - MetamodelChange.EntityTypeRemoved("B"), - MetamodelChange.EntityTypeRenamed("A", "C"), - MetamodelChange.EntityTypeAliasesChanged("C", emptySet(), setOf("A", "B")), - ), - diff.changes, - ) - } - - @Test - fun `a removed name claimed by two added types pairs with the first and leaves the other added`() { - val old = versionOf(listOf("A")) - val new = versionOf( - listOf("B", "C"), - aliases = mapOf("B" to setOf("A"), "C" to setOf("A")), - ) - val diff = differ.diff(old, new) - - assertEquals( - listOf( - MetamodelChange.EntityTypeAdded("C"), - MetamodelChange.EntityTypeRenamed("A", "B"), - ), - diff.changes, - ) - } - @Test fun `a renamed type reports everything else that moved under its new name`() { val old = versionOf( @@ -991,28 +957,243 @@ class MetamodelDifferTest { assertFalse(renamed.kindChanged) } + /** + * The type-merge path: two same-named domain types each declare `age` with a different + * shape, so the name carries two signatures and there is no single before to pair. + */ @Test - fun `an added signature claiming two removed names pairs with the first and leaves the other removed`() { + fun `a removed name carrying two signatures falls back to a removal and an addition`() { val old = versionOf( listOf("Person"), - properties = mapOf("Person" to setOf(valueProperty("a"), valueProperty("b"))), + properties = mapOf( + "Person" to setOf( + valueProperty("age", "string"), + valueProperty("age", "integer"), + ), + ), ) val new = versionOf( listOf("Person"), - properties = mapOf("Person" to setOf(valueProperty("c", aliases = setOf("a", "b")))), + properties = mapOf("Person" to setOf(valueProperty("years", "integer", aliases = setOf("age")))), ) val diff = differ.diff(old, new) - val renamed = diff.changes.filterIsInstance().single() - assertEquals("a", renamed.before.name) - assertEquals("c", renamed.after.name) + assertTrue( + diff.changes.filterIsInstance().isEmpty(), + "no honest pairing exists for a name with two signatures: ${diff.changes}", + ) val modified = diff.modifiedEntityTypes.single() - assertEquals(setOf("b"), modified.removedPropertyNames) - assertTrue(modified.addedProperties.isEmpty()) + assertEquals(setOf("age"), modified.removedPropertyNames) + assertEquals(setOf("years"), modified.addedPropertyNames) + } + + @Test + fun `an alias naming a property that still exists pairs nothing`() { + val old = versionOf(listOf("Person"), properties = mapOf("Person" to setOf(valueProperty("age", "integer")))) + val new = versionOf( + listOf("Person"), + properties = mapOf( + "Person" to setOf( + valueProperty("age", "integer"), + valueProperty("years", "integer", aliases = setOf("age")), + ), + ), + ) + val diff = differ.diff(old, new) + + assertTrue(diff.changes.filterIsInstance().isEmpty()) + assertEquals(setOf("years"), diff.modifiedEntityTypes.single().addedPropertyNames) + } + } + + /** + * Pairing needs an exclusive claim on both sides. Where more than one claim lands on a name the + * declaration has said two things at once, nothing pairs, and the diff reports the tangle + * rather than picking a winner off the sort order. + */ + @Nested + inner class ContestedRenames { + + /** + * The case from the #85 review. `Person`'s `age` moved from integer to string on one of the + * two claimants and stayed integer on the other, so choosing the alphabetically first would + * report a signature change on a type the operator never nominated as the rename, and hand + * the other one a clean bill as a brand-new type. + */ + @Test + fun `two added types claiming one former name pair with neither`() { + val old = versionOf( + listOf("Person"), + properties = mapOf("Person" to setOf(valueProperty("age", "integer"))), + ) + val new = versionOf( + listOf("Customer", "Employee"), + properties = mapOf( + "Customer" to setOf(valueProperty("age", "string")), + "Employee" to setOf(valueProperty("age", "integer")), + ), + aliases = mapOf("Customer" to setOf("Person"), "Employee" to setOf("Person")), + ) + val diff = differ.diff(old, new) + + assertEquals( + listOf( + MetamodelChange.EntityTypeRemoved("Person"), + MetamodelChange.EntityTypeAdded("Customer"), + MetamodelChange.EntityTypeAdded("Employee"), + MetamodelChange.AmbiguousEntityTypeRename( + formerNames = setOf("Person"), + candidates = setOf("Customer", "Employee"), + ), + ), + diff.changes, + ) + assertEquals(setOf("Person"), diff.removedEntityTypes, "the old type reads as lost, which it is") + assertTrue( + diff.propertySignatureChanges.isEmpty(), + "no old type's history is attached to a claimant: ${diff.changes}", + ) } @Test - fun `a removed name claimed by two added signatures pairs with the first and leaves the other added`() { + fun `one added type claiming two live former names pairs with neither`() { + val old = versionOf(listOf("A", "B")) + val new = versionOf(listOf("C"), aliases = mapOf("C" to setOf("A", "B"))) + + assertEquals( + listOf( + MetamodelChange.EntityTypeRemoved("A"), + MetamodelChange.EntityTypeRemoved("B"), + MetamodelChange.EntityTypeAdded("C"), + MetamodelChange.AmbiguousEntityTypeRename( + formerNames = setOf("A", "B"), + candidates = setOf("C"), + ), + ), + differ.diff(old, new).changes, + ) + } + + /** + * `A` is claimed by both, and `Y` claims `B` as well, so all four names hang together and + * no part of the tangle can be resolved on its own. One entry names the whole group. + */ + @Test + fun `claims that chain together report as one group`() { + val old = versionOf(listOf("A", "B")) + val new = versionOf( + listOf("X", "Y"), + aliases = mapOf("X" to setOf("A"), "Y" to setOf("A", "B")), + ) + + assertEquals( + listOf( + MetamodelChange.EntityTypeRemoved("A"), + MetamodelChange.EntityTypeRemoved("B"), + MetamodelChange.EntityTypeAdded("X"), + MetamodelChange.EntityTypeAdded("Y"), + MetamodelChange.AmbiguousEntityTypeRename( + formerNames = setOf("A", "B"), + candidates = setOf("X", "Y"), + ), + ), + differ.diff(old, new).changes, + ) + } + + @Test + fun `an uncontested rename beside a contested claim still pairs`() { + val old = versionOf(listOf("A", "Person")) + val new = versionOf( + listOf("B", "Customer", "Employee"), + aliases = mapOf( + "B" to setOf("A"), + "Customer" to setOf("Person"), + "Employee" to setOf("Person"), + ), + ) + + assertEquals( + listOf( + MetamodelChange.EntityTypeRemoved("Person"), + MetamodelChange.EntityTypeAdded("Customer"), + MetamodelChange.EntityTypeAdded("Employee"), + MetamodelChange.AmbiguousEntityTypeRename( + formerNames = setOf("Person"), + candidates = setOf("Customer", "Employee"), + ), + MetamodelChange.EntityTypeRenamed("A", "B"), + ), + differ.diff(old, new).changes, + ) + } + + @Test + fun `two contested groups come out ordered by their first former name`() { + val old = versionOf(listOf("A", "P")) + val new = versionOf( + listOf("A1", "A2", "P1", "P2"), + aliases = mapOf( + "A1" to setOf("A"), + "A2" to setOf("A"), + "P1" to setOf("P"), + "P2" to setOf("P"), + ), + ) + + assertEquals( + listOf( + MetamodelChange.AmbiguousEntityTypeRename(setOf("A"), setOf("A1", "A2")), + MetamodelChange.AmbiguousEntityTypeRename(setOf("P"), setOf("P1", "P2")), + ), + differ.diff(old, new).changes + .filterIsInstance(), + ) + } + + /** + * Substitution rides on a pair the differ made. Nothing paired here, so a referrer that + * followed the move reports its reference change in full, the reading it would get with no + * alias declared at all. + */ + @Test + fun `a contested claim substitutes nothing on the older side`() { + val old = versionOf( + listOf("Person", "Referrer"), + properties = mapOf("Referrer" to setOf(referenceProperty("link", "Person"))), + ) + val new = versionOf( + listOf("Customer", "Employee", "Referrer"), + properties = mapOf("Referrer" to setOf(referenceProperty("link", "Customer"))), + aliases = mapOf("Customer" to setOf("Person"), "Employee" to setOf("Person")), + ) + val diff = differ.diff(old, new) + + assertEquals( + MetamodelChange.PropertySignatureChanged( + typeName = "Referrer", + propertyName = "link", + before = referenceProperty("link", "Person"), + after = referenceProperty("link", "Customer"), + ), + diff.propertySignatureChanges.single(), + ) + } + + @Test + fun `touchedEntityTypes carries every name in a contested claim`() { + val diff = differ.diff( + versionOf(listOf("Person")), + versionOf( + listOf("Customer", "Employee"), + aliases = mapOf("Customer" to setOf("Person"), "Employee" to setOf("Person")), + ), + ) + assertEquals(setOf("Person", "Customer", "Employee"), diff.touchedEntityTypes) + } + + @Test + fun `two added signatures claiming one former name pair with neither`() { val old = versionOf(listOf("Person"), properties = mapOf("Person" to setOf(valueProperty("a")))) val new = versionOf( listOf("Person"), @@ -1025,59 +1206,166 @@ class MetamodelDifferTest { ) val diff = differ.diff(old, new) - val renamed = diff.changes.filterIsInstance().single() - assertEquals("b", renamed.after.name) + assertTrue(diff.changes.filterIsInstance().isEmpty()) + val modified = diff.modifiedEntityTypes.single() + assertEquals(setOf("a"), modified.removedPropertyNames) + assertEquals(setOf("b", "c"), modified.addedPropertyNames) + assertEquals( + MetamodelChange.AmbiguousPropertyRename( + typeName = "Person", + formerNames = setOf("a"), + candidates = setOf("b", "c"), + ), + diff.changes.filterIsInstance().single(), + ) + } + + @Test + fun `one added signature claiming two former names pairs with neither`() { + val old = versionOf( + listOf("Person"), + properties = mapOf("Person" to setOf(valueProperty("a"), valueProperty("b"))), + ) + val new = versionOf( + listOf("Person"), + properties = mapOf("Person" to setOf(valueProperty("c", aliases = setOf("a", "b")))), + ) + val diff = differ.diff(old, new) + + assertTrue(diff.changes.filterIsInstance().isEmpty()) val modified = diff.modifiedEntityTypes.single() + assertEquals(setOf("a", "b"), modified.removedPropertyNames) assertEquals(setOf("c"), modified.addedPropertyNames) - assertTrue(modified.removedProperties.isEmpty()) + assertEquals( + MetamodelChange.AmbiguousPropertyRename( + typeName = "Person", + formerNames = setOf("a", "b"), + candidates = setOf("c"), + ), + diff.changes.filterIsInstance().single(), + ) + } + + @Test + fun `a contested property claim sits after the paired renames on the same type`() { + val old = versionOf( + listOf("Person"), + properties = mapOf("Person" to setOf(valueProperty("a"), valueProperty("x"))), + ) + val new = versionOf( + listOf("Person"), + properties = mapOf( + "Person" to setOf( + valueProperty("b", aliases = setOf("a")), + valueProperty("c", aliases = setOf("a")), + valueProperty("y", aliases = setOf("x")), + ), + ), + ) + val diff = differ.diff(old, new) + + assertEquals( + listOf("EntityTypeModified", "PropertyRenamed", "AmbiguousPropertyRename"), + diff.changes.map { it::class.simpleName }, + ) + assertEquals("y", diff.changes.filterIsInstance().single().after.name) } /** - * The type-merge path: two same-named domain types each declare `age` with a different - * shape, so the name carries two signatures and there is no single before to pair. + * Exclusivity is judged among the types that are new in this version. `Keeper` survives and + * declares `Person` as a former name too, which the stamp allows once `Person` is gone, but + * a name arriving on a type that was already there is a merge rather than a rename + * candidate. So it doesn't contest `Person → Customer`, and reports as an alias change on + * `Keeper`. */ @Test - fun `a removed name carrying two signatures falls back to a removal and an addition`() { + fun `a surviving type's alias on the removed name does not contest the pairing`() { + val old = versionOf(listOf("Keeper", "Person")) + val new = versionOf( + listOf("Customer", "Keeper"), + aliases = mapOf("Customer" to setOf("Person"), "Keeper" to setOf("Person")), + ) + + assertEquals( + listOf( + MetamodelChange.EntityTypeRenamed("Person", "Customer"), + MetamodelChange.EntityTypeAliasesChanged("Keeper", emptySet(), setOf("Person")), + ), + differ.diff(old, new).changes, + ) + } + + /** + * A property name the type-merge path holds two signatures for is out of the running before + * claims are read, so a signature naming it claims nothing there, and its other claim is + * exclusive and pairs. + */ + @Test + fun `a name carrying two signatures cannot contest a claim`() { val old = versionOf( listOf("Person"), properties = mapOf( "Person" to setOf( valueProperty("age", "string"), valueProperty("age", "integer"), + valueProperty("b"), ), ), ) val new = versionOf( listOf("Person"), - properties = mapOf("Person" to setOf(valueProperty("years", "integer", aliases = setOf("age")))), + properties = mapOf("Person" to setOf(valueProperty("years", aliases = setOf("age", "b")))), ) val diff = differ.diff(old, new) - assertTrue( - diff.changes.filterIsInstance().isEmpty(), - "no honest pairing exists for a name with two signatures: ${diff.changes}", - ) - val modified = diff.modifiedEntityTypes.single() - assertEquals(setOf("age"), modified.removedPropertyNames) - assertEquals(setOf("years"), modified.addedPropertyNames) + assertTrue(diff.changes.filterIsInstance().isEmpty()) + val renamed = diff.changes.filterIsInstance().single() + assertEquals("b", renamed.before.name) + assertEquals("years", renamed.after.name) } @Test - fun `an alias naming a property that still exists pairs nothing`() { - val old = versionOf(listOf("Person"), properties = mapOf("Person" to setOf(valueProperty("age", "integer")))) + fun `a contested diff produces the same ordered change list every run`() { + val old = versionOf( + listOf("A", "B", "Holder", "Person"), + properties = mapOf("Holder" to setOf(valueProperty("a"), valueProperty("x"))), + ) val new = versionOf( - listOf("Person"), + listOf("C", "Customer", "Employee", "Holder"), properties = mapOf( - "Person" to setOf( - valueProperty("age", "integer"), - valueProperty("years", "integer", aliases = setOf("age")), + "Holder" to setOf( + valueProperty("b", aliases = setOf("a")), + valueProperty("c", aliases = setOf("a")), + valueProperty("y", aliases = setOf("x")), ), ), + aliases = mapOf( + "C" to setOf("A", "B"), + "Customer" to setOf("Person"), + "Employee" to setOf("Person"), + ), ) - val diff = differ.diff(old, new) - assertTrue(diff.changes.filterIsInstance().isEmpty()) - assertEquals(setOf("years"), diff.modifiedEntityTypes.single().addedPropertyNames) + val first = differ.diff(old, new).changes + assertTrue( + first.filterIsInstance().isNotEmpty(), + "the fixture has to contain a contested claim: $first", + ) + repeat(20) { + assertEquals(first, differ.diff(old, new).changes) + assertEquals(first, differ.diff(rebuilt(old), rebuilt(new)).changes) + } + } + + @Test + fun `one former name and one candidate is a rename, and cannot be reported as contested`() { + val thrown = assertThrows { + MetamodelChange.AmbiguousEntityTypeRename(setOf("Person"), setOf("Human")) + } + assertTrue( + thrown.message!!.contains("ordinary rename"), + "the message should say why: ${thrown.message}", + ) } } @@ -1316,6 +1604,130 @@ class MetamodelDifferTest { } } + /** + * The convenience accessors on [MetamodelDiff]. A caller reading one change kind should not have + * to write `filterIsInstance`, and every kind the differ can emit needs one, so a caller + * counting a diff through the accessors accounts for all of it. + */ + @Nested + inner class Accessors { + + @Test + fun `ambiguousEntityTypeRenames carries every contested type claim`() { + val diff = differ.diff( + versionOf(listOf("Person")), + versionOf( + listOf("Customer", "Employee"), + aliases = mapOf("Customer" to setOf("Person"), "Employee" to setOf("Person")), + ), + ) + + assertEquals( + listOf( + MetamodelChange.AmbiguousEntityTypeRename( + formerNames = setOf("Person"), + candidates = setOf("Customer", "Employee"), + ), + ), + diff.ambiguousEntityTypeRenames, + ) + assertTrue(diff.ambiguousPropertyRenames.isEmpty(), "a type claim is not a property claim") + } + + @Test + fun `ambiguousPropertyRenames carries every contested property claim`() { + val diff = differ.diff( + versionOf(listOf("Person"), properties = mapOf("Person" to setOf(valueProperty("a")))), + versionOf( + listOf("Person"), + properties = mapOf( + "Person" to setOf( + valueProperty("b", aliases = setOf("a")), + valueProperty("c", aliases = setOf("a")), + ), + ), + ), + ) + + assertEquals( + listOf( + MetamodelChange.AmbiguousPropertyRename( + typeName = "Person", + formerNames = setOf("a"), + candidates = setOf("b", "c"), + ), + ), + diff.ambiguousPropertyRenames, + ) + assertTrue(diff.ambiguousEntityTypeRenames.isEmpty(), "a property claim is not a type claim") + } + + /** + * The fixture holds at least one change of every kind these accessors cover — contested + * claims and relationships included — so the count through the accessors has to come out at + * the length of the change list, and dropping any one accessor from the sum breaks it. + * + * It deliberately holds no `EntityTypeRenamed`, `EntityTypeAliasesChanged` or + * `PropertyRenamed`, the three kinds still read through `filterIsInstance` here. Their + * accessors arrive with the drift slice, and this test grows to cover them then. + */ + @Test + fun `each accessor sees only its own kind, and together they cover the change list`() { + val diff = differ.diff( + versionOf( + listOf("Gone", "Holder", "Person", "Stable"), + properties = mapOf( + "Holder" to setOf(valueProperty("a"), valueProperty("age", "string")), + ), + relationships = listOf("Stable-[knew]->Gone"), + ), + versionOf( + listOf("Customer", "Employee", "Holder", "New", "Stable"), + properties = mapOf( + "Holder" to setOf( + valueProperty("b", aliases = setOf("a")), + valueProperty("c", aliases = setOf("a")), + valueProperty("age", "integer"), + ), + ), + aliases = mapOf("Customer" to setOf("Person"), "Employee" to setOf("Person")), + relationships = listOf("Stable-[knows]->New"), + ), + ) + + assertEquals(setOf("Gone", "Person"), diff.removedEntityTypes) + assertEquals(setOf("Customer", "Employee", "New"), diff.addedEntityTypes) + assertEquals(1, diff.ambiguousEntityTypeRenames.size) + assertEquals(1, diff.modifiedEntityTypes.size) + assertEquals(1, diff.ambiguousPropertyRenames.size) + assertEquals(1, diff.propertySignatureChanges.size) + assertEquals(setOf("Stable-[knows]->New"), diff.addedRelationships) + assertEquals(setOf("Stable-[knew]->Gone"), diff.removedRelationships) + + val throughAccessors = diff.removedEntityTypes.size + diff.addedEntityTypes.size + + diff.ambiguousEntityTypeRenames.size + diff.modifiedEntityTypes.size + + diff.ambiguousPropertyRenames.size + diff.propertySignatureChanges.size + + diff.addedRelationships.size + diff.removedRelationships.size + assertEquals(11, diff.changes.size, "the fixture should exercise every covered kind: ${diff.changes}") + assertEquals(diff.changes.size, throughAccessors, "the accessors should partition ${diff.changes}") + } + + @Test + fun `an empty diff gives every accessor an empty answer`() { + val diff = differ.diff(versionOf(listOf("Person")), versionOf(listOf("Person"))) + + assertTrue(diff.isEmpty) + assertTrue(diff.removedEntityTypes.isEmpty()) + assertTrue(diff.addedEntityTypes.isEmpty()) + assertTrue(diff.modifiedEntityTypes.isEmpty()) + assertTrue(diff.propertySignatureChanges.isEmpty()) + assertTrue(diff.ambiguousEntityTypeRenames.isEmpty()) + assertTrue(diff.ambiguousPropertyRenames.isEmpty()) + assertTrue(diff.addedRelationships.isEmpty()) + assertTrue(diff.removedRelationships.isEmpty()) + } + } + /** * A finished diff must not be reshapeable. Mirrors `MetamodelVersionTest.Immutability`: Kotlin's * read-only collection types are a compile-time promise only, and a Java caller sees plain @@ -1397,6 +1809,36 @@ class MetamodelDifferTest { } } + @Test + fun `the name sets inside a contested-rename change cannot be mutated`() { + val claimants = mutableSetOf("Customer", "Employee") + val typeChange = MetamodelChange.AmbiguousEntityTypeRename(setOf("Person"), claimants) + val propertyChange = MetamodelChange.AmbiguousPropertyRename("Person", setOf("a"), mutableSetOf("b", "c")) + + claimants += "Sneaky" + assertEquals(setOf("Customer", "Employee"), typeChange.candidates) + + @Suppress("UNCHECKED_CAST") + assertThrows { + (typeChange.formerNames as MutableSet).add("Sneaky") + } + + @Suppress("UNCHECKED_CAST") + assertThrows { + (typeChange.candidates as MutableSet).clear() + } + + @Suppress("UNCHECKED_CAST") + assertThrows { + (propertyChange.formerNames as MutableSet).add("Sneaky") + } + + @Suppress("UNCHECKED_CAST") + assertThrows { + (propertyChange.candidates as MutableSet).clear() + } + } + @Test fun `the sets a declared-observed diff hands back cannot be mutated`() { val diff = StructuralMetamodelDiffer().diffAgainstObserved( diff --git a/docs/design/metamodel-diff.md b/docs/design/metamodel-diff.md index e04b500a..f028e81d 100644 --- a/docs/design/metamodel-diff.md +++ b/docs/design/metamodel-diff.md @@ -52,12 +52,18 @@ over, where an unhandled change kind would be treated as harmless. | `EntityTypeModified` | A type present in both versions gained or lost labels, or gained or lost whole properties (matched by name), with the full signature of each. | | `PropertyRenamed` | A property changed name, paired up through a former name its new signature carries. Carries `before` and `after`. | | `PropertySignatureChanged` | A property kept its name but changed shape: its type, its cardinality, or whether it holds a value or points at another type. Carries `before` and `after`. | +| `AmbiguousEntityTypeRename` / `AmbiguousPropertyRename` | A contested claim, so no rename was read: more than one new name claiming one former name, or one new name claiming more than one former name that was still live. Carries the whole contested group. | | `RelationshipAdded` / `RelationshipRemoved` | An allowed relationship descriptor appeared or disappeared. | The kinds don't overlap. Each difference is reported exactly once, by whichever kind describes it most precisely. Rendered relationship descriptors are the exception, and [Where exactly-once stops](#where-exactly-once-stops) says why. +The two ambiguity kinds sit outside that accounting. They describe a declaration the differ could +not act on rather than a difference between the two schemas, and the names inside one are also +reported as ordinary additions and removals. [The pairing rule](#the-pairing-rule) says when they +come up. + `PropertySignatureChanged` is why the stamp carries signatures rather than property names. `age` turning from a string into an integer, or a single `worksAt` becoming a list of them, changes what the graph can hold, and data extracted under the old shape may not fit the new one. Under a @@ -81,10 +87,12 @@ leaving the differ is sorted first. Sets are compared as sets, never as a delimi projection: these names come from free text and LLM extraction and routinely contain commas and spaces, so `{"a", "b c"}` and `{"a b", "c"}` must not collapse into the same thing. -Entity-type changes come first, by type name, then relationship changes. Within one type the order -is `EntityTypeRenamed`, `EntityTypeAliasesChanged`, `EntityTypeModified`, `PropertyRenamed` by new -property name, then `PropertySignatureChanged` by property name. A renamed type is filed under its -new name. +Entity-type changes come first, by type name, then relationship changes. Any +`AmbiguousEntityTypeRename` comes after the added and removed types and before the per-type blocks, +ordered by first contested former name. Within one type the order is `EntityTypeRenamed`, +`EntityTypeAliasesChanged`, `EntityTypeModified`, `PropertyRenamed` by new property name, +`AmbiguousPropertyRename` by first contested former name, then `PropertySignatureChanged` by +property name. A renamed type is filed under its new name. ## Declared renames @@ -103,36 +111,70 @@ case-sensitive. Pairing runs on what is left after the ordinary name matching, so a name present on both sides is never a candidate: an alias naming a property that still exists says nothing, and matches nothing. -Removed names are walked in sorted order against the added entries in sorted order, which makes the -result one-to-one and identical every run. + +A rename pairs when the claim is **exclusive both ways among the claims that are in the running**: +the old name is claimed by one new name alone, and that new name claims one old name alone. The way +to see it is as a graph — old names on one side, new names on the other, an edge for every declared +former name still in the running. A piece of that graph holding one name on each side is a rename. +Anything larger is contested and pairs nothing. + +Two declarations never enter that graph, so neither one contests a pairing: + +- **A surviving type's alias.** Exclusivity is judged among the types that are *new* in this + version, because only a removed name arriving on a new type is shaped like a rename. A type + present in both versions can legally declare the removed name as a former name — `Keeper` + aliasing `Person` while `Person` moves to `Customer`. That is a merge rather than a rename + candidate, so `Person → Customer` still pairs, and `Keeper`'s declaration reports where it + belongs, as an `EntityTypeAliasesChanged` on `Keeper`. +- **A property name carrying two signatures.** The type-merge path below takes such a name out of + the running before claims are read, so a claim on it is voided rather than contested. A signature + declaring `aliases = {age, b}` where `age` carries two signatures still pairs `b` exclusively. ```mermaid flowchart TD - start["a removed name
(walked in sorted order)"] + start["the claims: an edge from each new
name to each old name it declares"] dup{"a property name carrying
more than one signature?"} - scan{"an unclaimed added entry, in
sorted order, declaring this
name as a former name?"} + group["take one connected group
of the claim graph"] + exclusive{"one old name and one
new name in the group?"} pair["pair them: EntityTypeRenamed
or PropertyRenamed"] exclude["exclude both from the added
and removed sets"] empty{"anything left in the
EntityTypeModified?"} emit["emit EntityTypeModified"] drop["emit nothing"] - plain["ordinary removal:
EntityTypeRemoved, or removedProperties"] + ambiguous["report the whole group:
AmbiguousEntityTypeRename
or AmbiguousPropertyRename"] + plain["ordinary removal and addition:
EntityTypeRemoved / EntityTypeAdded,
or removedProperties / addedProperties"] start --> dup - dup -- "yes: the type-merge path" --> plain - dup -- "no" --> scan - scan -- "yes" --> pair - scan -- "no" --> plain + dup -- "yes: the type-merge path,
the name is out of the running" --> plain + dup -- "no" --> group + group --> exclusive + exclusive -- "yes" --> pair + exclusive -- "no" --> ambiguous + ambiguous --> plain pair --> exclude exclude --> empty empty -- "yes" --> emit empty -- "no" --> drop ``` -Two edge cases the sorted walk settles. One added entry declaring two removed names pairs with the -first of them, and the other stays an ordinary removal. One removed name claimed by two added -entries pairs with the first of those, and the second stays an ordinary addition. Either way the -rename is reported once. +**The ambiguity is reported, never guessed.** Two new types both declaring `Person` as a former name +is a declaration saying two things at once. Resolving it on sort order, or on any other tiebreak, +attaches `Person`'s labels, properties and references to a type nobody nominated, and hands the +other a clean bill as something brand new. The same holds in the other direction: one new type +claiming two old names that were both live is a merge, and neither old name is the rename. So the +differ pairs neither, every name in the group reports as an ordinary addition or removal, and one +ambiguity entry names the whole group so the set-aside declaration is visible instead of silent. +Groups are found by walking out from each old name, so claims that chain together — `X` claims `A` +while `Y` claims both `A` and `B` — come back as a single entry covering all four names. + +Reporting rather than throwing is deliberate. `MetamodelVersion` already refuses an alias naming a +type the same schema still declares, and stays quiet about two types sharing one former name, so a +schema in this state stamps cleanly. The two sides of a diff are then two independently stamped +versions, and a caller comparing historical stamps out of a store cannot edit either one; throwing +would leave it with no way to read its own history. The reading a caller gets is the conservative +one — the old name looks lost, which is what quarantine should act on — with the ambiguity entry +beside it saying why. The fix belongs in the declaration: retire the alias from the types that +should not carry it, or stamp the moves separately so each rename stands alone. The type-merge branch is the same exception `EntityTypeModified` already documents: a `DataDictionary` may hold two same-named domain types whose properties get unioned, so one property @@ -163,6 +205,10 @@ substitutes old name for new in exactly two places: 1. the `type` field of `Kind.REFERENCE` property signatures, and 2. label sets. +Substitution rides on the pairs the differ made, so a contested claim substitutes nothing: a +referrer that followed the move reports its reference change in full, the reading it would get with +no alias declared at all. + A delta that vanishes under the substitution is the rename propagating and folds into `EntityTypeRenamed`. A delta that survives is reported and judged normally, stated in substituted form: a referrer that moved from `A` to `D` while `A` was renamed to `B` reports `B → D`, because From edc27303009ff6d5d2f47b37f8e379241d258ceb Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:22:43 -0400 Subject: [PATCH 5/8] Keep known-but-ungoverned types out of observed drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeclaredSchema.from filtered the dictionary down to the governed types before the observed comparison ever ran, so a type the host declared and deliberately left outside governance arrived looking exactly like a type nobody declared. The runner read it as a removal and the quarantine policy could mark its propositions STALE — the opposite of what per-type governance promises. The declaration now carries the known-but-ungoverned type and relationship names beside the governed stamp, as a third set distinct from the governed names and from declared aliases, and diffAgainstObserved excludes them. A selector governing no types now enforces no types: every dictionary type stays ungoverned and only a name the dictionary never declared reads as drift. Refs #45; stacks on feat/metamodel-version-store. --- .../dice/metamodel/DeclaredSchemaSource.kt | 37 ++++++++++-- .../dice/metamodel/MetamodelVersion.kt | 27 +++++++++ .../support/StructuralMetamodelDiffer.kt | 11 +++- .../dice/metamodel/DeclaredSchemaTest.kt | 27 +++++++++ .../dice/metamodel/MetamodelDifferTest.kt | 56 +++++++++++++++++++ 5 files changed, 152 insertions(+), 6 deletions(-) diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt index 469a6a46..89b0d696 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt @@ -19,7 +19,9 @@ import com.embabel.agent.core.DataDictionary import org.jetbrains.annotations.ApiStatus /** - * The schema as declared: the stamped [version] plus the bare relationship type names it allows. + * The schema as declared: the stamped [version] plus the bare relationship type names it allows, + * and the entity type and relationship names the host declared but chose to leave outside + * governance. * * The bare names travel alongside the stamp rather than being recovered from it, because * [MetamodelVersion.relationshipNames] holds rendered `From-[name]->To` descriptors and @@ -29,27 +31,51 @@ import org.jetbrains.annotations.ApiStatus * declared relationships against what a graph holds needs those bare names, and that comparison is * the next slice. * + * An ungoverned type is still a known one: the host's dictionary named it, and a + * [GovernedTypeSelector] simply chose not to version it. That is a different thing from a type + * nobody ever declared, and a drift check needs to tell the two apart — see + * [ungovernedEntityTypeNames]. + * * @property version The stamped declared schema. * @property relationshipTypeNames The bare relationship type names [version] allows. + * @property ungovernedEntityTypeNames Entity type names the host's dictionary declares, left out of + * [version] because the selector doesn't govern them. Still known types. + * @property ungovernedRelationshipTypeNames Relationship type names an ungoverned type declares, + * left out of [relationshipTypeNames] the same way. */ @ApiStatus.Experimental class DeclaredSchema( val version: MetamodelVersion, relationshipTypeNames: Set, + ungovernedEntityTypeNames: Set = emptySet(), + ungovernedRelationshipTypeNames: Set = emptySet(), ) { /** Copied into a JVM-immutable set so the declaration can't drift from its stamped [version]. */ val relationshipTypeNames: Set = java.util.Set.copyOf(relationshipTypeNames) + val ungovernedEntityTypeNames: Set = java.util.Set.copyOf(ungovernedEntityTypeNames) + + val ungovernedRelationshipTypeNames: Set = java.util.Set.copyOf(ungovernedRelationshipTypeNames) + override fun equals(other: Any?): Boolean = other is DeclaredSchema && version == other.version && - relationshipTypeNames == other.relationshipTypeNames + relationshipTypeNames == other.relationshipTypeNames && + ungovernedEntityTypeNames == other.ungovernedEntityTypeNames && + ungovernedRelationshipTypeNames == other.ungovernedRelationshipTypeNames - override fun hashCode(): Int = 31 * version.hashCode() + relationshipTypeNames.hashCode() + override fun hashCode(): Int = java.util.Objects.hash( + version, + relationshipTypeNames, + ungovernedEntityTypeNames, + ungovernedRelationshipTypeNames, + ) override fun toString(): String = - "DeclaredSchema(version=$version, relationshipTypeNames=$relationshipTypeNames)" + "DeclaredSchema(version=$version, relationshipTypeNames=$relationshipTypeNames, " + + "ungovernedEntityTypeNames=$ungovernedEntityTypeNames, " + + "ungovernedRelationshipTypeNames=$ungovernedRelationshipTypeNames)" companion object { @@ -103,6 +129,9 @@ class DeclaredSchema( ): DeclaredSchema = DeclaredSchema( version = MetamodelVersion.from(dataDictionary, selector, aliases), relationshipTypeNames = MetamodelVersion.governedRelationshipTypeNames(dataDictionary, selector), + ungovernedEntityTypeNames = MetamodelVersion.ungovernedEntityTypeNames(dataDictionary, selector), + ungovernedRelationshipTypeNames = + MetamodelVersion.ungovernedRelationshipTypeNames(dataDictionary, selector), ) } } diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt index aa9170eb..eaaec421 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt @@ -532,5 +532,32 @@ class MetamodelVersion @JvmOverloads constructor( .filter { selector.governs(it.from) } .map { it.name } .toSet() + + /** + * The dictionary's entity type names that [selector] leaves out of governance. + * + * These are still types the host declared — just ones it chose not to version. A drift + * check needs to tell them apart from a type nobody ever declared, so they travel as their + * own set, computed straight from the dictionary and the selector's verdict on each type. + */ + internal fun ungovernedEntityTypeNames( + dataDictionary: DataDictionary, + selector: GovernedTypeSelector, + ): Set = dataDictionary.domainTypes + .filterNot { selector.governs(it) } + .map { it.name } + .toSet() + + /** + * The bare relationship type names [selector] leaves out of governance: the complement of + * [governedRelationshipTypeNames] over every relationship the dictionary allows. + */ + internal fun ungovernedRelationshipTypeNames( + dataDictionary: DataDictionary, + selector: GovernedTypeSelector, + ): Set = dataDictionary.allowedRelationships() + .filterNot { selector.governs(it.from) } + .map { it.name } + .toSet() } } diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt index b2892717..4c721226 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt @@ -151,6 +151,12 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { declared.version.entityTypeLabels.values.flatten() + declared.version.entityTypeAliases.values.flatten() + // A type the host's dictionary names but the selector leaves outside governance is a known + // type, and the drift check has to recognise it as such. It gets its own excluded set, + // separate from declaredLabels, because it must stay out of unobservedEntityTypes below: a + // governance-exempt type with no data isn't the informational case that bucket describes. + val excludedFromDrift = declaredLabels + declared.ungovernedEntityTypeNames + // Drift is observed and never declared: orphaned data whose declaring integration is gone, // or was never registered. The opposite direction gets its own informational bucket, since a // declared type with zero instances is an ordinary state. That direction stays on the type @@ -165,12 +171,13 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { // text and can contain a `-[...]->`-shaped substring themselves. val declaredRels = declared.relationshipTypeNames val observedRels = observed.relationshipTypeNames + val relsExcludedFromDrift = declaredRels + declared.ungovernedRelationshipTypeNames return DeclaredObservedDiff( declared = declared, observedSchema = observed, - driftedEntityTypes = canonical(observedTypes - declaredLabels), - driftedRelationshipTypes = canonical(observedRels - declaredRels), + driftedEntityTypes = canonical(observedTypes - excludedFromDrift), + driftedRelationshipTypes = canonical(observedRels - relsExcludedFromDrift), unobservedEntityTypes = canonical(declaredTypes - observedTypes), unobservedRelationshipTypes = canonical(declaredRels - observedRels), ) diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DeclaredSchemaTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DeclaredSchemaTest.kt index 0adf3ecc..cdc2c3aa 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DeclaredSchemaTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DeclaredSchemaTest.kt @@ -57,6 +57,33 @@ class DeclaredSchemaTest { assertEquals(listOf("Company", "Person"), declared.version.entityTypeNames) } + @Test + fun `ungoverned types and their relationships are carried alongside the governed stamp`() { + // Sighting declares `about`, so leaving Sighting ungoverned takes its relationship with it. + val declared = DeclaredSchema.from(dictionary(), GovernedTypeSelector { it.name in setOf("Person", "Company") }) + + assertEquals(setOf("Sighting"), declared.ungovernedEntityTypeNames) + assertEquals(setOf("about"), declared.ungovernedRelationshipTypeNames) + } + + @Test + fun `a selector governing everything leaves nothing ungoverned`() { + val declared = DeclaredSchema.from(dictionary()) + + assertTrue(declared.ungovernedEntityTypeNames.isEmpty()) + assertTrue(declared.ungovernedRelationshipTypeNames.isEmpty()) + } + + @Test + fun `a selector governing nothing leaves every dictionary type ungoverned`() { + val declared = DeclaredSchema.from(dictionary(), GovernedTypeSelector { false }) + + assertEquals(setOf("Person", "Company", "Sighting"), declared.ungovernedEntityTypeNames) + assertEquals(setOf("worksAt", "about"), declared.ungovernedRelationshipTypeNames) + assertTrue(declared.version.entityTypeNames.isEmpty(), "the stamp itself must cover nothing") + assertTrue(declared.relationshipTypeNames.isEmpty()) + } + @Test fun `a source is just a supplier of the declaration`() { val source = DeclaredSchemaSource { DeclaredSchema.from(dictionary()) } diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt index 99a9ea9d..0228fdb2 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt @@ -17,6 +17,7 @@ package com.embabel.dice.metamodel import com.embabel.agent.core.Cardinality import com.embabel.agent.core.DataDictionary +import com.embabel.agent.core.DomainTypePropertyDefinition import com.embabel.agent.core.DynamicType import com.embabel.agent.core.ValuePropertyDefinition import com.embabel.dice.metamodel.support.StructuralMetamodelDiffer @@ -720,6 +721,61 @@ class MetamodelDifferTest { assertEquals(setOf("X"), diff.driftedRelationshipTypes) assertEquals(setOf(trickyRelName), diff.unobservedRelationshipTypes) } + + /** + * `Sighting` is in the dictionary and the selector deliberately excludes it. Observing it + * has to read as an ordinary open-world type doing what open-world types do: a downstream + * policy watching drift for signs of orphaned data would otherwise flag a proposition + * resting on `Sighting` for a governance decision the host left it out of on purpose. + */ + @Test + fun `a known but ungoverned type is not drift when observed`() { + val person = DynamicType("Person") + val sighting = DynamicType( + name = "Sighting", + ownProperties = listOf(DomainTypePropertyDefinition("about", person)), + ) + val dictionary = DataDictionary.fromDomainTypes("test", listOf(person, sighting)) + val declaration = DeclaredSchema.from(dictionary, GovernedTypeSelector { it.name == "Person" }) + + val diff = declaredObservedDiffer.diffAgainstObserved( + declaration, + observed(entityTypeNames = setOf("Person", "Sighting"), relationshipTypeNames = setOf("about")), + ) + + assertFalse(diff.hasDrift, "Sighting and its relationship are known and deliberately ungoverned: " + + "entity=${diff.driftedEntityTypes}, rel=${diff.driftedRelationshipTypes}") + assertTrue(diff.driftedEntityTypes.isEmpty()) + assertTrue(diff.driftedRelationshipTypes.isEmpty()) + } + + /** + * A selector that governs nothing must enforce nothing over what the dictionary already + * names, and stay a statement about governance: a type the dictionary never declared at all + * still surfaces as drift, keeping detection alive for genuinely undeclared types. + */ + @Test + fun `a selector governing no types enforces no types the dictionary declares`() { + val person = DynamicType("Person") + val sighting = DynamicType( + name = "Sighting", + ownProperties = listOf(DomainTypePropertyDefinition("about", person)), + ) + val dictionary = DataDictionary.fromDomainTypes("test", listOf(person, sighting)) + val declaration = DeclaredSchema.from(dictionary, GovernedTypeSelector { false }) + + val diff = declaredObservedDiffer.diffAgainstObserved( + declaration, + observed( + entityTypeNames = setOf("Person", "Sighting", "GhostIntegrationType"), + relationshipTypeNames = setOf("about"), + ), + ) + + assertTrue(diff.hasDrift, "a type the dictionary never named must still be reported") + assertEquals(setOf("GhostIntegrationType"), diff.driftedEntityTypes) + assertTrue(diff.driftedRelationshipTypes.isEmpty()) + } } /** From 9ff2f200f5f1a7a1277276e652e7c72df515ca42 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:20:51 -0400 Subject: [PATCH 6/8] Compare declared and observed types by own label Declared entity type names are JVM FQNs while observed mention types arrive as bare simple names, so diffAgainstObserved reported every declared type unobserved and could misread a same-named observation. DeclaredSchema now derives entityTypeOwnLabels and the differ matches either spelling, reporting drift under declared names. Ungoverned and former names get the same treatment, proven by discriminating tests. TypeIdentity ships as a spec-only SPI for mapping external type systems onto declared identity: interface, contract KDoc and an OpenAPI example, with no production references. --- CHANGELOG.md | 28 ++++ .../dice/metamodel/DeclaredSchemaSource.kt | 42 ++++++ .../embabel/dice/metamodel/TypeIdentity.kt | 116 ++++++++++++++++ .../support/StructuralMetamodelDiffer.kt | 37 +++++- .../dice/metamodel/DeclaredSchemaTest.kt | 74 +++++++++++ .../dice/metamodel/MetamodelDifferTest.kt | 124 ++++++++++++++++++ 6 files changed, 416 insertions(+), 5 deletions(-) create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/TypeIdentity.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9436ce01..d6e89d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,3 +144,31 @@ and the consumer PRs that deliver it). it as harmless. Nothing else changes for a schema that declares no aliases — pairing and substitution are no-ops with an empty alias map, and the existing diff behavior, ordering and output are unchanged. Binary compatibility is untouched; consumers recompile. + +- Type identity in the declared/observed comparison. `DeclaredSchema.entityTypeOwnLabels` holds + the label each declared entity type writes onto a node: the declared name cut at its last dot, + derived through the new `DeclaredSchema.ownLabelOf` helper. A JVM-backed type is declared by its + class name, so a stamp holds `com.example.Person` while extraction records the mention as + `Person` and the graph reports `Person` as the label. `diffAgainstObserved` put those two + spellings side by side, which reported one healthy type twice — as an unobserved declaration, + and as drift under the very label it was written with. Both directions now match on either + spelling: a declared type counts as observed when the graph reports its declared name or its own + label, and the drift exclusion covers the own labels of the declared types, of declared former + names, and of the known-but-ungoverned types, which reach the check carrying no label set of + their own. What gets reported is unchanged — an unobserved type comes back under the name the + stamp declares. Two declared names differing only in their package share one own label and the + set holds it once, which is what a graph does as well: a label carries no package, so a node + written under either type reads back the same way. A schema whose declared names hold no dots + behaves as it did before, pinned by the existing suite. + Alongside it, `TypeIdentity`, **EXPERIMENTAL** (shape may change before 1.0): the interface a + host implements to say which declared entity type an outside name means, for names arriving from + a TypeScript API, an OpenAPI document, or any other system that spells types its own way. This + slice ships it as a specification — KDoc, a stated contract (total, deterministic, + round-tripping, many-to-one, exact string matching) and a worked OpenAPI example. Nothing in + DICE implements it, calls it or wires it; the shipped differ compares on the own-label rule + above, which covers the graph. + **Compatibility: additive, carrying one behavioral fix.** One new property on `DeclaredSchema`, + one new static helper, one new interface; no existing API touched. `contentHash` is untouched: + own labels are derived on demand and reach no hash. The behavioral part is the fix itself — for + a host declaring fully qualified type names, a drift check that reported such a type in both + buckets at once reports it in neither. A host whose declared names hold no dots sees no change. diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt index 89b0d696..d576fb43 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt @@ -58,6 +58,31 @@ class DeclaredSchema( val ungovernedRelationshipTypeNames: Set = java.util.Set.copyOf(ungovernedRelationshipTypeNames) + /** + * The simple labels the declared entity types carry into a graph: each name in + * [MetamodelVersion.entityTypeNames] cut down to the part after its final dot. + * + * A declared name can be fully qualified. A JVM-backed type is named by its class name, so the + * stamp holds `com.example.Person`, while extraction records a mention of it as `Person` and a + * graph reports `Person` as the label on the node. A comparison that put those two spellings + * side by side would call every declared type unobserved and read a same-named observed type as + * drift, so the comparison runs on these labels. + * + * The cut is textual: it takes the name apart at its last dot and changes nothing else. Two + * spellings sit outside it — a JVM nested class carries its outer class in the class name after + * a `$`, and the agent platform uppercases the first character of a label it derives this way, + * where this keeps every character as declared. A host that meets either one maps its own + * names; the `TypeIdentity` SPI in this package specifies that mapping. + * + * Two declared names differing only in their package share one label, and this set holds it + * once. A graph does the same: a label carries no package, so nothing reading one back can tell + * those two types apart. + * + * Derived from [version], so [equals] and [toString] stay on the fields a caller handed in. + */ + val entityTypeOwnLabels: Set = + java.util.Set.copyOf(version.entityTypeNames.mapTo(mutableSetOf()) { ownLabelOf(it) }) + override fun equals(other: Any?): Boolean = other is DeclaredSchema && version == other.version && @@ -79,6 +104,23 @@ class DeclaredSchema( companion object { + /** + * The label a declared entity type name writes onto a node: the part after its final dot, + * or the whole name when it holds no dot. + * + * This is the cut the agent platform makes when it turns a type name into a label, so a + * stamp holding `com.example.Person` lines up with the `Person` a graph reports. A name + * ending in a dot has nothing after it and stands as its own label; that spelling is + * malformed, and folding it into an empty label would quietly match every other malformed + * name. + * + * @param entityTypeName A declared entity type name. + * @return Its own label. + */ + @JvmStatic + fun ownLabelOf(entityTypeName: String): String = + entityTypeName.substringAfterLast('.').ifEmpty { entityTypeName } + /** * Declare the governed part of [dataDictionary]: stamp it and carry through the bare * relationship names the same governed types declare. diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/TypeIdentity.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/TypeIdentity.kt new file mode 100644 index 00000000..ad874427 --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/TypeIdentity.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.dice.metamodel + +/** + * How a host says which declared entity type an outside name means. + * + * A DICE schema is declared in the agent platform's terms, and a declared name is often a JVM class + * name: `com.example.Person`. The names that come back at it are spelled by whatever produced them. + * A graph reports the label `Person`. Extraction records a mention type of `Person`. A TypeScript + * API calls it `PersonDTO`, and an OpenAPI document calls the same thing `PersonRecord`. Every one + * of those has to land on the declared type before a drift check, a quarantine decision or a + * provenance record can mean anything. + * + * The built-in answer covers the graph, and only the graph: + * [DeclaredSchema.entityTypeOwnLabels] cuts a declared name down at its last dot, which is how a + * label is derived, and the shipped differ compares on that. A name system whose spelling + * is anything else needs a host to say what maps to what, and this interface is the shape of that + * statement. + * + * ## Contract + * + * - **Total.** Any string can arrive, including one the host has never heard of. + * [declaredNameFor] answers `null` for a name this mapping claims nothing about, and + * [externalNamesFor] answers an empty set. An unknown name is an ordinary answer; throwing would + * turn a drift check into an outage. + * - **Deterministic and free of side effects.** A drift check calls this once per observed name on + * every pass, so an implementation reads a table it already holds and touches no network. + * - **Round trip.** When `declaredNameFor(x)` answers `d`, then `x` is in `externalNamesFor(d)`. + * The two directions answer the same drift check: one decides whether an observed name is + * declared, the other decides whether a declared type was seen. A mapping that accepted a name one + * way and dropped it the other would report one type as both undeclared and missing. + * - **Many to one.** Several outside names may map to one declared type — a DTO, a record and a + * label for the same thing. One outside name maps to at most one declared type: a name that could + * mean two declared types identifies nothing, and the host resolves it before answering. + * - **Exact strings.** Names are compared character for character. A mapping that wants case + * folding or trimming does it inside its own answers. + * - **Declared names only.** What [declaredNameFor] returns is a name that appears in + * [MetamodelVersion.entityTypeNames], spelled the way the stamp spells it. A mapping that answers + * something else names a type nobody declared, which reads downstream as the drift it was meant to + * explain. + * + * ## Example + * + * A host serves an OpenAPI-described API whose schema names differ from its JVM class names, and + * maps the two: + * + * ```kotlin + * class OpenApiTypeIdentity(private val schemaNames: Map) : TypeIdentity { + * + * override val typeSystem: String = "openapi" + * + * override fun declaredNameFor(externalName: String): String? = schemaNames[externalName] + * + * override fun externalNamesFor(declaredTypeName: String): Set = + * schemaNames.filterValues { it == declaredTypeName }.keys + * } + * + * val identity = OpenApiTypeIdentity( + * mapOf( + * "PersonRecord" to "com.example.Person", + * "PersonSummary" to "com.example.Person", + * ), + * ) + * + * identity.declaredNameFor("PersonRecord") // "com.example.Person" + * identity.declaredNameFor("Widget") // null: nothing here claims it + * identity.externalNamesFor("com.example.Person") // ["PersonRecord", "PersonSummary"] + * ``` + * + * ## Status + * + * A specification, and nothing more. Nothing in DICE implements it, calls it or wires it: this file + * holds the contract so the host mapping has a written shape to be built against, and the slice + * that reads it lands separately. Experimental: shape may change before 1.0. + */ +interface TypeIdentity { + + /** + * Which name system this mapping speaks for, as a short stable id: `openapi`, `typescript`, + * `graph-label`. Free-form, and used to say where a name came from when several mappings are in + * play at once. + */ + val typeSystem: String + + /** + * The declared entity type an outside name means. + * + * @param externalName A type name as [typeSystem] spells it. + * @return The declared entity type name, or `null` when this mapping claims nothing about + * [externalName]. + */ + fun declaredNameFor(externalName: String): String? + + /** + * Every name a declared entity type can arrive under in [typeSystem]. + * + * @param declaredTypeName A name from [MetamodelVersion.entityTypeNames]. + * @return The outside names that map to it, empty when this mapping knows the type by no name + * of its own. + */ + fun externalNamesFor(declaredTypeName: String): Set +} diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt index 4c721226..362c1dc7 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt @@ -144,25 +144,39 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { // nobody had touched, so the declared side of the drift check is the type names plus every // label those types declare. // + // A declared name can also be fully qualified where the observed one is simple. The stamp + // holds `com.example.Person` for a JVM-backed type, extraction records the mention as + // `Person`, and the graph reports `Person`. Both spellings of every declared type go on the + // declared side, through DeclaredSchema.entityTypeOwnLabels. + // // Declared former names count as declared too. Nodes written before a type was renamed keep // the old label, and the rename was declared, so the old label is known. Leaving it out - // would report a declared rename as drift on every check from then on. + // would report a declared rename as drift on every check from then on. A former name is a + // declared name, so it brings its own label with it the same way. + val declaredAliases = declared.version.entityTypeAliases.values.flatten() val declaredLabels = declaredTypes + + declared.entityTypeOwnLabels + declared.version.entityTypeLabels.values.flatten() + - declared.version.entityTypeAliases.values.flatten() + declaredAliases + + ownLabelsOf(declaredAliases) // A type the host's dictionary names but the selector leaves outside governance is a known // type, and the drift check has to recognise it as such. It gets its own excluded set, // separate from declaredLabels, because it must stay out of unobservedEntityTypes below: a // governance-exempt type with no data isn't the informational case that bucket describes. - val excludedFromDrift = declaredLabels + declared.ungovernedEntityTypeNames + // These names come off the same dictionary the governed ones do, so they can be fully + // qualified in the same way, and their own labels are excluded alongside them. + val excludedFromDrift = declaredLabels + + declared.ungovernedEntityTypeNames + + ownLabelsOf(declared.ungovernedEntityTypeNames) // Drift is observed and never declared: orphaned data whose declaring integration is gone, // or was never registered. The opposite direction gets its own informational bucket, since a // declared type with zero instances is an ordinary state. That direction stays on the type // names: "declared but with no data" is a statement about types, and a parent label listed // as an unobserved type would be noise about something that was never a type in its own - // right. + // right. A declared type counts as observed under either spelling of its own name, so a + // fully qualified declaration is answered by the simple label a graph reports for it. // // Relationships compare on the bare type name, because that is all a graph can report: a // `db.relationshipTypes()`-style query knows the type, not which node types an instance @@ -178,7 +192,7 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { observedSchema = observed, driftedEntityTypes = canonical(observedTypes - excludedFromDrift), driftedRelationshipTypes = canonical(observedRels - relsExcludedFromDrift), - unobservedEntityTypes = canonical(declaredTypes - observedTypes), + unobservedEntityTypes = canonical(declaredTypes.filterNot { isObserved(it, observedTypes) }), unobservedRelationshipTypes = canonical(declaredRels - observedRels), ) } @@ -425,6 +439,19 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { private companion object { + /** + * The own labels of a set of declared names: [DeclaredSchema.ownLabelOf] over each of them. + */ + private fun ownLabelsOf(names: Collection): Set = + names.mapTo(mutableSetOf()) { DeclaredSchema.ownLabelOf(it) } + + /** + * Whether the graph reported a declared type, under either spelling of its name: the name + * as it was declared, or the label that name writes onto a node. + */ + private fun isObserved(declaredTypeName: String, observedTypes: Set): Boolean = + declaredTypeName in observedTypes || DeclaredSchema.ownLabelOf(declaredTypeName) in observedTypes + /** Old names and the new names claiming them, where no one claim is exclusive. */ private class ContestedClaim(val formerNames: Set, val candidates: Set) diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DeclaredSchemaTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DeclaredSchemaTest.kt index cdc2c3aa..cfba5158 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DeclaredSchemaTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DeclaredSchemaTest.kt @@ -84,6 +84,80 @@ class DeclaredSchemaTest { assertTrue(declared.relationshipTypeNames.isEmpty()) } + /** + * A JVM-backed type is declared by its class name, and the graph reports the last segment of it. + * The own label is that segment, so the two sides of a drift check have a name in common. + */ + @Test + fun `a fully qualified declared name yields its simple own label`() { + val declared = DeclaredSchema( + version = MetamodelVersion( + schemaName = "app", + entityTypeNames = listOf("com.example.Person"), + entityTypeLabels = mapOf("com.example.Person" to setOf("Person")), + entityTypeProperties = mapOf("com.example.Person" to emptySet()), + relationshipNames = emptyList(), + ), + relationshipTypeNames = emptySet(), + ) + + assertEquals(setOf("Person"), declared.entityTypeOwnLabels) + assertEquals(listOf("com.example.Person"), declared.version.entityTypeNames) + } + + @Test + fun `an undotted declared name is its own label`() { + val declared = DeclaredSchema.from(dictionary()) + + assertEquals(setOf("Person", "Company", "Sighting"), declared.entityTypeOwnLabels) + } + + /** + * Two packages, one simple name. The set holds `Person` once, which is all a graph can hold: + * a label carries no package, so a node written under either type comes back the same way. + * A drift check therefore accepts an observed `Person` for both, and the report says so. + */ + @Test + fun `two declared names sharing a simple name collapse to one own label`() { + val declared = DeclaredSchema( + version = MetamodelVersion( + schemaName = "app", + entityTypeNames = listOf("com.example.Person", "com.other.Person"), + entityTypeLabels = mapOf( + "com.example.Person" to setOf("Person"), + "com.other.Person" to setOf("Person"), + ), + entityTypeProperties = mapOf( + "com.example.Person" to emptySet(), + "com.other.Person" to emptySet(), + ), + relationshipNames = emptyList(), + ), + relationshipTypeNames = emptySet(), + ) + + assertEquals(setOf("Person"), declared.entityTypeOwnLabels) + assertEquals(2, declared.version.entityTypeNames.size, "both types stay declared in full") + } + + @Test + fun `a name ending in a dot stands as its own label`() { + // Nothing follows the dot, and an empty label would match every other name shaped this way. + assertEquals("com.example.", DeclaredSchema.ownLabelOf("com.example.")) + assertEquals("Person", DeclaredSchema.ownLabelOf("com.example.Person")) + assertEquals("Person", DeclaredSchema.ownLabelOf("Person")) + } + + @Test + fun `own labels cannot be mutated through the getter`() { + val declared = DeclaredSchema.from(dictionary()) + + @Suppress("UNCHECKED_CAST") + assertThrows { + (declared.entityTypeOwnLabels as MutableSet).add("Sneaky") + } + } + @Test fun `a source is just a supplier of the declaration`() { val source = DeclaredSchemaSource { DeclaredSchema.from(dictionary()) } diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt index 0228fdb2..23a03828 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt @@ -549,6 +549,106 @@ class MetamodelDifferTest { assertEquals(setOf("NeverSeenYet"), diff.unobservedEntityTypes) } + /** + * A JVM-backed type is declared by its class name, and what comes back at it is the simple + * label: extraction records a mention type of `Person`, and the graph reports `Person` on + * the node. Comparing the two spellings directly called every fully qualified type + * unobserved, and read the label it was written under as drift — one declared type + * reported twice, in both buckets, on a schema in perfect health. + */ + @Test + fun `a fully qualified declared type is matched by the simple label a graph reports`() { + val diff = declaredObservedDiffer.diffAgainstObserved( + declared("com.example.Person"), + observed(entityTypeNames = setOf("Person")), + ) + assertFalse(diff.hasDrift, "the observed label names the declared type: ${diff.driftedEntityTypes}") + assertTrue(diff.driftedEntityTypes.isEmpty()) + assertTrue( + diff.unobservedEntityTypes.isEmpty(), + "the declared type was observed under its label: ${diff.unobservedEntityTypes}", + ) + } + + @Test + fun `a graph reporting a declared name in full still matches it`() { + val diff = declaredObservedDiffer.diffAgainstObserved( + declared("com.example.Person"), + observed(entityTypeNames = setOf("com.example.Person")), + ) + assertFalse(diff.hasDrift) + assertTrue(diff.unobservedEntityTypes.isEmpty()) + } + + /** + * The label match must stay a match on the declared name and nothing else. A type nobody + * declared is still drift, which is the whole point of the bucket. + */ + @Test + fun `an observed label matching no declared name or own label is still drift`() { + val diff = declaredObservedDiffer.diffAgainstObserved( + declared("com.example.Person"), + observed(entityTypeNames = setOf("Person", "Ghost")), + ) + assertTrue(diff.hasDrift) + assertEquals(setOf("Ghost"), diff.driftedEntityTypes) + assertTrue(diff.unobservedEntityTypes.isEmpty()) + } + + @Test + fun `a fully qualified declared type with no data is unobserved under its declared name`() { + val diff = declaredObservedDiffer.diffAgainstObserved( + declared("com.example.Person", "com.example.NeverSeenYet"), + observed(entityTypeNames = setOf("Person")), + ) + assertFalse(diff.hasDrift) + assertEquals(setOf("com.example.NeverSeenYet"), diff.unobservedEntityTypes) + } + + /** + * An ungoverned type is declared by the same dictionary as a governed one, so its name can + * be fully qualified too, and it reaches the drift check with no label set of its own to + * fall back on. Reading it by its simple label is what keeps a deliberately ungoverned type + * out of drift once its name is qualified. + */ + @Test + fun `a known but ungoverned fully qualified type is not drift when observed by its label`() { + val person = DynamicType("com.example.Person") + val sighting = DynamicType( + name = "com.example.Sighting", + ownProperties = listOf(DomainTypePropertyDefinition("about", person)), + ) + val dictionary = DataDictionary.fromDomainTypes("test", listOf(person, sighting)) + val declaration = DeclaredSchema.from(dictionary, GovernedTypeSelector { it.name == "com.example.Person" }) + + assertEquals(setOf("com.example.Sighting"), declaration.ungovernedEntityTypeNames) + + val diff = declaredObservedDiffer.diffAgainstObserved( + declaration, + observed(entityTypeNames = setOf("Person", "Sighting"), relationshipTypeNames = setOf("about")), + ) + + assertFalse(diff.hasDrift, "both types are declared: ${diff.driftedEntityTypes}") + assertTrue(diff.driftedEntityTypes.isEmpty()) + assertTrue(diff.unobservedEntityTypes.isEmpty()) + } + + @Test + fun `an ungoverned qualified type does not hide an undeclared one`() { + val person = DynamicType("com.example.Person") + val sighting = DynamicType("com.example.Sighting") + val dictionary = DataDictionary.fromDomainTypes("test", listOf(person, sighting)) + val declaration = DeclaredSchema.from(dictionary, GovernedTypeSelector { it.name == "com.example.Person" }) + + val diff = declaredObservedDiffer.diffAgainstObserved( + declaration, + observed(entityTypeNames = setOf("Person", "Sighting", "Ghost")), + ) + + assertTrue(diff.hasDrift) + assertEquals(setOf("Ghost"), diff.driftedEntityTypes) + } + @Test fun `relationship drift is compared on the bare relationship type name, not the full descriptor`() { // Declared descriptors carry from/to node types; an observed graph only reports bare @@ -1650,6 +1750,30 @@ class MetamodelDifferTest { assertEquals(setOf("GhostIntegrationType"), diff.driftedEntityTypes) } + /** + * A declared former name is a declared name, so it carries its own label the same way a + * live one does. Rename `com.example.Person` to `com.example.Human` and the nodes written + * before the rename still come back labelled `Person`, which the declaration explains. + */ + @Test + fun `a qualified former name is known by its label too`() { + val renamed = DeclaredSchema( + version = versionOf( + listOf("com.example.Human"), + aliases = mapOf("com.example.Human" to setOf("com.example.Person")), + ), + relationshipTypeNames = emptySet(), + ) + + val diff = declaredObservedDiffer.diffAgainstObserved(renamed, observed("Human", "Person")) + + assertFalse(diff.hasDrift, "the rename was declared: ${diff.driftedEntityTypes}") + assertTrue(diff.unobservedEntityTypes.isEmpty()) + + val ghosted = declaredObservedDiffer.diffAgainstObserved(renamed, observed("Human", "Ghost")) + assertEquals(setOf("Ghost"), ghosted.driftedEntityTypes) + } + @Test fun `a former name is not reported as an unobserved type`() { val diff = declaredObservedDiffer.diffAgainstObserved(declared, observed("Human")) From 1951aabd736ebb566ebb9552c9f252111deffe70 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:33:35 -0400 Subject: [PATCH 7/8] Mark the diff and observation surface experimental --- .../main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt | 4 ++++ .../main/kotlin/com/embabel/dice/metamodel/MetamodelDiffer.kt | 3 +++ .../main/kotlin/com/embabel/dice/metamodel/ObservedSchema.kt | 3 +++ .../kotlin/com/embabel/dice/metamodel/ObservedSchemaSource.kt | 3 +++ .../main/kotlin/com/embabel/dice/metamodel/TypeIdentity.kt | 3 +++ 5 files changed, 16 insertions(+) diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt index 1bd29084..e979bc9f 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiff.kt @@ -14,6 +14,8 @@ * limitations under the License. */ package com.embabel.dice.metamodel +import org.jetbrains.annotations.ApiStatus + import java.util.Objects @@ -458,6 +460,7 @@ sealed interface MetamodelChange { * [MetamodelChange.PropertySignatureChanged] by property name. The same pair of versions * always produces the same list. */ +@ApiStatus.Experimental class MetamodelDiff( val fromVersion: MetamodelVersion, val toVersion: MetamodelVersion, @@ -607,6 +610,7 @@ class MetamodelDiff( * @property unobservedEntityTypes Declared entity type names with no observed instances. * @property unobservedRelationshipTypes Declared relationship type names with no observed instances. */ +@ApiStatus.Experimental class DeclaredObservedDiff( val declared: DeclaredSchema, val observedSchema: ObservedSchema, diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiffer.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiffer.kt index 3e07674a..5bcbbc36 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiffer.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelDiffer.kt @@ -15,6 +15,8 @@ */ package com.embabel.dice.metamodel +import org.jetbrains.annotations.ApiStatus + import com.embabel.agent.core.DataDictionary /** @@ -28,6 +30,7 @@ import com.embabel.agent.core.DataDictionary * application stamps at ingestion time and stores the stamp, so the older side comes back out of a * [MetamodelVersionStore] rather than being recomputed from a dictionary that has since moved on. */ +@ApiStatus.Experimental interface MetamodelDiffer { /** diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchema.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchema.kt index fbe6df4b..7526a917 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchema.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchema.kt @@ -15,6 +15,8 @@ */ package com.embabel.dice.metamodel +import org.jetbrains.annotations.ApiStatus + import java.time.Instant import java.util.Objects @@ -38,6 +40,7 @@ import java.util.Objects * @property relationshipTypeNames Relationship type names observed in the graph. * @property capturedAt When this snapshot was taken. */ +@ApiStatus.Experimental class ObservedSchema( entityTypeNames: Set, relationshipTypeNames: Set, diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchemaSource.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchemaSource.kt index 46f9da95..6a69b4a8 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchemaSource.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/ObservedSchemaSource.kt @@ -15,6 +15,8 @@ */ package com.embabel.dice.metamodel +import org.jetbrains.annotations.ApiStatus + import com.embabel.agent.core.ContextId /** @@ -34,6 +36,7 @@ import com.embabel.agent.core.ContextId * Kotlin default argument. It is also all Java gets: `ContextId` is a Kotlin value class, so the * scoped form compiles to a mangled JVM name Java can't call. */ +@ApiStatus.Experimental interface ObservedSchemaSource { /** diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/TypeIdentity.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/TypeIdentity.kt index ad874427..d5e35c78 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/TypeIdentity.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/TypeIdentity.kt @@ -15,6 +15,8 @@ */ package com.embabel.dice.metamodel +import org.jetbrains.annotations.ApiStatus + /** * How a host says which declared entity type an outside name means. * @@ -87,6 +89,7 @@ package com.embabel.dice.metamodel * holds the contract so the host mapping has a written shape to be built against, and the slice * that reads it lands separately. Experimental: shape may change before 1.0. */ +@ApiStatus.Experimental interface TypeIdentity { /** From e280a1135b903aa0ca95428182c2222d5cd0287f Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:15:15 -0400 Subject: [PATCH 8/8] Count a type observed under its declared former name The drift side already treats a declared former name as known, so nodes still labelled with a renamed type's old name are not drift. The unobserved bucket only looked for the current name and its label, so the same graph read as "no drift" and "type unobserved" at once. A type whose data still carries its old label is observed, and both sides now say so. --- .../support/StructuralMetamodelDiffer.kt | 25 ++++++++++---- .../dice/metamodel/MetamodelDifferTest.kt | 33 +++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt index 362c1dc7..b6c80177 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/StructuralMetamodelDiffer.kt @@ -176,7 +176,10 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { // names: "declared but with no data" is a statement about types, and a parent label listed // as an unobserved type would be noise about something that was never a type in its own // right. A declared type counts as observed under either spelling of its own name, so a - // fully qualified declaration is answered by the simple label a graph reports for it. + // fully qualified declaration is answered by the simple label a graph reports for it. A + // declared former name counts the same way: if a type's data still only carries the label + // from before it was renamed, that is the type being observed, not a type with no data at + // all, and this bucket has to agree with the drift check above on that. // // Relationships compare on the bare type name, because that is all a graph can report: a // `db.relationshipTypes()`-style query knows the type, not which node types an instance @@ -192,7 +195,11 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { observedSchema = observed, driftedEntityTypes = canonical(observedTypes - excludedFromDrift), driftedRelationshipTypes = canonical(observedRels - relsExcludedFromDrift), - unobservedEntityTypes = canonical(declaredTypes.filterNot { isObserved(it, observedTypes) }), + unobservedEntityTypes = canonical( + declaredTypes.filterNot { + isObserved(it, declared.version.entityTypeAliases[it].orEmpty(), observedTypes) + }, + ), unobservedRelationshipTypes = canonical(declaredRels - observedRels), ) } @@ -446,11 +453,17 @@ class StructuralMetamodelDiffer : MetamodelDiffer, DeclaredObservedDiffer { names.mapTo(mutableSetOf()) { DeclaredSchema.ownLabelOf(it) } /** - * Whether the graph reported a declared type, under either spelling of its name: the name - * as it was declared, or the label that name writes onto a node. + * Whether the graph reported a declared type, under any spelling of its current name or of + * a declared former name: as declared, or as the label that name writes onto a node. */ - private fun isObserved(declaredTypeName: String, observedTypes: Set): Boolean = - declaredTypeName in observedTypes || DeclaredSchema.ownLabelOf(declaredTypeName) in observedTypes + private fun isObserved( + declaredTypeName: String, + formerNames: Set, + observedTypes: Set, + ): Boolean = + (setOf(declaredTypeName) + formerNames).any { + it in observedTypes || DeclaredSchema.ownLabelOf(it) in observedTypes + } /** Old names and the new names claiming them, where no one claim is exclusive. */ private class ContestedClaim(val formerNames: Set, val candidates: Set) diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt index 23a03828..dd47c7c6 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelDifferTest.kt @@ -1782,6 +1782,39 @@ class MetamodelDifferTest { "a former name was never a type of its own: ${diff.unobservedEntityTypes}", ) } + + @Test + fun `a type whose data is still labelled with its former name is observed`() { + val diff = declaredObservedDiffer.diffAgainstObserved(declared, observed("Person")) + assertFalse(diff.hasDrift, "the rename was declared, so the old label is known: ${diff.driftedEntityTypes}") + assertTrue( + diff.unobservedEntityTypes.isEmpty(), + "data under the declared former name is Human being seen, not a Human nobody wrote: ${diff.unobservedEntityTypes}", + ) + } + + @Test + fun `a qualified former name observed by its label counts as observed too`() { + val renamed = DeclaredSchema( + version = versionOf( + listOf("com.example.Human"), + aliases = mapOf("com.example.Human" to setOf("com.example.Person")), + ), + relationshipTypeNames = emptySet(), + ) + + val diff = declaredObservedDiffer.diffAgainstObserved(renamed, observed("Person")) + + assertFalse(diff.hasDrift, "the rename was declared: ${diff.driftedEntityTypes}") + assertTrue(diff.unobservedEntityTypes.isEmpty()) + } + + @Test + fun `a type with no data under any of its names is still unobserved`() { + val diff = declaredObservedDiffer.diffAgainstObserved(declared, observed("Ghost")) + assertEquals(setOf("Human"), diff.unobservedEntityTypes) + assertEquals(setOf("Ghost"), diff.driftedEntityTypes) + } } /**