Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,99 @@ 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
Comment thread
jimador marked this conversation as resolved.
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
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.

- Declared renames in the diff, **EXPERIMENTAL** (shape may change before 1.0): five new
`MetamodelChange` members — `PropertyRenamed(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 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
`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 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 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
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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,30 +31,96 @@ 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
Comment thread
jimador marked this conversation as resolved.
* [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<String>,
ungovernedEntityTypeNames: Set<String> = emptySet(),
ungovernedRelationshipTypeNames: Set<String> = emptySet(),
) {

/** Copied into a JVM-immutable set so the declaration can't drift from its stamped [version]. */
val relationshipTypeNames: Set<String> = java.util.Set.copyOf(relationshipTypeNames)

val ungovernedEntityTypeNames: Set<String> = java.util.Set.copyOf(ungovernedEntityTypeNames)

val ungovernedRelationshipTypeNames: Set<String> = 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<String> =
java.util.Set.copyOf(version.entityTypeNames.mapTo(mutableSetOf()) { ownLabelOf(it) })

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 {

/**
* 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.
Expand Down Expand Up @@ -103,6 +171,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),
)
}
}
Expand Down
Loading
Loading