From a3a1997bffe52e82c3d0370be47df66efafd623c Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:24:55 -0400 Subject: [PATCH 01/11] feat(metamodel): drift checking and quarantine DriftReport with a bounded, explicitly scoped DriftReportStore contract (scope-before-limit pinned by test), DriftCheckRunner with dryRun-first semantics, and the quarantine layer: lossiness decided on property signatures (narrowing quarantines, widening never), already-quarantined classification independent of the current diff, non-destructive STALE writes. DefaultDriftCheckRunner stamps the declared version every run so report hashes always resolve, and takes the base PropositionStore port. dice-metamodel now depends on dice core. Refs #45; stacks on feat/metamodel-diff. --- CHANGELOG.md | 26 + dice-metamodel/pom.xml | 31 +- .../dice/metamodel/DriftCheckRunner.kt | 120 +++++ .../dice/metamodel/DriftQuarantinePolicy.kt | 142 ++++++ .../com/embabel/dice/metamodel/DriftReport.kt | 225 +++++++++ .../support/DefaultDriftCheckRunner.kt | 160 ++++++ .../MentionTypeDriftQuarantinePolicy.kt | 231 +++++++++ .../dice/metamodel/DriftCheckRunnerTest.kt | 472 ++++++++++++++++++ .../metamodel/DriftQuarantinePolicyTest.kt | 442 ++++++++++++++++ .../dice/metamodel/DriftReportStoreTest.kt | 161 ++++++ .../embabel/dice/metamodel/DriftReportTest.kt | 113 +++++ .../dice/metamodel/InMemoryMetamodelStores.kt | 99 ++++ docs/design/INDEX.md | 5 +- docs/design/architecture.md | 18 +- docs/design/metamodel-diff.md | 13 +- docs/design/metamodel-drift.md | 226 +++++++++ docs/design/metamodel-versioning.md | 19 +- 17 files changed, 2481 insertions(+), 22 deletions(-) create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt create mode 100644 dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt create mode 100644 dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt create mode 100644 dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportStoreTest.kt create mode 100644 dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportTest.kt create mode 100644 dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt create mode 100644 docs/design/metamodel-drift.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d6e89d3f..5dd08e69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,3 +172,29 @@ and the consumer PRs that deliver it). 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. +- Drift checking and quarantine contracts in `dice-metamodel`, plus the default runner. + `DriftCheckRunner` sequences one check — declare → stamp → observe → diff → report → optionally + quarantine — and is dry-run by default: `run()` persists a `DriftReport` and touches no + proposition. `DefaultDriftCheckRunner` stamps the declared version into the + `MetamodelVersionStore` on every run *before* writing the report, so a report's `versionHash` + always resolves through `findVersion`; the write upserts on `(schemaName, contentHash)`, so an + unchanged schema costs one idempotent write. `DriftReportStore` is the durable log, separate from + the version store because stamps and reports have different volumes and lifetimes. Every read on + it is **bounded** — `driftReports`, `globalDriftReports` and `driftReportsInContext` each take a + `limit` and an optional `since`, and none has a default body, because filtering a limited page + down to one scope in memory applies the limit before the filter and can report zero drift while + plenty sits in the store. Quarantine is non-destructive and idempotent: `DriftQuarantinePolicy` + returns `QuarantineDecision`s (`Conforming` / `Quarantined` / `AlreadyQuarantined`) as immutable + `STALE` copies carrying a reason under `dice.metamodel.quarantine.reason`, and the caller + persists them. The shipped `MentionTypeDriftQuarantinePolicy` fires only on lossy changes — + a removed type, a type that lost labels or properties, or a property whose signature narrowed + (type changed, value ↔ reference, or cardinality shrank along `ONE` ⊂ `OPTIONAL` ⊂ `SET` ⊂ + `LIST`) — never on additive ones. An inherited label observed in the graph is declared, not + drift, so it never quarantines. A `ContextId` scopes the observation, the candidate propositions + and the persisted report alike, so a mis-declared schema in one context cannot reach another's + data. Still no Drivine implementation and no Spring wiring; both arrive in later slices. + **Compatibility: additive.** New types in an existing module; no existing API touched. One + dependency-graph change: `dice-metamodel` now depends on `dice` (core), because quarantine works + on the proposition model — anything depending on `dice-metamodel` alone now pulls `dice` in + transitively. `dice-metamodel` is no longer a leaf module, and `embabel-agent-rag-core` joins + `embabel-agent-api` as a `provided` dependency it expects the host to supply. diff --git a/dice-metamodel/pom.xml b/dice-metamodel/pom.xml index 67a95652..0141e63f 100644 --- a/dice-metamodel/pom.xml +++ b/dice-metamodel/pom.xml @@ -10,13 +10,23 @@ dice-metamodel jar Dice Metamodel - Schema versioning for DICE knowledge graphs: content-hash stamping, the declared-schema contract, and the version store contract + Schema governance for DICE knowledge graphs: content-hash stamping, the declared-schema contract, diffing, drift checking, and non-destructive quarantine + + com.embabel.dice + dice + + + com.embabel.agent @@ -34,6 +44,19 @@ provided + + + com.embabel.agent + embabel-agent-rag-core + provided + + + + + org.slf4j + slf4j-api + + org.springframework.boot diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt new file mode 100644 index 00000000..f926c314 --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt @@ -0,0 +1,120 @@ +/* + * 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 +import java.util.Objects + +/** + * What one [DriftCheckRunner.run] call found and did. + * + * The drift itself lives in [report] and is read back off it, rather than being copied into + * fields here. Every run persists a report, so the two would otherwise be two copies of the same + * answer — and the failure mode of two copies is that they disagree, leaving a caller who logged + * the result and an operator who read the stored report looking at different type sets for the same + * check. There is one set of drifted types per run, and it is the one that got written down. + * + * @property dryRun Whether this was a preview. On a dry run the report is still persisted; no + * proposition is touched. + * @property report The [DriftReport] this run saved. Every run saves one, including a clean one — + * "checked and found nothing" has to be as retrievable as "checked and found drift". + * @property quarantinedCount How many propositions this run newly quarantined. Always 0 on a dry + * run, and 0 whenever there was no entity-type drift. + */ +class DriftCheckResult( + val dryRun: Boolean, + val report: DriftReport, + val quarantinedCount: Int, +) { + + /** The declared schema the check ran against. */ + val schemaName: String get() = report.schemaName + + /** The context the check was scoped to, or `null` when it covered the whole graph. */ + val contextId: ContextId? get() = report.contextId + + /** Entity type names the graph held but the schema never declared. */ + val driftedEntityTypes: Set get() = report.driftedEntityTypes + + /** Relationship type names observed with no matching declaration. */ + val driftedRelationshipTypes: Set get() = report.driftedRelationshipTypes + + /** `true` when the graph contained any type or relationship that was never declared. */ + val hasDrift: Boolean get() = report.hasDrift + + override fun equals(other: Any?): Boolean = + other is DriftCheckResult && + dryRun == other.dryRun && + report == other.report && + quarantinedCount == other.quarantinedCount + + override fun hashCode(): Int = Objects.hash(dryRun, report, quarantinedCount) + + override fun toString(): String = + "DriftCheckResult(dryRun=$dryRun, quarantinedCount=$quarantinedCount, report=$report)" +} + +/** + * Runs a drift check end to end: takes the declared schema, stamps it, snapshots what a live graph + * actually holds, compares the two, writes the result down, and — only if you ask — quarantines the + * propositions the drift stranded. + * + * Dry-run by default, and that default is the whole design stance. Observing and reporting is + * useful on its own and can't hurt anything; changing proposition state is a separate decision + * somebody has to make on purpose. Nothing here schedules itself either — a consuming application + * decides when [run] is called, the same way it does for the collector. + * + * The shorter [run] forms are real overloads with bodies rather than Kotlin default arguments, + * because Java can't see a default argument: `runner.run()` has to exist as a method for a Java + * caller to write it. Implementations override the two-argument form and get the other two free. + * Those two shorter forms are also the whole Java surface — `ContextId` is a Kotlin value class, so + * the two-argument form compiles to a mangled JVM name Java can't call. + */ +interface DriftCheckRunner { + + /** + * Declare, stamp, observe, diff, report — and, when [dryRun] is `false` and drift touched any + * entity type, quarantine. + * + * @param dryRun When `true`, the check runs and its [DriftReport] is persisted, but no + * proposition is touched. When `false`, propositions whose mentions reference a drifted + * entity type are handed to the configured [DriftQuarantinePolicy] and whatever it flags is + * persisted. + * @param contextId `null` means the check covers the whole graph. Non-null scopes everything + * the check touches to that one context: the observed snapshot, the candidate propositions + * read for quarantine, and the persisted [DriftReport]. A mis-declared schema in one context + * can then only ever quarantine propositions in that same context — it has no way to reach + * another one. + * @return What was found, and what was quarantined if this was a live run. + */ + fun run(dryRun: Boolean, contextId: ContextId?): DriftCheckResult + + /** + * Run a dry check over the whole graph — the safe default. Nothing is quarantined; the report + * is still persisted. + * + * @return What was found. + */ + fun run(): DriftCheckResult = run(dryRun = true, contextId = null) + + /** + * Run over the whole graph, dry or live. + * + * @param dryRun `true` to preview without touching any proposition. + * @return What was found, and what was quarantined if this was a live run. + */ + fun run(dryRun: Boolean): DriftCheckResult = run(dryRun, null) +} diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt new file mode 100644 index 00000000..06749f4f --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt @@ -0,0 +1,142 @@ +/* + * 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.dice.proposition.Proposition + +/** + * What a policy decided about one [Proposition]. + * + * Three outcomes, not two, and the third is the one that's easy to miss: a proposition an earlier + * sweep already quarantined. It isn't clean, so calling it conforming would overstate how healthy + * the set is, and it isn't newly quarantined either, since this sweep deliberately left it alone to + * preserve its original reason. It gets its own variant so `conforming.size` means what it says. + * + * Sealed, so a `when` over the outcomes is exhaustive and the compiler speaks up if a fourth + * ever lands. + */ +sealed interface QuarantineDecision { + + /** + * Nothing in the schema change touches this proposition; it needs no action. + * + * @property proposition The proposition, unchanged. + */ + data class Conforming(val proposition: Proposition) : QuarantineDecision + + /** + * An earlier sweep already quarantined this one — it is `STALE` and carries a + * `DiceMetadataKeys.QUARANTINE_REASON` — so this sweep left it exactly as it found it. Nothing + * needs persisting for these. + * + * To force one back through evaluation, clear its `QUARANTINE_REASON` metadata and pass it in + * again. + * + * @property proposition The proposition, unchanged. + * @property originalReason The reason the earlier sweep recorded, when it is still readable as + * text. `null` when the metadata value is present but isn't a string — the proposition still + * counts as already quarantined; only the explanation is unrecoverable. + */ + data class AlreadyQuarantined( + val proposition: Proposition, + val originalReason: String?, + ) : QuarantineDecision + + /** + * Schema drift stranded this proposition, and it has been flagged. + * + * [proposition] is an immutable copy already moved to `STALE` and annotated with the reason + * under `DiceMetadataKeys.QUARANTINE_REASON`. The original is never mutated, and nothing is + * written anywhere — persisting the copy is the caller's job. + * + * @property proposition The flagged, `STALE` copy. + * @property reason A human-readable explanation of why it was quarantined. + * @property affectedMentionTypes The entity type names that triggered it. + */ + data class Quarantined( + val proposition: Proposition, + val reason: String, + val affectedMentionTypes: Set, + ) : QuarantineDecision +} + +/** + * What a whole sweep decided, with one decision per proposition it was given. + * + * @property conforming Propositions the change doesn't touch — genuinely clean. + * @property quarantined Propositions this sweep flagged, as `STALE` copies waiting to be persisted. + * @property alreadyQuarantined Propositions an earlier sweep had already flagged, left untouched by + * this one. Empty unless the input contained some. + */ +data class QuarantineResult @JvmOverloads constructor( + val conforming: List, + val quarantined: List, + val alreadyQuarantined: List = emptyList(), +) { + + /** How many propositions the sweep looked at. */ + val total: Int get() = conforming.size + quarantined.size + alreadyQuarantined.size + + /** Every proposition the sweep saw, in one flat list. */ + val allPropositions: List + get() = conforming.map { it.proposition } + + quarantined.map { it.proposition } + + alreadyQuarantined.map { it.proposition } +} + +/** + * Decides which propositions a schema change has stranded, and flags them. + * + * Quarantining is **non-destructive**. An affected proposition comes back as an immutable copy + * moved to [com.embabel.dice.proposition.PropositionStatus.STALE] with a metadata note explaining + * why; the original is untouched and nothing is written to any store. Persisting the copies is + * deliberately the caller's job — the policy is a decision, not an effect, which is what lets a + * drift check preview one without changing anything. + * + * It takes a [MetamodelDiff] — a comparison of two *declared* versions — because "what did the + * schema stop recognising?" is the question that matters, and a diff answers it precisely. A drift + * check, which compares a declaration against a live graph instead, synthesizes the equivalent diff + * rather than re-deciding quarantine on its own terms. + * + * ```kotlin + * val diff = differ.diff(previousVersion, currentVersion) + * val result = policy.evaluate(diff, repository.findAll()) + * result.quarantined.forEach { repository.save(it.proposition) } + * ``` + */ +interface DriftQuarantinePolicy { + + /** + * Evaluate every proposition against [diff]. + * + * Implementations must be **idempotent**: a proposition already quarantined by a prior sweep + * (`STALE` with a `QUARANTINE_REASON`) must not have its original reason overwritten. Those come + * back unchanged as [QuarantineDecision.AlreadyQuarantined] — not as conforming, which would + * misreport them as clean. Clear the metadata key to force one back through evaluation. + * + * That classification does not depend on [diff]. Being already quarantined is a fact about the + * proposition, so an empty or purely additive diff must still sort those into + * [QuarantineResult.alreadyQuarantined] rather than short-circuiting the whole input into + * [QuarantineResult.conforming]. Drift checks run on a schedule and most of them find nothing, + * so a shortcut there would make quarantined records look healthy nearly every run. + * + * @param diff What changed between the old and new schema. + * @param propositions The propositions to evaluate. Any [Iterable] will do — a list, a + * repository page, a lazy sequence. + * @return One decision per input proposition. + */ + fun evaluate(diff: MetamodelDiff, propositions: Iterable): QuarantineResult +} diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt new file mode 100644 index 00000000..39354f94 --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt @@ -0,0 +1,225 @@ +/* + * 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 +import java.time.Instant +import java.util.Objects + +/** + * One drift check, written down: what a live graph held that nobody had declared, at one moment, + * measured against one declared schema version. + * + * A report is a fact about a point in time, not a running total. Keeping every one of them is what + * lets you answer "when did this start?" and "is it getting worse?" later — a single mutable + * "current drift" field could only ever answer "right now", and would quietly lose the answer the + * moment somebody fixed the schema. + * + * [versionHash] is the tie back to the schema this was judged against. A `DriftCheckRunner` stamps + * the declared version into a [MetamodelVersionStore] before writing the report, so the hash always + * resolves through [MetamodelVersionStore.findVersion] — you can pull a year-old report and still + * recover the exact shape that was expected when it was taken. + * + * @property schemaName The declared schema's name at check time. Together with [versionHash] this + * is what resolves the report back to a stored [MetamodelVersion]. + * @property versionHash The [MetamodelVersion.contentHash] of the declared schema the check ran + * against. + * @property driftedEntityTypes Entity type names (labels) the graph held but the schema never + * declared, sorted the way the diff produced them. + * @property driftedRelationshipTypes Relationship type names observed with no matching declaration. + * @property capturedAt When the observation was taken — the [ObservedSchema.capturedAt] of the + * snapshot it was computed from, not the time of the write. + * @property contextId The context the check was scoped to, or `null` when it covered the whole + * graph. + */ +class DriftReport @JvmOverloads constructor( + val schemaName: String, + val versionHash: String, + driftedEntityTypes: Set, + driftedRelationshipTypes: Set, + val capturedAt: Instant, + val contextId: ContextId? = null, +) { + + // Both sets are copied into genuinely immutable ones that keep the order they arrived in, and + // this is a plain class rather than a `data class`, for the same reason as everything else in + // this module: a record of a moment must not be reshapeable afterwards, Kotlin's read-only + // `Set` is a compile-time promise a Java caller sees straight through, and a generated `copy()` + // would hand its arguments to the fields and skip the copying entirely. + + val driftedEntityTypes: Set = immutableCopy(driftedEntityTypes) + + val driftedRelationshipTypes: Set = immutableCopy(driftedRelationshipTypes) + + /** `true` when this check found anything undeclared at all. */ + val hasDrift: Boolean + get() = driftedEntityTypes.isNotEmpty() || driftedRelationshipTypes.isNotEmpty() + + override fun equals(other: Any?): Boolean = + other is DriftReport && + schemaName == other.schemaName && + versionHash == other.versionHash && + driftedEntityTypes == other.driftedEntityTypes && + driftedRelationshipTypes == other.driftedRelationshipTypes && + capturedAt == other.capturedAt && + contextId == other.contextId + + override fun hashCode(): Int = Objects.hash( + schemaName, + versionHash, + driftedEntityTypes, + driftedRelationshipTypes, + capturedAt, + contextId, + ) + + override fun toString(): String = + "DriftReport(schemaName=$schemaName, versionHash=$versionHash, " + + "driftedEntityTypes=$driftedEntityTypes, driftedRelationshipTypes=$driftedRelationshipTypes, " + + "capturedAt=$capturedAt, contextId=${contextId?.value})" + + private companion object { + + private fun immutableCopy(values: Set): Set = + java.util.Collections.unmodifiableSet(LinkedHashSet(values)) + } +} + +/** + * Durable log of drift checks. Append-only in spirit: nothing is ever deleted, so the history of + * what a graph looked like against what was declared accumulates and stays answerable. + * + * Kept apart from [MetamodelVersionStore] on purpose. Stamps and reports have different lifetimes + * and very different volumes — a schema gets stamped when somebody changes it, while a scheduled + * drift check writes a report every run whether it found anything or not. Folding both into one + * interface would force any backend to serve both access patterns, and would make "I only want to + * record versions" impossible to express. + * + * **What "save" means here.** [saveDriftReport] is an upsert on the natural key `(schemaName, + * versionHash, capturedAt, contextId)`. Two observations differing in any of those are separate + * records; re-saving one with the same key overwrites its drifted type sets rather than adding a + * duplicate. + * + * ## Every read is bounded + * + * There is no "give me all of them". A drift log grows once per check per schema forever, so an + * unbounded read is a query that works on a laptop and falls over in production after a month of + * hourly checks — and the caller who wrote it had no way to know. Every read here therefore takes a + * `limit`, and optionally a `since` instant to bound the window as well. Callers ask for a page; + * they never ask for a table. + * + * ## Three reads, no defaults + * + * The scope is explicit at the call site: [driftReports] is everything, [globalDriftReports] is + * only unscoped whole-graph checks, and [driftReportsInContext] is one context's. Three names + * rather than one method with a nullable context, because `driftReports(schema, null)` would have + * quietly meant "the global ones" while `driftReports(schema)` meant "all of them" — the same + * looking call with a different answer and nothing but the doc to tell them apart. Splitting them + * also gives Java callers a way to reach the global reports at all: `ContextId` is a Kotlin value + * class, so [driftReportsInContext] compiles to a mangled JVM name Java can't call, while the other + * two stay callable. + * + * None of the three has a default body, and that is the direct consequence of bounding the reads. + * Filtering `driftReports(schema, limit)` down to the global ones in memory would return at most + * `limit` rows *before* the filter, so a schema whose recent history is mostly context-scoped could + * report zero global drift while plenty sat in the store — a wrong answer that looks like a right + * one. The scope has to be pushed down into the query, so each implementation writes all three. + */ +interface DriftReportStore { + + /** + * Record one drift observation, keyed on `(schemaName, versionHash, capturedAt, contextId)`. + * + * @param report The observation to save. + */ + fun saveDriftReport(report: DriftReport) + + /** + * Reports for a schema at any scope — global checks and every context's, mixed together — + * newest first by [DriftReport.capturedAt]. + * + * @param schemaName The schema to look up. + * @param limit The most reports to return. Must be positive. + * @param since When non-null, only reports captured at or after this instant. + * @return At most [limit] matching reports, newest first. Empty when there are none. + * @throws IllegalArgumentException if [limit] is not positive. + */ + fun driftReports(schemaName: String, limit: Int, since: Instant?): List + + /** + * The same read with no time window. + * + * A real overload with a body rather than a Kotlin default argument, so Java callers can write + * it too — Java cannot see a Kotlin default argument. + * + * @param schemaName The schema to look up. + * @param limit The most reports to return. Must be positive. + * @return At most [limit] matching reports, newest first. + */ + fun driftReports(schemaName: String, limit: Int): List = + driftReports(schemaName, limit, null) + + /** + * Reports from unscoped, whole-graph checks only — those whose [DriftReport.contextId] is + * `null`. A check scoped to a context is excluded no matter which context it was. + * + * @param schemaName The schema to look up. + * @param limit The most reports to return. Must be positive. + * @param since When non-null, only reports captured at or after this instant. + * @return At most [limit] matching reports, newest first. + * @throws IllegalArgumentException if [limit] is not positive. + */ + fun globalDriftReports(schemaName: String, limit: Int, since: Instant?): List + + /** + * The same read with no time window. + * + * @param schemaName The schema to look up. + * @param limit The most reports to return. Must be positive. + * @return At most [limit] matching reports, newest first. + */ + fun globalDriftReports(schemaName: String, limit: Int): List = + globalDriftReports(schemaName, limit, null) + + /** + * Reports from checks scoped to [contextId]. Global reports are excluded, as are other + * contexts'. + * + * @param schemaName The schema to look up. + * @param contextId The context to restrict to. + * @param limit The most reports to return. Must be positive. + * @param since When non-null, only reports captured at or after this instant. + * @return At most [limit] matching reports, newest first. + * @throws IllegalArgumentException if [limit] is not positive. + */ + fun driftReportsInContext( + schemaName: String, + contextId: ContextId, + limit: Int, + since: Instant?, + ): List + + /** + * The same read with no time window. + * + * @param schemaName The schema to look up. + * @param contextId The context to restrict to. + * @param limit The most reports to return. Must be positive. + * @return At most [limit] matching reports, newest first. + */ + fun driftReportsInContext(schemaName: String, contextId: ContextId, limit: Int): List = + driftReportsInContext(schemaName, contextId, limit, null) +} diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt new file mode 100644 index 00000000..3c672183 --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt @@ -0,0 +1,160 @@ +/* + * 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.agent.core.ContextId +import com.embabel.dice.metamodel.DeclaredObservedDiffer +import com.embabel.dice.metamodel.DeclaredSchemaSource +import com.embabel.dice.metamodel.DriftCheckResult +import com.embabel.dice.metamodel.DriftCheckRunner +import com.embabel.dice.metamodel.DriftQuarantinePolicy +import com.embabel.dice.metamodel.DriftReport +import com.embabel.dice.metamodel.DriftReportStore +import com.embabel.dice.metamodel.MetamodelChange +import com.embabel.dice.metamodel.MetamodelDiff +import com.embabel.dice.metamodel.MetamodelVersion +import com.embabel.dice.metamodel.MetamodelVersionStore +import com.embabel.dice.metamodel.ObservedSchemaSource +import com.embabel.dice.proposition.PropositionStore +import org.slf4j.LoggerFactory + +/** + * The shipped [DriftCheckRunner]. It sequences the collaborators and decides nothing itself: the + * comparison belongs to the differ, the quarantine call to the policy, and this class only makes + * sure they happen in an order that leaves a coherent record behind. + * + * Stateless, so calling it repeatedly or for different schemas at once is fine. Two concurrent + * checks of the *same* schema aren't corrupting — each captures its own complete snapshot — but + * they are wasteful; serialize at the scheduling layer if that matters. + * + * ## Stamp before you report + * + * The declared version is saved to [versionStore] on **every** run, before the report is written. + * That looks redundant, because a version that hasn't changed re-saves onto its own key and stores + * nothing new. The point is the guarantee it buys: a [DriftReport] records the + * [MetamodelVersion.contentHash] it was judged against, and that hash is only useful if it resolves + * back to a real stamp through [MetamodelVersionStore.findVersion]. Stamping last, or only when the + * schema moved, leaves the first check after a schema change pointing at a hash nothing has ever + * recorded — the reports that matter most are exactly the ones that would dangle. Since + * `saveVersion` upserts on `(schemaName, contentHash)`, paying for it every run costs one idempotent + * write and removes the failure mode entirely. + * + * @param declaredSchemaSource Supplies the schema as declared. Read first, so everything downstream + * is judged against one declaration. + * @param versionStore Where the declared stamp is recorded each run, so report hashes always + * resolve. + * @param observedSchemaSource Snapshots what the live graph actually contains. + * @param differ Compares the declaration against the observation. + * @param driftReportStore Durable log the report is written to — every run, drift or not. + * @param quarantinePolicy Decides which stranded propositions to quarantine. Consulted only on a + * live run that found entity-type drift; this runner never reimplements the decision. + * @param propositionStore Where candidate propositions are read from and quarantined copies are + * saved back to. The base persistence port, not `PropositionRepository`: a drift check only ever + * reads by context or in bulk and saves, so asking for vector search, graph traversal and + * temporal query alongside would shut a plain store-and-retrieve backend out of drift checking + * for capabilities it is never asked to use. + */ +class DefaultDriftCheckRunner( + private val declaredSchemaSource: DeclaredSchemaSource, + private val versionStore: MetamodelVersionStore, + private val observedSchemaSource: ObservedSchemaSource, + private val differ: DeclaredObservedDiffer, + private val driftReportStore: DriftReportStore, + private val quarantinePolicy: DriftQuarantinePolicy, + private val propositionStore: PropositionStore, +) : DriftCheckRunner { + + private val logger = LoggerFactory.getLogger(DefaultDriftCheckRunner::class.java) + + override fun run(dryRun: Boolean, contextId: ContextId?): DriftCheckResult { + val declared = declaredSchemaSource.declare() + + // Stamp first — see the class doc. This has to happen before the report is written, so the + // hash the report carries is already resolvable by the time anyone can read it. + versionStore.saveVersion(declared.version) + + val observed = observedSchemaSource.observe(contextId) + val diff = differ.diffAgainstObserved(declared = declared, observed = observed) + + val report = DriftReport( + schemaName = declared.version.schemaName, + versionHash = declared.version.contentHash, + driftedEntityTypes = diff.driftedEntityTypes, + driftedRelationshipTypes = diff.driftedRelationshipTypes, + // The instant the graph was looked at, not the instant this write happens: the report + // is a statement about the snapshot. + capturedAt = observed.capturedAt, + contextId = contextId, + ) + // Written unconditionally. A zero-drift check is a fact worth having on record, not a no-op. + driftReportStore.saveDriftReport(report) + + val quarantinedCount = if (!dryRun && diff.driftedEntityTypes.isNotEmpty()) { + quarantineDriftedEntityTypes(declared.version, diff.driftedEntityTypes, contextId) + } else { + 0 + } + + logger.info( + "Drift check for '{}' complete (dryRun={}, contextId={}): {} drifted entity type(s), " + + "{} drifted relationship type(s), {} quarantined", + declared.version.schemaName, + dryRun, + contextId?.value, + diff.driftedEntityTypes.size, + diff.driftedRelationshipTypes.size, + quarantinedCount, + ) + + return DriftCheckResult(dryRun = dryRun, report = report, quarantinedCount = quarantinedCount) + } + + /** + * Hand the drifted types to [quarantinePolicy] and persist whatever it flags. + * + * The policy takes a [MetamodelDiff] — two *declared* versions compared — but what a drift check + * has is a declaration compared against a live observation, which is a different question. They + * agree on the part the policy cares about, though: a mention whose type the declared schema + * doesn't recognise is stranded either way, whether the type was dropped from a newer + * declaration or was never declared at all. So we synthesize the equivalent diff — nothing but a + * [MetamodelChange.EntityTypeRemoved] per drifted type — and let the real policy decide, rather + * than re-deciding quarantine here with a second, subtly different rule. + * + * Both ends of the synthesized diff point at the same declared version. There was no old-to-new + * transition; the two sides are there only so the policy's reason string has something to name. + */ + private fun quarantineDriftedEntityTypes( + declaredVersion: MetamodelVersion, + driftedEntityTypes: Set, + contextId: ContextId?, + ): Int { + val syntheticDiff = MetamodelDiff( + fromVersion = declaredVersion, + toVersion = declaredVersion, + changes = driftedEntityTypes.sorted().map { MetamodelChange.EntityTypeRemoved(it) }, + ) + // Scoping is the whole blast radius: a proposition in another context is never a candidate, + // so nothing this run does can reach it, whatever its mentions say. + val propositions = if (contextId != null) { + propositionStore.findByContextId(contextId) + } else { + propositionStore.findAll() + } + val result = quarantinePolicy.evaluate(syntheticDiff, propositions) + result.quarantined.forEach { decision -> propositionStore.save(decision.proposition) } + return result.quarantined.size + } +} diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt new file mode 100644 index 00000000..8c774f36 --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt @@ -0,0 +1,231 @@ +/* + * 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.agent.core.Cardinality +import com.embabel.dice.common.DiceMetadataKeys +import com.embabel.dice.metamodel.DriftQuarantinePolicy +import com.embabel.dice.metamodel.MetamodelChange +import com.embabel.dice.metamodel.MetamodelDiff +import com.embabel.dice.metamodel.PropertySignature +import com.embabel.dice.metamodel.QuarantineDecision +import com.embabel.dice.metamodel.QuarantineResult +import com.embabel.dice.proposition.Proposition +import com.embabel.dice.proposition.PropositionStatus +import org.slf4j.LoggerFactory + +/** + * The shipped [DriftQuarantinePolicy]: quarantine a proposition when one of its entity mentions + * names a type the schema change made **lossy**. Lossy means the change can strand data that was + * already extracted: + * + * - the type was **removed** — nothing describes those mentions any more; + * - the type kept its name but **lost** labels or whole properties; + * - a property kept its name but its shape **narrowed** — its value type or reference target + * changed, it flipped between holding a value and pointing at another type, or its cardinality + * shrank (a list collapsing to a single value, an optional becoming required). + * + * Everything else is additive and never triggers quarantine: new types, new labels, new properties, + * and cardinality moving the other way (a single value becoming a list holds everything it held + * before). This is where the diff's deliberate refusal to judge gets resolved — + * [MetamodelChange.PropertySignatureChanged] states that `age` went from `string` to `integer`, and + * this policy is what decides that stranding is possible and the affected propositions should be + * pulled out of normal use until a person looks. + * + * The conservative call on a type change is to treat *any* move as lossy, in either direction. We + * know the declared type names moved; we don't know how the backend stored the values or whether + * the new type can read the old ones, and guessing wrong in the permissive direction leaves + * unreadable data looking healthy. Swap in a different policy if your storage makes some widenings + * provably safe. + * + * Quarantining transitions the proposition to [PropositionStatus.STALE] and annotates it under + * [DiceMetadataKeys.QUARANTINE_REASON]. Both produce an immutable copy — the original is never + * mutated, and persisting the copies is the caller's job. + * + * A proposition an earlier sweep already quarantined comes back as + * [QuarantineDecision.AlreadyQuarantined], untouched: never re-flagged, original reason preserved, + * and never counted as conforming. That holds whatever the diff in front of us looks like, an empty + * one included — being already quarantined is a fact about the proposition, not about this check. + */ +class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { + + private val logger = LoggerFactory.getLogger(MentionTypeDriftQuarantinePolicy::class.java) + + override fun evaluate(diff: MetamodelDiff, propositions: Iterable): QuarantineResult { + val removedTypes = diff.removedEntityTypes + + // Types whose name survived but which lost labels or whole properties. Also lossy: a + // mention may have relied on a label or property that is simply gone. Keyed by type name. + val lossyModified = diff.modifiedEntityTypes + .filter { it.removedLabels.isNotEmpty() || it.removedProperties.isNotEmpty() } + .associateBy { it.typeName } + + // Types carrying a property that kept its name but narrowed. Grouped by type name, since + // one type can have several such properties and the reason should name them all. + val narrowedProperties = diff.propertySignatureChanges + .filter { isNarrowing(it) } + .groupBy { it.typeName } + + // There is deliberately no "nothing lossy, so everything conforms" shortcut here. Whether a + // proposition is already quarantined has nothing to do with the diff in front of us — it is + // a fact about the proposition — and a shortcut that skipped the check would report an + // earlier sweep's quarantined records as Conforming the moment a later check happened to + // find nothing new. Since drift checks run on a schedule and most of them find nothing, that + // is the common case, not the rare one: quarantined data would look healthy almost always. + // One code path, always classified. + + val conforming = mutableListOf() + val quarantined = mutableListOf() + // Propositions left alone because a previous sweep already quarantined them. Their own + // bucket rather than folded into conforming: they aren't clean, and a caller reading + // conforming.size as a health number would be wrong about them. + val alreadyQuarantined = mutableListOf() + + for (proposition in propositions) { + if (isAlreadyQuarantined(proposition)) { + logger.debug( + "Leaving already-quarantined proposition (id={}) untouched; original reason preserved", + proposition.id, + ) + alreadyQuarantined += QuarantineDecision.AlreadyQuarantined( + proposition = proposition, + originalReason = proposition.metadata[DiceMetadataKeys.QUARANTINE_REASON] as? String, + ) + continue + } + + val mentionTypes = proposition.mentions.mapTo(mutableSetOf()) { it.type } + val removedHit = mentionTypes intersect removedTypes + val lossyHit = mentionTypes intersect lossyModified.keys + val narrowedHit = mentionTypes intersect narrowedProperties.keys + val affectedTypes = removedHit + lossyHit + narrowedHit + + if (affectedTypes.isEmpty()) { + conforming += QuarantineDecision.Conforming(proposition) + continue + } + + val reason = buildReason( + removedTypes = removedHit, + lossyChanges = lossyHit.map { lossyModified.getValue(it) }, + narrowedChanges = narrowedHit.flatMap { narrowedProperties.getValue(it) }, + fromSchema = diff.fromVersion.schemaName, + toSchema = diff.toVersion.schemaName, + ) + val flagged = proposition + .withStatus(PropositionStatus.STALE) + .withMetadataValue(DiceMetadataKeys.QUARANTINE_REASON, reason) + + logger.debug("Quarantining proposition '{}' (id={}): {}", proposition.text, proposition.id, reason) + + quarantined += QuarantineDecision.Quarantined( + proposition = flagged, + reason = reason, + affectedMentionTypes = affectedTypes, + ) + } + + logger.info( + "Drift quarantine sweep complete: {} conforming, {} already quarantined from a prior sweep, " + + "{} newly quarantined (removed types: {}, lossy-modified types: {}, narrowed-property types: {})", + conforming.size, + alreadyQuarantined.size, + quarantined.size, + removedTypes, + lossyModified.keys, + narrowedProperties.keys, + ) + + return QuarantineResult( + conforming = conforming, + quarantined = quarantined, + alreadyQuarantined = alreadyQuarantined, + ) + } + + /** + * Whether a proposition is one an earlier sweep already handled: `STALE` *and* carrying a + * quarantine reason. Both halves matter — a proposition made stale by ordinary decay carries no + * reason and is still a live candidate here. + */ + private fun isAlreadyQuarantined(proposition: Proposition): Boolean = + proposition.status == PropositionStatus.STALE && + proposition.metadata.containsKey(DiceMetadataKeys.QUARANTINE_REASON) + + /** + * Whether a property's new shape might not hold what its old shape did. + * + * A changed value type or a flip between value and reference always counts. Cardinality counts + * only when it shrank: the four cardinalities line up as `ONE` ⊂ `OPTIONAL` ⊂ `SET` ⊂ `LIST` by + * what they can hold, so moving up that order is safe (one value fits in a list) and moving + * down can strand something (a list of three doesn't fit in a single value; a list collapsing + * to a set drops duplicates). + */ + private fun isNarrowing(change: MetamodelChange.PropertySignatureChanged): Boolean = + change.typeChanged || + change.kindChanged || + breadth(change.after.cardinality) < breadth(change.before.cardinality) + + /** How much a cardinality can hold, as a rank — bigger holds everything smaller can. */ + private fun breadth(cardinality: Cardinality): Int = when (cardinality) { + Cardinality.ONE -> 0 + Cardinality.OPTIONAL -> 1 + Cardinality.SET -> 2 + Cardinality.LIST -> 3 + } + + private fun buildReason( + removedTypes: Set, + lossyChanges: List, + narrowedChanges: List, + fromSchema: String, + toSchema: String, + ): String { + val clauses = mutableListOf() + + if (removedTypes.isNotEmpty()) { + clauses += "type(s) [${removedTypes.sorted().joinToString(", ")}] removed" + } + + lossyChanges.sortedBy { it.typeName }.forEach { change -> + val losses = mutableListOf() + if (change.removedLabels.isNotEmpty()) { + losses += "label(s) [${change.removedLabels.sorted().joinToString(", ")}]" + } + if (change.removedProperties.isNotEmpty()) { + // Names, not full signatures: a reason is read by a person deciding whether to + // rescue the proposition, and a rendered PropertySignature buries the name in + // constructor noise. + losses += "propert${if (change.removedPropertyNames.size == 1) "y" else "ies"} " + + "[${change.removedPropertyNames.sorted().joinToString(", ")}]" + } + clauses += "type '${change.typeName}' lost ${losses.joinToString(" and ")}" + } + + narrowedChanges + .sortedWith(compareBy({ it.typeName }, { it.propertyName })) + .forEach { change -> + clauses += "type '${change.typeName}' narrowed property '${change.propertyName}' " + + "(${describe(change.before)} -> ${describe(change.after)})" + } + + return "Schema drift '$fromSchema' → '$toSchema': ${clauses.joinToString("; ")}" + } + + /** A property signature as a person would read it: `string ONE`, `Company LIST`. */ + private fun describe(signature: PropertySignature): String = + "${signature.type.ifEmpty { signature.kind.name.lowercase() }} ${signature.cardinality}" +} diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt new file mode 100644 index 00000000..5193124b --- /dev/null +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt @@ -0,0 +1,472 @@ +/* + * 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 +import com.embabel.dice.common.DiceMetadataKeys +import com.embabel.dice.metamodel.support.DefaultDriftCheckRunner +import com.embabel.dice.metamodel.support.MentionTypeDriftQuarantinePolicy +import com.embabel.dice.metamodel.support.StructuralMetamodelDiffer +import com.embabel.dice.proposition.EntityMention +import com.embabel.dice.proposition.MentionRole +import com.embabel.dice.proposition.Proposition +import com.embabel.dice.proposition.PropositionStore +import com.embabel.dice.proposition.PropositionStatus +import com.embabel.dice.proposition.store.InMemoryPropositionRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Instant + +/** + * [DefaultDriftCheckRunner] against fake sources and stores, but the *real* + * [StructuralMetamodelDiffer] and [MentionTypeDriftQuarantinePolicy] — both covered on their own + * elsewhere — so these tests exercise the actual delegation rather than a stand-in for it. + */ +class DriftCheckRunnerTest { + + private val contextId = ContextId("test-context") + private val otherContextId = ContextId("other-context") + private val schemaName = "test-schema" + private val capturedAt = Instant.parse("2026-01-01T00:00:00Z") + + private lateinit var versionStore: InMemoryMetamodelVersionStore + private lateinit var reportStore: OrderRecordingDriftReportStore + private lateinit var propositionStore: InMemoryPropositionRepository + + /** Declared schema, overridable per test. Defaults to two types and one relationship. */ + private var declaredEntityTypes = listOf("Person", "Company") + private var declaredEntityTypeLabels: Map>? = null + private var declaredRelationshipTypeNames = setOf("WORKS_AT") + + /** Observed schema, overridable per test. Defaults to matching the declaration exactly. */ + private var observedEntityTypes = setOf("Person", "Company") + private var observedRelationshipTypeNames = setOf("WORKS_AT") + + @BeforeEach + fun setUp() { + versionStore = InMemoryMetamodelVersionStore() + reportStore = OrderRecordingDriftReportStore(versionStore) + propositionStore = InMemoryPropositionRepository() + } + + private fun declaredVersion(): MetamodelVersion = MetamodelVersion( + schemaName = schemaName, + entityTypeNames = declaredEntityTypes, + entityTypeLabels = declaredEntityTypeLabels ?: declaredEntityTypes.associateWith { setOf(it) }, + entityTypeProperties = declaredEntityTypes.associateWith { emptySet() }, + relationshipNames = declaredRelationshipTypeNames.map { "Person-[$it]->Company" }, + ) + + // Typed as the base persistence port, not PropositionRepository: whatever a test passes in, + // the runner only ever gets store-and-retrieve out of it. + private fun buildRunner(store: PropositionStore = propositionStore): DriftCheckRunner { + val declaredSchema = DeclaredSchema( + version = declaredVersion(), + relationshipTypeNames = declaredRelationshipTypeNames, + ) + return DefaultDriftCheckRunner( + declaredSchemaSource = DeclaredSchemaSource { declaredSchema }, + versionStore = versionStore, + // Each snapshot gets its own instant, a minute apart, the way real observations do. It + // matters: a report's natural key includes the capture instant, so two checks sharing + // one really are the same observation and collapse to a single record. + observedSchemaSource = object : ObservedSchemaSource { + private var observations = 0L + + override fun observe(contextId: ContextId?): ObservedSchema = ObservedSchema( + entityTypeNames = observedEntityTypes, + relationshipTypeNames = observedRelationshipTypeNames, + capturedAt = capturedAt.plusSeconds(60 * observations++), + ) + }, + differ = StructuralMetamodelDiffer(), + driftReportStore = reportStore, + quarantinePolicy = MentionTypeDriftQuarantinePolicy(), + propositionStore = store, + ) + } + + private fun proposition( + text: String, + vararg mentionTypes: String, + inContext: ContextId = contextId, + ): Proposition = Proposition( + contextId = inContext, + text = text, + mentions = mentionTypes.map { type -> + EntityMention(span = type.lowercase(), type = type, role = MentionRole.SUBJECT) + }, + confidence = 0.9, + ) + + private fun savedReports(): List = reportStore.driftReports(schemaName, limit = 100) + + // ---- Stamping ---- + + @Test + fun `every run stamps the declared version, so the report's hash resolves`() { + val runner = buildRunner() + + val result = runner.run(dryRun = true) + + val resolved = versionStore.findVersion(schemaName, result.report.versionHash) + assertNotNull(resolved, "a report's versionHash is useless if nothing ever recorded that stamp") + assertEquals(declaredVersion(), resolved) + } + + @Test + fun `the stamp is written before the report, not after`() { + // The ordering is the whole point of stamping every run: a report written first would, for + // the length of that window and forever if the second write failed, name a version hash + // nothing had ever recorded. + val runner = buildRunner() + + runner.run(dryRun = true) + + assertTrue( + reportStore.versionWasResolvableWhenReportSaved.single(), + "the declared version must already be in the version store when the report is written", + ) + } + + @Test + fun `a repeated run re-stamps idempotently rather than growing history`() { + val runner = buildRunner() + + runner.run(dryRun = true) + runner.run(dryRun = true) + runner.run(dryRun = true) + + assertEquals(3, versionStore.saveCount, "the stamp is attempted every run") + assertEquals(1, versionStore.versionHistory(schemaName).size, "but an unchanged schema stores once") + assertEquals(3, savedReports().size, "while every check leaves its own report") + } + + // ---- Reporting ---- + + @Test + fun `zero drift still leaves a retrievable report`() { + val runner = buildRunner() + + val result = runner.run(dryRun = true) + + assertFalse(result.hasDrift) + assertTrue(result.driftedEntityTypes.isEmpty()) + assertTrue(result.driftedRelationshipTypes.isEmpty()) + assertEquals(0, result.quarantinedCount) + assertTrue(result.dryRun) + + val reports = savedReports() + assertEquals(1, reports.size, "even a clean check must leave a record behind") + assertEquals(result.report, reports.single()) + assertFalse(reports.single().hasDrift) + } + + @Test + fun `the report is stamped with the snapshot's instant, not the write's`() { + observedEntityTypes = setOf("Person", "Company", "GhostType") + val runner = buildRunner() + + val result = runner.run(dryRun = true) + + assertEquals(capturedAt, result.report.capturedAt) + } + + @Test + fun `the result reads its drift straight off the report it saved`() { + observedEntityTypes = setOf("Person", "Company", "GhostType") + observedRelationshipTypeNames = setOf("WORKS_AT", "UNDECLARED_LINK") + val runner = buildRunner() + + val result = runner.run(dryRun = true) + + val saved = savedReports().single() + assertEquals(saved.driftedEntityTypes, result.driftedEntityTypes) + assertEquals(saved.driftedRelationshipTypes, result.driftedRelationshipTypes) + assertEquals(saved.schemaName, result.schemaName) + assertEquals(saved.contextId, result.contextId) + } + + // ---- Dry run vs. live ---- + + @Test + fun `drift with dryRun persists the report but quarantines nothing`() { + observedEntityTypes = setOf("Person", "Company", "GhostType") + propositionStore.save(proposition("a ghost was mentioned", "GhostType")) + val runner = buildRunner() + + val result = runner.run(dryRun = true) + + assertEquals(setOf("GhostType"), result.driftedEntityTypes) + assertEquals(0, result.quarantinedCount) + assertTrue(result.dryRun) + assertEquals(setOf("GhostType"), savedReports().single().driftedEntityTypes) + + val untouched = propositionStore.findAll().single() + assertEquals(PropositionStatus.ACTIVE, untouched.status) + assertNull(untouched.metadata[DiceMetadataKeys.QUARANTINE_REASON]) + } + + @Test + fun `the default run is a dry whole-graph check`() { + observedEntityTypes = setOf("Person", "Company", "GhostType") + propositionStore.save(proposition("a ghost was mentioned", "GhostType")) + val runner = buildRunner() + + val result = runner.run() + + assertTrue(result.dryRun, "the no-argument form must be the safe one") + assertNull(result.contextId) + assertEquals(PropositionStatus.ACTIVE, propositionStore.findAll().single().status) + } + + @Test + fun `a live run delegates quarantine to the configured policy and persists what it flags`() { + observedEntityTypes = setOf("Person", "Company", "GhostType") + val affected = propositionStore.save(proposition("a ghost was mentioned", "GhostType")) + val safe = propositionStore.save(proposition("Alice works at Acme", "Person", "Company")) + val runner = buildRunner() + + val result = runner.run(dryRun = false) + + assertEquals(setOf("GhostType"), result.driftedEntityTypes) + assertEquals(1, result.quarantinedCount) + assertFalse(result.dryRun) + + val quarantined = propositionStore.findById(affected.id)!! + assertEquals(PropositionStatus.STALE, quarantined.status) + assertNotNull(quarantined.metadata[DiceMetadataKeys.QUARANTINE_REASON]) + + assertEquals(PropositionStatus.ACTIVE, propositionStore.findById(safe.id)!!.status) + assertEquals(1, savedReports().size, "the report is written on a live run just the same") + } + + @Test + fun `a plain store-and-retrieve backend can drive a live run`() { + // The runner asks for the base persistence port, so a backend with no vector search, graph + // traversal or temporal query to offer is still allowed to check for drift. + observedEntityTypes = setOf("Person", "Company", "GhostType") + val affected = propositionStore.save(proposition("a ghost was mentioned", "GhostType")) + val bareStore: PropositionStore = RecordingPropositionStore(propositionStore) + val runner = buildRunner(bareStore) + + val result = runner.run(dryRun = false) + + assertEquals(1, result.quarantinedCount) + assertEquals(PropositionStatus.STALE, propositionStore.findById(affected.id)!!.status) + } + + @Test + fun `a live run with only relationship drift never quarantines`() { + // Nothing a mention's type could ever match, so a live run must still touch nothing. + observedRelationshipTypeNames = setOf("WORKS_AT", "UNDECLARED_LINK") + propositionStore.save(proposition("Alice works at Acme", "Person", "Company")) + val runner = buildRunner() + + val result = runner.run(dryRun = false) + + assertTrue(result.driftedEntityTypes.isEmpty()) + assertEquals(setOf("UNDECLARED_LINK"), result.driftedRelationshipTypes) + assertEquals(0, result.quarantinedCount) + assertEquals(PropositionStatus.ACTIVE, propositionStore.findAll().single().status) + } + + // ---- Label closure ---- + + @Test + fun `an inherited label observed in the graph is not drift and never quarantines`() { + // Declaring Person with parent Agent puts both labels on every Person node, so the graph + // reports Agent too. Comparing observed labels against type names alone would call Agent + // undeclared and quarantine perfectly good propositions on a schema nobody had touched. + declaredEntityTypes = listOf("Person") + declaredEntityTypeLabels = mapOf("Person" to setOf("Person", "Agent")) + observedEntityTypes = setOf("Person", "Agent", "GhostType") + val agentMention = propositionStore.save(proposition("Alice acts", "Agent")) + val ghostMention = propositionStore.save(proposition("a ghost was mentioned", "GhostType")) + val runner = buildRunner() + + val result = runner.run(dryRun = false) + + assertEquals(setOf("GhostType"), result.driftedEntityTypes, "the inherited label is declared") + assertEquals(1, result.quarantinedCount) + assertEquals( + PropositionStatus.ACTIVE, + propositionStore.findById(agentMention.id)!!.status, + "a proposition mentioning an inherited label must survive a live run", + ) + assertEquals(PropositionStatus.STALE, propositionStore.findById(ghostMention.id)!!.status) + } + + @Test + fun `a declared type with no data is reported as unobserved, never as drift`() { + observedEntityTypes = setOf("Person") + propositionStore.save(proposition("Alice is a person", "Person")) + val runner = buildRunner() + + val result = runner.run(dryRun = false) + + assertTrue(result.driftedEntityTypes.isEmpty(), "declared-but-empty is an ordinary state") + assertEquals(0, result.quarantinedCount) + } + + // ---- Names that look like delimiters ---- + + @Test + fun `declared relationship names with pipes, tabs and newlines flow through untouched`() { + val delimiterLaden = setOf("REL|WITH|PIPE", "REL\tWITH\tTAB", "REL\nWITH\nNEWLINE") + declaredRelationshipTypeNames = delimiterLaden + observedRelationshipTypeNames = delimiterLaden + "UNDECLARED|ALSO\tDELIMITED" + val runner = buildRunner() + + val result = runner.run(dryRun = true) + + // The declared names must reach the differ exactly as supplied — no splitting, trimming or + // delimiter parsing — so only the genuinely undeclared name shows up as drift. + assertEquals(setOf("UNDECLARED|ALSO\tDELIMITED"), result.driftedRelationshipTypes) + assertEquals(setOf("UNDECLARED|ALSO\tDELIMITED"), savedReports().single().driftedRelationshipTypes) + } + + // ---- Context scoping ---- + + @Test + fun `a scoped run reads candidates via findByContextId, not findAll`() { + observedEntityTypes = setOf("Person", "Company", "GhostType") + propositionStore.save(proposition("a ghost was mentioned", "GhostType")) + val recording = RecordingPropositionStore(propositionStore) + val runner = buildRunner(recording) + + val result = runner.run(dryRun = false, contextId = contextId) + + assertEquals(contextId, recording.findByContextIdCall) + assertNull(recording.findAllCall) + assertEquals(contextId, result.contextId) + assertEquals(contextId, result.report.contextId) + } + + @Test + fun `an unscoped run reads candidates via findAll, not findByContextId`() { + observedEntityTypes = setOf("Person", "Company", "GhostType") + propositionStore.save(proposition("a ghost was mentioned", "GhostType")) + val recording = RecordingPropositionStore(propositionStore) + val runner = buildRunner(recording) + + val result = runner.run(dryRun = false) + + assertEquals(true, recording.findAllCall) + assertNull(recording.findByContextIdCall) + assertNull(result.contextId) + assertNull(result.report.contextId) + } + + @Test + fun `a scoped live run leaves another context's propositions completely alone`() { + // Both propositions mention the drifted type and a global run would quarantine both. + // Scoping to one context must reach exactly one of them. + observedEntityTypes = setOf("Person", "Company", "GhostType") + val inScope = propositionStore.save(proposition("a ghost in context A", "GhostType")) + val outOfScope = propositionStore.save( + proposition("a ghost in context B", "GhostType", inContext = otherContextId), + ) + val runner = buildRunner() + + val result = runner.run(dryRun = false, contextId = contextId) + + assertEquals(setOf("GhostType"), result.driftedEntityTypes) + assertEquals(1, result.quarantinedCount, "only context A's proposition is a candidate") + assertEquals(PropositionStatus.STALE, propositionStore.findById(inScope.id)!!.status) + + val untouched = propositionStore.findById(outOfScope.id)!! + assertEquals( + PropositionStatus.ACTIVE, + untouched.status, + "a check scoped to one context must not be able to reach another's propositions", + ) + assertNull(untouched.metadata[DiceMetadataKeys.QUARANTINE_REASON]) + } + + @Test + fun `a scoped dry check still stamps the report with its context`() { + val runner = buildRunner() + + val result = runner.run(dryRun = true, contextId = contextId) + + assertEquals(contextId, result.contextId) + assertEquals(contextId, result.report.contextId) + assertEquals( + listOf(result.report), + reportStore.driftReportsInContext(schemaName, contextId, limit = 10), + "and it must come back from the context-scoped read", + ) + assertTrue( + reportStore.globalDriftReports(schemaName, limit = 10).isEmpty(), + "a scoped check is not a global one", + ) + } + + /** + * Records which candidate-read the runner actually called, so a test can assert the scoped or + * global read path directly rather than inferring it from a side effect. Everything else is + * delegated unchanged. + * + * Deliberately a bare [PropositionStore] and not a `PropositionRepository`: passing one of these + * to the runner is what proves a plain store-and-retrieve backend, with no vector search or + * graph traversal to offer, can still drive a live drift check. + */ + private class RecordingPropositionStore( + private val delegate: PropositionStore, + ) : PropositionStore by delegate { + + var findAllCall: Boolean? = null + private set + + var findByContextIdCall: ContextId? = null + private set + + override fun findAll(): List { + findAllCall = true + return delegate.findAll() + } + + override fun findByContextId(contextId: ContextId): List { + findByContextIdCall = contextId + return delegate.findByContextId(contextId) + } + } + + /** + * An [InMemoryDriftReportStore] that also notes, at the moment each report is written, whether + * the version store could already resolve the hash that report carries. That is the stamp-first + * guarantee, observed at the only instant where it can actually be violated. + */ + private class OrderRecordingDriftReportStore( + private val versionStore: MetamodelVersionStore, + private val delegate: InMemoryDriftReportStore = InMemoryDriftReportStore(), + ) : DriftReportStore by delegate { + + val versionWasResolvableWhenReportSaved = mutableListOf() + + override fun saveDriftReport(report: DriftReport) { + versionWasResolvableWhenReportSaved += + versionStore.findVersion(report.schemaName, report.versionHash) != null + delegate.saveDriftReport(report) + } + } +} diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt new file mode 100644 index 00000000..3d0d97c1 --- /dev/null +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt @@ -0,0 +1,442 @@ +/* + * 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.common.DiceMetadataKeys +import com.embabel.dice.metamodel.support.MentionTypeDriftQuarantinePolicy +import com.embabel.dice.metamodel.support.StructuralMetamodelDiffer +import com.embabel.dice.proposition.EntityMention +import com.embabel.dice.proposition.MentionRole +import com.embabel.dice.proposition.Proposition +import com.embabel.dice.proposition.PropositionStatus +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +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 com.embabel.agent.core.ContextId + +class DriftQuarantinePolicyTest { + + private val contextId = ContextId("test-context") + private lateinit var policy: MentionTypeDriftQuarantinePolicy + private lateinit var differ: MetamodelDiffer + + @BeforeEach + fun setUp() { + policy = MentionTypeDriftQuarantinePolicy() + differ = StructuralMetamodelDiffer() + } + + private fun schemaWith(vararg typeNames: String): DataDictionary = + DataDictionary.fromDomainTypes("test", typeNames.map { DynamicType(name = it) }) + + private fun proposition( + text: String, + vararg mentionTypes: String, + status: PropositionStatus = PropositionStatus.ACTIVE, + ): Proposition = Proposition( + contextId = contextId, + text = text, + mentions = mentionTypes.map { type -> + EntityMention(span = type.lowercase(), type = type, role = MentionRole.SUBJECT) + }, + confidence = 0.9, + ).withStatus(status) + + private fun reasonOf(decision: QuarantineDecision.Quarantined): String = + decision.proposition.metadata[DiceMetadataKeys.QUARANTINE_REASON] as String + + @Nested + inner class NothingLossy { + + @Test + fun `an empty diff leaves everything conforming`() { + val diff = differ.diff(schemaWith("Person", "Company"), schemaWith("Person", "Company")) + + val result = policy.evaluate( + diff, + listOf( + proposition("Alice works at Acme", "Person", "Company"), + proposition("Bob likes coffee", "Person"), + ), + ) + + assertEquals(2, result.conforming.size) + assertEquals(0, result.quarantined.size) + result.conforming.forEach { assertEquals(PropositionStatus.ACTIVE, it.proposition.status) } + } + + @Test + fun `an empty proposition list produces an empty result`() { + val diff = differ.diff(schemaWith("Person"), schemaWith()) + + assertEquals(0, policy.evaluate(diff, emptyList()).total) + } + } + + @Nested + inner class RemovedTypes { + + @Test + fun `a proposition mentioning a removed type is quarantined`() { + val diff = differ.diff(schemaWith("Person", "LegacyType"), schemaWith("Person")) + + val result = policy.evaluate( + diff, + listOf(proposition("legacy entity stuff", "LegacyType"), proposition("Alice is a person", "Person")), + ) + + assertEquals(1, result.conforming.size) + assertEquals(1, result.quarantined.size) + val decision = result.quarantined.single() + assertEquals(PropositionStatus.STALE, decision.proposition.status) + assertTrue(decision.affectedMentionTypes.contains("LegacyType")) + assertNotNull(decision.proposition.metadata[DiceMetadataKeys.QUARANTINE_REASON]) + } + + @Test + fun `a conforming proposition stays ACTIVE and unannotated`() { + val diff = differ.diff(schemaWith("Person", "Removed"), schemaWith("Person")) + + val result = policy.evaluate(diff, listOf(proposition("Alice is active", "Person"))) + + assertEquals(1, result.conforming.size) + val kept = result.conforming.single().proposition + assertEquals(PropositionStatus.ACTIVE, kept.status) + assertNull(kept.metadata[DiceMetadataKeys.QUARANTINE_REASON]) + } + + @Test + fun `one bad mention among several is enough`() { + val diff = differ.diff(schemaWith("Person", "Company", "OldType"), schemaWith("Person", "Company")) + + val result = policy.evaluate(diff, listOf(proposition("Alice at Acme via OldType", "Person", "OldType"))) + + assertEquals(1, result.quarantined.size) + assertTrue(result.quarantined.single().affectedMentionTypes.contains("OldType")) + } + + @Test + fun `the reason names the removed type`() { + val diff = differ.diff(schemaWith("Person", "DeprecatedEntity"), schemaWith("Person")) + + val result = policy.evaluate(diff, listOf(proposition("something deprecated", "DeprecatedEntity"))) + + val reason = reasonOf(result.quarantined.single()) + assertTrue(reason.contains("DeprecatedEntity"), "the reason should name the type: $reason") + } + + @Test + fun `quarantine never touches the original`() { + val diff = differ.diff(schemaWith("Person", "Removed"), schemaWith("Person")) + val original = proposition("entity with removed type", "Removed") + + val result = policy.evaluate(diff, listOf(original)) + + assertEquals(PropositionStatus.STALE, result.quarantined.single().proposition.status) + assertEquals(PropositionStatus.ACTIVE, original.status, "the caller's copy must be untouched") + assertNull(original.metadata[DiceMetadataKeys.QUARANTINE_REASON]) + } + + @Test + fun `every proposition seen comes back somewhere`() { + val diff = differ.diff(schemaWith("Person", "Removed"), schemaWith("Person")) + + val result = policy.evaluate( + diff, + listOf(proposition("safe", "Person"), proposition("affected", "Removed")), + ) + + assertEquals(2, result.allPropositions.size) + assertEquals(2, result.total) + } + } + + @Nested + inner class LostShape { + + private fun personWith(parents: List = emptyList(), props: List = emptyList()): DataDictionary = + DataDictionary.fromDomainTypes( + "test", + listOf( + DynamicType( + name = "Person", + parents = parents.map { DynamicType(name = it) }, + ownProperties = props.map { ValuePropertyDefinition(it) }, + ), + ), + ) + + @Test + fun `losing a label quarantines`() { + val diff = differ.diff(personWith(parents = listOf("Agent")), personWith()) + + val result = policy.evaluate(diff, listOf(proposition("Alice is a person", "Person"))) + + assertEquals(1, result.quarantined.size) + val decision = result.quarantined.single() + assertEquals(PropositionStatus.STALE, decision.proposition.status) + assertTrue(decision.affectedMentionTypes.contains("Person")) + assertTrue(reasonOf(decision).contains("Agent"), "the reason should name the lost label") + } + + @Test + fun `losing a property quarantines, and the reason names it plainly`() { + val diff = differ.diff(personWith(props = listOf("age", "email")), personWith(props = listOf("age"))) + + val result = policy.evaluate(diff, listOf(proposition("Alice is a person", "Person"))) + + assertEquals(1, result.quarantined.size) + val reason = reasonOf(result.quarantined.single()) + assertTrue(reason.contains("email"), "the reason should name the lost property: $reason") + assertTrue( + !reason.contains("PropertySignature("), + "a reason is read by a person; it should not render a signature's constructor: $reason", + ) + } + + @Test + fun `an additive change never quarantines`() { + val diff = differ.diff(personWith(), personWith(parents = listOf("Agent"), props = listOf("age"))) + assertTrue(diff.modifiedEntityTypes.isNotEmpty(), "sanity: the type was seen as modified") + + val result = policy.evaluate(diff, listOf(proposition("Alice is a person", "Person"))) + + assertEquals(1, result.conforming.size) + assertEquals(0, result.quarantined.size) + } + } + + @Nested + inner class NarrowedPropertySignatures { + + /** A `Person` whose single `age` property has the given shape. */ + private fun personAged(type: String, cardinality: Cardinality): DataDictionary = + DataDictionary.fromDomainTypes( + "test", + listOf( + DynamicType( + name = "Person", + ownProperties = listOf(ValuePropertyDefinition("age", type = type, cardinality = cardinality)), + ), + ), + ) + + private fun evaluateShapeChange( + fromType: String, + fromCardinality: Cardinality, + toType: String, + toCardinality: Cardinality, + ): QuarantineResult { + val diff = differ.diff(personAged(fromType, fromCardinality), personAged(toType, toCardinality)) + assertTrue( + diff.propertySignatureChanges.isNotEmpty(), + "sanity: the differ should have reported a signature change", + ) + return policy.evaluate(diff, listOf(proposition("Alice is a person", "Person"))) + } + + @Test + fun `a changed value type quarantines`() { + val result = evaluateShapeChange("string", Cardinality.ONE, "integer", Cardinality.ONE) + + assertEquals(1, result.quarantined.size) + val reason = reasonOf(result.quarantined.single()) + assertTrue(reason.contains("age"), "the reason should name the property: $reason") + assertTrue(reason.contains("string") && reason.contains("integer"), "and both shapes: $reason") + } + + @Test + fun `a shrinking cardinality quarantines`() { + val result = evaluateShapeChange("string", Cardinality.LIST, "string", Cardinality.ONE) + + assertEquals(1, result.quarantined.size, "a list of values does not fit in a single one") + } + + @Test + fun `an optional turning required quarantines`() { + val result = evaluateShapeChange("string", Cardinality.OPTIONAL, "string", Cardinality.ONE) + + assertEquals(1, result.quarantined.size, "data extracted without the value no longer satisfies it") + } + + @Test + fun `a list collapsing to a set quarantines, since duplicates are dropped`() { + val result = evaluateShapeChange("string", Cardinality.LIST, "string", Cardinality.SET) + + assertEquals(1, result.quarantined.size) + } + + @Test + fun `a widening cardinality does not quarantine`() { + assertEquals( + 0, + evaluateShapeChange("string", Cardinality.ONE, "string", Cardinality.LIST).quarantined.size, + "one value fits in a list", + ) + assertEquals( + 0, + evaluateShapeChange("string", Cardinality.ONE, "string", Cardinality.OPTIONAL).quarantined.size, + "a required value fits where an optional one is allowed", + ) + assertEquals( + 0, + evaluateShapeChange("string", Cardinality.SET, "string", Cardinality.LIST).quarantined.size, + "a set fits in a list", + ) + } + + @Test + fun `a narrowed property on a type nobody mentions leaves the proposition alone`() { + val diff = differ.diff(personAged("string", Cardinality.LIST), personAged("string", Cardinality.ONE)) + + val result = policy.evaluate(diff, listOf(proposition("Acme is a company", "Company"))) + + assertEquals(1, result.conforming.size) + assertEquals(0, result.quarantined.size) + } + } + + @Nested + inner class Idempotency { + + @Test + fun `a second sweep leaves an already-quarantined proposition exactly as it was`() { + val diff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) + + val first = policy.evaluate(diff, listOf(proposition("entity with removed type", "RemovedType"))) + assertEquals(1, first.quarantined.size) + val stale = first.quarantined.single().proposition + + val second = policy.evaluate(diff, listOf(stale)) + + assertEquals( + 0, + second.conforming.size, + "an already-quarantined proposition is not clean and must not be reported as conforming", + ) + assertEquals(0, second.quarantined.size) + assertEquals(1, second.alreadyQuarantined.size) + assertEquals(1, second.total) + + val decision = second.alreadyQuarantined.single() + assertEquals( + stale.metadata[DiceMetadataKeys.QUARANTINE_REASON], + decision.proposition.metadata[DiceMetadataKeys.QUARANTINE_REASON], + ) + assertEquals(stale.metadata[DiceMetadataKeys.QUARANTINE_REASON], decision.originalReason) + assertEquals(PropositionStatus.STALE, decision.proposition.status) + assertTrue(second.allPropositions.contains(decision.proposition)) + } + + @Test + fun `an empty diff still reports an already-quarantined proposition as quarantined`() { + // The regression this guards: a "nothing lossy, so everything conforms" shortcut would + // skip the already-quarantined check entirely. Drift checks run on a schedule and most + // of them find nothing, so that shortcut is the common path — quarantined records would + // come back Conforming on almost every run and look healthy. + val lossyDiff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) + val stale = policy + .evaluate(lossyDiff, listOf(proposition("entity with removed type", "RemovedType"))) + .quarantined.single().proposition + + val emptyDiff = differ.diff(schemaWith("Person"), schemaWith("Person")) + assertTrue(emptyDiff.isEmpty, "sanity: the second check found nothing") + + val result = policy.evaluate(emptyDiff, listOf(stale)) + + assertEquals(0, result.conforming.size, "a quarantined proposition is never clean") + assertEquals(1, result.alreadyQuarantined.size) + assertEquals(1, result.total) + val decision = result.alreadyQuarantined.single() + assertEquals(stale.id, decision.proposition.id) + assertEquals(PropositionStatus.STALE, decision.proposition.status) + assertEquals(stale.metadata[DiceMetadataKeys.QUARANTINE_REASON], decision.originalReason) + } + + @Test + fun `a purely additive diff still reports an already-quarantined proposition as quarantined`() { + val lossyDiff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) + val stale = policy + .evaluate(lossyDiff, listOf(proposition("entity with removed type", "RemovedType"))) + .quarantined.single().proposition + val clean = proposition("Alice is a person", "Person") + + val additiveDiff = differ.diff(schemaWith("Person"), schemaWith("Person", "NewType")) + assertTrue(additiveDiff.changes.isNotEmpty(), "sanity: the diff is non-empty but additive") + + val result = policy.evaluate(additiveDiff, listOf(clean, stale)) + + assertEquals(1, result.conforming.size, "only the genuinely clean one conforms") + assertEquals(clean.id, result.conforming.single().proposition.id) + assertEquals(1, result.alreadyQuarantined.size) + assertEquals(0, result.quarantined.size) + } + + @Test + fun `a clean proposition and an already-quarantined one land in different buckets`() { + val diff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) + val stale = policy + .evaluate(diff, listOf(proposition("entity with removed type", "RemovedType"))) + .quarantined.single().proposition + val clean = proposition("Alice is a person", "Person") + + val result = policy.evaluate(diff, listOf(clean, stale)) + + assertEquals(1, result.conforming.size) + assertEquals(clean.id, result.conforming.single().proposition.id) + assertEquals(1, result.alreadyQuarantined.size) + assertEquals(stale.id, result.alreadyQuarantined.single().proposition.id) + assertEquals(0, result.quarantined.size) + assertEquals(2, result.total) + } + + @Test + fun `a proposition made stale by something other than quarantine is still evaluated`() { + // STALE alone isn't enough to skip one — decay makes propositions stale too, and those + // carry no quarantine reason. Skipping on status alone would let drifted data through. + val diff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) + val staleByDecay = proposition("aged out", "RemovedType", status = PropositionStatus.STALE) + + val result = policy.evaluate(diff, listOf(staleByDecay)) + + assertEquals(1, result.quarantined.size) + assertEquals(0, result.alreadyQuarantined.size) + } + } + + @Nested + inner class WithoutMentions { + + @Test + fun `a proposition with no mentions conforms even when types were removed`() { + val diff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) + + val result = policy.evaluate(diff, listOf(proposition("A fact with no entity mentions"))) + + assertEquals(1, result.conforming.size) + assertEquals(0, result.quarantined.size) + assertEquals(PropositionStatus.ACTIVE, result.conforming.single().proposition.status) + } + } +} diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportStoreTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportStoreTest.kt new file mode 100644 index 00000000..b37dc213 --- /dev/null +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportStoreTest.kt @@ -0,0 +1,161 @@ +/* + * 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 +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Instant + +/** + * Pins the [DriftReportStore] contract against [InMemoryDriftReportStore], which is the reference + * reading of it. A Drivine-backed store lands in a later slice and has to answer these same + * questions the same way — in Cypher rather than in memory, but with identical semantics. + */ +class DriftReportStoreTest { + + private val schemaName = "test-schema" + private val contextA = ContextId("ctx-a") + private val contextB = ContextId("ctx-b") + private val epoch = Instant.parse("2026-01-01T00:00:00Z") + + private lateinit var store: DriftReportStore + + @BeforeEach + fun setUp() { + store = InMemoryDriftReportStore() + } + + /** A report captured [minute] minutes after the epoch, optionally scoped to a context. */ + private fun save(minute: Long, contextId: ContextId? = null, schema: String = schemaName): DriftReport { + val report = DriftReport( + schemaName = schema, + versionHash = "hash-$minute", + driftedEntityTypes = setOf("Ghost$minute"), + driftedRelationshipTypes = emptySet(), + capturedAt = epoch.plusSeconds(minute * 60), + contextId = contextId, + ) + store.saveDriftReport(report) + return report + } + + @Test + fun `reads come back newest first`() { + save(1) + save(3) + save(2) + + val reports = store.driftReports(schemaName, limit = 10) + + assertEquals(listOf("hash-3", "hash-2", "hash-1"), reports.map { it.versionHash }) + } + + @Test + fun `a limit returns the newest page, not an arbitrary one`() { + (1L..5L).forEach { save(it) } + + val reports = store.driftReports(schemaName, limit = 2) + + assertEquals(2, reports.size) + assertEquals(listOf("hash-5", "hash-4"), reports.map { it.versionHash }) + } + + @Test + fun `since bounds the window from below, inclusively`() { + (1L..4L).forEach { save(it) } + + val reports = store.driftReports(schemaName, limit = 10, since = epoch.plusSeconds(2 * 60)) + + assertEquals(listOf("hash-4", "hash-3", "hash-2"), reports.map { it.versionHash }) + } + + @Test + fun `a non-positive limit is rejected rather than quietly meaning everything`() { + save(1) + + assertThrows(IllegalArgumentException::class.java) { store.driftReports(schemaName, limit = 0) } + assertThrows(IllegalArgumentException::class.java) { store.driftReports(schemaName, limit = -1) } + } + + @Test + fun `each read sees only its own scope`() { + val global = save(1) + val inA = save(2, contextA) + val inB = save(3, contextB) + + assertEquals( + listOf(inB, inA, global), + store.driftReports(schemaName, limit = 10), + "the unscoped read sees everything", + ) + assertEquals(listOf(global), store.globalDriftReports(schemaName, limit = 10)) + assertEquals(listOf(inA), store.driftReportsInContext(schemaName, contextA, limit = 10)) + assertEquals(listOf(inB), store.driftReportsInContext(schemaName, contextB, limit = 10)) + } + + @Test + fun `scoping happens before limiting, not after`() { + // The failure this guards against: a store that reads a limited page and then filters it + // would answer "no global drift" here, because the newest three reports are all + // context-scoped and the one global report never survives to the filter. + val global = save(1) + save(2, contextA) + save(3, contextA) + save(4, contextA) + + val globals = store.globalDriftReports(schemaName, limit = 3) + + assertEquals(listOf(global), globals, "a scoped read must push its scope into the query") + } + + @Test + fun `reports for another schema are never returned`() { + save(1, schema = "other-schema") + + assertTrue(store.driftReports(schemaName, limit = 10).isEmpty()) + } + + @Test + fun `re-saving the same observation updates it in place`() { + val first = save(1) + val corrected = DriftReport( + schemaName = first.schemaName, + versionHash = first.versionHash, + driftedEntityTypes = setOf("GhostA", "GhostB"), + driftedRelationshipTypes = setOf("HAUNTS"), + capturedAt = first.capturedAt, + contextId = first.contextId, + ) + + store.saveDriftReport(corrected) + + val reports = store.driftReports(schemaName, limit = 10) + assertEquals(1, reports.size, "same natural key means the same record, not a second one") + assertEquals(setOf("GhostA", "GhostB"), reports.single().driftedEntityTypes) + } + + @Test + fun `checks of the same schema at different instants are separate records`() { + save(1) + save(2) + + assertEquals(2, store.driftReports(schemaName, limit = 10).size) + } +} diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportTest.kt new file mode 100644 index 00000000..1d923bca --- /dev/null +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportTest.kt @@ -0,0 +1,113 @@ +/* + * 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 +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Instant + +class DriftReportTest { + + private val capturedAt = Instant.parse("2026-01-01T00:00:00Z") + + private fun report( + schemaName: String = "test-schema", + versionHash: String = "abc123", + driftedEntityTypes: Set = emptySet(), + driftedRelationshipTypes: Set = emptySet(), + capturedAt: Instant = this.capturedAt, + contextId: ContextId? = null, + ) = DriftReport( + schemaName = schemaName, + versionHash = versionHash, + driftedEntityTypes = driftedEntityTypes, + driftedRelationshipTypes = driftedRelationshipTypes, + capturedAt = capturedAt, + contextId = contextId, + ) + + @Test + fun `a report preserves everything it was given`() { + val entities = setOf("UnknownPerson", "UnknownOrg") + val relationships = setOf("UNDECLARED_LINKS") + val context = ContextId("ctx-1") + + val report = report( + driftedEntityTypes = entities, + driftedRelationshipTypes = relationships, + contextId = context, + ) + + assertEquals("test-schema", report.schemaName) + assertEquals("abc123", report.versionHash) + assertEquals(entities, report.driftedEntityTypes) + assertEquals(relationships, report.driftedRelationshipTypes) + assertEquals(capturedAt, report.capturedAt) + assertEquals(context, report.contextId) + } + + @Test + fun `a clean report is a perfectly ordinary report`() { + val clean = report() + + assertTrue(clean.driftedEntityTypes.isEmpty()) + assertTrue(clean.driftedRelationshipTypes.isEmpty()) + assertFalse(clean.hasDrift) + assertNull(clean.contextId, "no context means the check covered the whole graph") + } + + @Test + fun `hasDrift fires on either kind of drift`() { + assertTrue(report(driftedEntityTypes = setOf("Ghost")).hasDrift) + assertTrue(report(driftedRelationshipTypes = setOf("HAUNTS")).hasDrift) + } + + @Test + fun `the drifted type sets cannot be changed after the fact`() { + // A record of a moment has to stay that record. The caller's set is copied in, and what + // comes back out refuses mutation rather than merely discouraging it. + val mutable = mutableSetOf("Ghost") + val report = report(driftedEntityTypes = mutable) + + mutable += "AddedLater" + + assertEquals(setOf("Ghost"), report.driftedEntityTypes, "the caller's later change must not leak in") + assertThrows(UnsupportedOperationException::class.java) { + @Suppress("UNCHECKED_CAST") + (report.driftedEntityTypes as MutableSet).add("AddedLater") + } + } + + @Test + fun `two reports of the same observation are equal, and a different scope is not`() { + assertEquals(report(driftedEntityTypes = setOf("Ghost")), report(driftedEntityTypes = setOf("Ghost"))) + assertEquals( + report(driftedEntityTypes = setOf("Ghost")).hashCode(), + report(driftedEntityTypes = setOf("Ghost")).hashCode(), + ) + assertNotEquals( + report(contextId = ContextId("ctx-1")), + report(contextId = ContextId("ctx-2")), + ) + assertNotEquals(report(contextId = ContextId("ctx-1")), report()) + } +} diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt new file mode 100644 index 00000000..ba438f86 --- /dev/null +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt @@ -0,0 +1,99 @@ +/* + * 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 +import java.time.Instant + +/** + * In-memory [MetamodelVersionStore] for tests: upserts on `(schemaName, contentHash)` and keeps + * history newest first, the same way the real contract describes. + */ +internal class InMemoryMetamodelVersionStore : MetamodelVersionStore { + + private val versions = mutableListOf() + + /** How many writes reached the store, idempotent ones included — lets a test see stamp order. */ + var saveCount: Int = 0 + private set + + override fun saveVersion(version: MetamodelVersion) { + saveCount++ + val alreadyStored = versions.any { + it.schemaName == version.schemaName && it.contentHash == version.contentHash + } + if (!alreadyStored) { + versions.add(0, version) + } + } + + override fun latestVersion(schemaName: String): MetamodelVersion? = + versions.firstOrNull { it.schemaName == schemaName } + + override fun versionHistory(schemaName: String): List = + versions.filter { it.schemaName == schemaName } +} + +/** + * In-memory [DriftReportStore] for tests, and the reference reading of the bounded contract. + * + * The thing worth copying into a real backend is that each read applies its scope **before** its + * limit. Filtering a limited page afterwards would let a schema whose recent history is mostly + * context-scoped report zero global drift while plenty sat in the store, which is why the interface + * has no default bodies for the three reads. + */ +internal class InMemoryDriftReportStore : DriftReportStore { + + private val reports = mutableListOf() + + override fun saveDriftReport(report: DriftReport) { + // Upsert on the natural key: same schema, version, capture instant and context is the same + // observation, so it replaces rather than duplicating. + val existing = reports.indexOfFirst { + it.schemaName == report.schemaName && + it.versionHash == report.versionHash && + it.capturedAt == report.capturedAt && + it.contextId == report.contextId + } + if (existing >= 0) { + reports[existing] = report + } else { + reports.add(report) + } + } + + override fun driftReports(schemaName: String, limit: Int, since: Instant?): List = + page(limit, since) { it.schemaName == schemaName } + + override fun globalDriftReports(schemaName: String, limit: Int, since: Instant?): List = + page(limit, since) { it.schemaName == schemaName && it.contextId == null } + + override fun driftReportsInContext( + schemaName: String, + contextId: ContextId, + limit: Int, + since: Instant?, + ): List = page(limit, since) { it.schemaName == schemaName && it.contextId == contextId } + + private fun page(limit: Int, since: Instant?, scope: (DriftReport) -> Boolean): List { + require(limit > 0) { "limit must be positive, but was $limit" } + return reports + .filter(scope) + .filter { since == null || !it.capturedAt.isBefore(since) } + .sortedByDescending { it.capturedAt } + .take(limit) + } +} diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 64ef24c7..780f0a24 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -77,6 +77,9 @@ you need. 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. +- [metamodel-drift.md](metamodel-drift.md) — checking a live graph against a declared schema: the + runner's declare, stamp, observe, diff, report sequence, the bounds on every read of the drift + log, and quarantine, which marks affected propositions stale rather than deleting them. ## Modules @@ -90,6 +93,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), [metamodel-diff.md](metamodel-diff.md) | +| `dice-metamodel` | [metamodel-versioning.md](metamodel-versioning.md), [metamodel-diff.md](metamodel-diff.md), [metamodel-drift.md](metamodel-drift.md) | | `dice-integration-tests` | not separately documented — exercises the above end-to-end | diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 0bd50e7e..e7962725 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -16,7 +16,7 @@ DICE is a multi-module Maven build. Each module's intent, and what it's allowed | `dice-storage-autoconfigure` | Spring Boot autoconfiguration that wires `dice-storage`'s beans (repository, projectors, trust scorer) into a host application. Depends on `dice-storage`. | | `dice-ingestion` | Content-hash dedup ledger and source adapters that sit in front of `PropositionPipeline`, so the same artifact is never extracted twice concurrently. Depends on `dice`. | | `dice-report` | Rationale and structured report generation over propositions and their lineage. Depends on `dice`. | -| `dice-metamodel` | Schema versioning: content-hash stamps over the governed part of a `DataDictionary`, the declared-schema seam, and the version store contract. Pure JVM. Depends on no other DICE module. `dice-storage` implements its store contract. | +| `dice-metamodel` | Schema governance: content-hash stamps over the governed part of a `DataDictionary`, the declared-schema seam, the version and drift-report store contracts, diffing, drift checking, and non-destructive quarantine. Depends on `dice`, plus `embabel-agent-api` at provided scope. `dice-storage` implements its store contracts. | | `dice-integration-tests` | End-to-end tests exercising the real Neo4j backend and full pipeline across module boundaries. Depends on `dice`, `dice-ingestion`, `dice-report` (and transitively `dice-storage`). Not shipped. | ```mermaid @@ -26,11 +26,12 @@ flowchart TB autoconf["dice-storage-autoconfigure
(Spring Boot wiring)"] ingestion["dice-ingestion
(dedup ledger)"] report["dice-report
(rationale/reports)"] - metamodel["dice-metamodel
(schema versioning)"] + metamodel["dice-metamodel
(schema governance)"] itest["dice-integration-tests"] storage --> dice storage --> metamodel + metamodel --> dice autoconf --> storage ingestion --> dice report --> dice @@ -40,11 +41,12 @@ flowchart TB ``` `dice` never depends on any other DICE module — it's the leaf of the graph, so every other module -can be added or removed without touching core logic. `dice-metamodel` stamps a schema, and depends -only on Embabel's agent core types. One DICE module depends on it: `dice-storage`, which implements -its `MetamodelVersionStore` against Neo4j. `dice-storage-autoconfigure` is the only module that -knows about Spring Boot autoconfiguration; plain `dice-storage` stays framework-neutral so it can -be wired by hand outside Spring Boot. +can be added or removed without touching core logic. `dice-metamodel` depends on `dice`, because +quarantine marks a stranded proposition `STALE` and that touches the proposition model. Beyond +`dice` it takes no storage, no Spring, and no graph driver. One DICE module depends on it: +`dice-storage`, which implements its `MetamodelVersionStore` and `DriftReportStore` against Neo4j. +`dice-storage-autoconfigure` is the only module that knows about Spring Boot autoconfiguration; +plain `dice-storage` stays framework-neutral so it can be wired by hand outside Spring Boot. ### Subsystem design docs @@ -68,6 +70,8 @@ Each subsystem below the module level has its own design note: - [events](events.md) — `DiceEvent` model and emitters - [report](report.md) — `dice-report` rationale and structured reports - [metamodel-versioning](metamodel-versioning.md) — `MetamodelVersion` stamping, per-type governance +- [metamodel-diff](metamodel-diff.md) — the change taxonomy, declared-vs-declared and declared-vs-observed +- [metamodel-drift](metamodel-drift.md) — `DriftCheckRunner`, drift reports, non-destructive quarantine - [web-api](web-api.md) — REST surface (`DiscoveryController` and friends) ## System-level map diff --git a/docs/design/metamodel-diff.md b/docs/design/metamodel-diff.md index f028e81d..5650c5de 100644 --- a/docs/design/metamodel-diff.md +++ b/docs/design/metamodel-diff.md @@ -344,7 +344,12 @@ has no Spring wiring, so it's an ordinary constructor call until the autoconfigu Diffing is the comparison half of the middle tier described in [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. +result, and has landed on top of these contracts in [metamodel-drift.md](metamodel-drift.md): +`DriftCheckRunner` sequences declare, stamp, observe, diff, record; `DriftReportStore` keeps the +reports, so a drift seen last week is still answerable; and `DriftQuarantinePolicy` marks stranded +propositions stale rather than deleting them. + +The drift note is also where a change gets judged. A `PropertySignatureChanged` here states that +`age` went from `string` to `integer`; the quarantine policy decides whether that can strand data. +Any type change can, as can a cardinality that shrinks; a cardinality that widens holds everything +it held before. diff --git a/docs/design/metamodel-drift.md b/docs/design/metamodel-drift.md new file mode 100644 index 00000000..f34e2984 --- /dev/null +++ b/docs/design/metamodel-drift.md @@ -0,0 +1,226 @@ +# Metamodel drift: checking a live graph against what you declared + +A stamp says whether the schema moved. A diff says what moved. Neither of them looks at the graph. + +This note is about the step that does: a **drift check**, which takes the schema an application says +it governs, goes and sees what a live graph is actually holding, writes down where the two disagree, +and — only if you ask it to — pulls the propositions that disagreement stranded out of normal use. + +The shape of a run is fixed, and every step of it leaves something behind: + +```mermaid +sequenceDiagram + autonumber + participant Caller + participant Runner as DriftCheckRunner + participant Declared as DeclaredSchemaSource + participant Versions as MetamodelVersionStore + participant Observed as ObservedSchemaSource + participant Differ as DeclaredObservedDiffer + participant Reports as DriftReportStore + participant Policy as DriftQuarantinePolicy + participant Props as PropositionRepository + + Caller->>Runner: run(dryRun, contextId) + Runner->>Declared: declare() + Declared-->>Runner: DeclaredSchema (stamp + bare rel names) + Runner->>Versions: saveVersion(stamp) + Note over Runner,Versions: stamp first, so the hash the report
carries always resolves later + Runner->>Observed: observe(contextId) + Observed-->>Runner: ObservedSchema (labels + rel types, one instant) + Runner->>Differ: diffAgainstObserved(declared, observed) + Differ-->>Runner: DeclaredObservedDiff (drifted vs unobserved) + Runner->>Reports: saveDriftReport(report) + Note over Runner,Reports: written every run — a clean check
is a fact worth having + alt live run and entity-type drift + Runner->>Props: candidates (scoped or all) + Runner->>Policy: evaluate(diff, candidates) + Policy-->>Runner: STALE copies + reasons + Runner->>Props: save(each quarantined copy) + else dry run, or no entity-type drift + Note over Runner: nothing is touched + end + Runner-->>Caller: DriftCheckResult +``` + +## Three tiers, and why quarantine is last + +Schema governance in DICE escalates in three steps, and they shipped in this order deliberately. + +**Stamp and observe.** Capture the schema as a content hash and keep the history. Identity, no +opinions. See [metamodel-versioning.md](metamodel-versioning.md). + +**Detect and report.** Compare — two declarations against each other, or a declaration against a +live graph — and write down what you find. This is where the drift check sits, and it is the +default: `run()` with no arguments is a dry, whole-graph check that persists a report and changes +nothing. Reporting is safe to leave running forever, so it should be the thing you never have to +think about. + +**Quarantine.** Act on a lossy change by marking the affected propositions stale, never deleting +them. Opt-in, off by default, and one argument away: `run(dryRun = false)`. The contracts land here; +the wiring that actually schedules a live run arrives with the autoconfigure slice. + +Each tier is only safe on top of the one below, and each is worth having alone. You can stamp for a +year without detecting, and detect for a year without quarantining. What is still not on the list is +*rejecting* undeclared types at write time: extraction is LLM-driven, and a type nobody declared is +often a real finding, so throwing it away at the door is the one thing that can't be undone later. + +The prior art is worth naming, because it is the same instinct. RDF's SHACL doesn't refuse data that +violates a shape; it produces a **validation report** — a document listing each violation, what was +expected, and where. Validation is a thing you run against data that already exists, and its output +is evidence for a person, not a gate. A `DriftReport` is the same idea for a property graph: it names +the undeclared types, records the schema version they were judged against, and stays on file whether +or not anybody acts on it. + +## Stamp before you report + +A `DriftReport` records the `versionHash` of the declared schema it was measured against. That hash +is what turns an old report back into something meaningful — pull a report from six months ago, look +its hash up with `MetamodelVersionStore.findVersion`, and you get the exact shape that was expected +when the observation was taken. + +That only works if the stamp is already in the store. So `DefaultDriftCheckRunner` saves the declared +version on **every** run, before it writes the report, even when the schema hasn't moved. + +It looks wasteful and isn't. `saveVersion` upserts on `(schemaName, contentHash)`, so an unchanged +schema re-saves onto its own key and stores nothing new — the cost is one idempotent write per check. +What it buys is that a report can never name a hash nothing has recorded. Stamping afterwards, or +only when the schema changed, would leave exactly the reports that matter most — the first check +after somebody changed the schema — pointing at nothing. + +## What counts as drift + +The comparison itself is [`DeclaredObservedDiffer`](metamodel-diff.md), and it is asymmetric on +purpose: + +- **Drifted** — observed in the graph, never declared. Actionable. Data is sitting there whose + declaring integration has been removed, or was never registered, so nothing can tell it apart as + valid or explain its shape. +- **Unobserved** — declared, but with no instances right now. Purely informational. A declared type + with no data yet is an ordinary state, not a problem. + +One subtlety decides whether a drift check is usable at all: **an inherited label is 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. Comparing observed labels against declared +*type names* would call `Agent` undeclared drift on a schema nobody had touched, and on a live run +would quarantine perfectly good propositions for it. So the declared side of the comparison is the +type names plus the full label closure those types declare. + +## Reports are bounded reads, always + +`DriftReportStore` is the durable log: `saveDriftReport` plus three reads that name their scope at +the call site — `driftReports` (everything), `globalDriftReports` (unscoped whole-graph checks only), +`driftReportsInContext` (one context). Three names rather than one method with a nullable context, +because `driftReports(schema, null)` would have quietly meant "the global ones" while +`driftReports(schema)` meant "all of them": the same-looking call with a different answer. + +Every read takes a `limit`, and optionally a `since` instant. There is no "give me all of them", and +that is not a convenience decision. A drift log grows once per check per schema forever, so an +unbounded read is a query that works on a laptop and falls over after a month of hourly checks — and +the caller who wrote it had no way to know. Callers ask for a page; they never ask for a table. + +Bounding the reads is also why none of the three has a default implementation. Filtering a limited +page down to the global reports in memory would apply the limit *before* the filter, so a schema +whose recent history happened to be mostly context-scoped could report zero global drift while plenty +sat in the store — a wrong answer that looks like a right one. The scope has to go into the query, so +every backend writes all three. + +## Quarantine: what it does, and what it refuses to do + +A drift check that isn't a dry run hands the drifted types to a `DriftQuarantinePolicy`. The shipped +one, `MentionTypeDriftQuarantinePolicy`, quarantines a proposition when one of its entity mentions +names a type a **lossy** change touched: + +| Change | Lossy? | +| --- | --- | +| Type removed | Yes — nothing describes those mentions any more | +| Type lost labels or whole properties | Yes — a mention may have relied on what's gone | +| Property narrowed: type changed, value ↔ reference, or cardinality shrank | Yes — the new shape may not hold the old data | +| Type, label or property added | No | +| Cardinality widened (`ONE` → `OPTIONAL` → `SET` → `LIST`) | No — everything that fit before still fits | + +That last row is the ordering the policy uses: the four cardinalities line up by what they can hold, +so moving up is safe and moving down can strand something — a list of three doesn't fit in a single +value, and a list collapsing to a set drops duplicates. This is where the diff's deliberate refusal +to judge gets resolved. `MetamodelDiff` states that `age` went from `string` to `integer`; deciding +that this can strand data is policy, and it lives here. + +Type changes count as lossy in **both** directions. We know the declared types moved; we don't know +how a backend stored the values or whether the new type can read the old ones, and guessing wrong in +the permissive direction leaves unreadable data looking healthy. + +Two properties make this safe to run as routine maintenance: + +- **Non-destructive.** Nothing is deleted and nothing is mutated. An affected proposition comes back + as an immutable copy moved to `STALE` and annotated with a human-readable reason under + `dice.metamodel.quarantine.reason`. Leaving drifted propositions in normal retrieval would corrupt + query results; deleting them would destroy something a person might want to rescue. Quarantine + takes the middle path — out of normal use, kept, and flagged with *why*. +- **Idempotent.** A proposition an earlier sweep already quarantined comes back in its own bucket, + `AlreadyQuarantined`, untouched and with its original reason intact — never re-flagged, and never + counted as conforming, so `conforming.size` stays an honest number. To force one back through + evaluation, clear its reason metadata first. Status alone isn't enough to skip a proposition: + ordinary decay makes propositions stale too, and those carry no reason and are still candidates. + And that classification never depends on the diff in front of it. Being already quarantined is a + fact about the proposition, so an empty or purely additive diff still sorts one into + `alreadyQuarantined` — a shortcut there would report quarantined records as clean on exactly the + runs that find nothing, which is most of them. + +The policy decides; it never writes. The `STALE` copies come back to the caller, and the runner is +what persists them. That separation is exactly what lets a dry run produce the same decisions without +changing anything. + +The runner reads and writes those propositions through `PropositionStore`, the base persistence +port — not `PropositionRepository`. A drift check only ever reads by context or in bulk and saves, so +demanding vector search, graph traversal and temporal query alongside would shut a plain +store-and-retrieve backend out of drift checking over capabilities it never uses. + +## Scope is the blast radius + +Every part of a run takes the same optional `ContextId`, and it means the same thing throughout: the +observed snapshot, the candidate propositions read for quarantine, and the persisted report are all +confined to that one context. A mis-declared schema in one context can then only ever quarantine +propositions in that same context — it has no way to reach another one. Pass `null` and the check +covers the whole graph. + +## Using it + +```kotlin +val runner = DefaultDriftCheckRunner( + declaredSchemaSource = { DeclaredSchema.from(dataDictionary, governed) }, + versionStore = versionStore, + observedSchemaSource = observedSchemaSource, + differ = StructuralMetamodelDiffer(), + driftReportStore = driftReportStore, + quarantinePolicy = MentionTypeDriftQuarantinePolicy(), + propositionStore = propositionStore, +) + +// The default: dry, whole graph. Reports, changes nothing. +val result = runner.run() +if (result.hasDrift) { + log.warn("undeclared in the graph: {} {}", result.driftedEntityTypes, result.driftedRelationshipTypes) +} + +// Opt in to acting on it. +val live = runner.run(dryRun = false) +log.info("quarantined {} proposition(s)", live.quarantinedCount) + +// What did the last week look like? +driftReportStore.globalDriftReports(schemaName, limit = 50, since = Instant.now().minus(7, ChronoUnit.DAYS)) +``` + +`DriftCheckResult` reads its drifted types straight off the `report` it saved rather than keeping a +second copy, so what you log and what an operator later reads out of the store can't disagree. + +The runner is stateless and schedules nothing. Running it repeatedly, or for different schemas at +once, is fine; two concurrent checks of the *same* schema aren't corrupting — each captures its own +complete snapshot — but they are wasteful, so serialize at the scheduling layer if that matters. + +## What comes next + +Two things are contracts here with no implementation yet. `DriftReportStore` and `ObservedSchemaSource` +need a graph-backed implementation — a Drivine-backed report store, and an observer that asks Neo4j +for its distinct labels and relationship types. And none of this is wired: there is no Spring +configuration in `dice-metamodel`, so a runner is an ordinary constructor call until the autoconfigure +slice assembles one, with quarantine still off unless a host turns it on. diff --git a/docs/design/metamodel-versioning.md b/docs/design/metamodel-versioning.md index 2281e245..33ec5d2a 100644 --- a/docs/design/metamodel-versioning.md +++ b/docs/design/metamodel-versioning.md @@ -413,16 +413,23 @@ Versioning is the first of three escalating tiers, shipped in that order. **Stamp and observe** is this slice: identity and history. -**Detect and report** is underway. The comparison half has landed, in +**Detect and report** has landed on top of it, in two halves. The comparison is 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. +`ObservedSchema` snapshot of a live graph. Acting on the result is in +[metamodel-drift.md](metamodel-drift.md): `DriftCheckRunner` sequences declare, stamp, observe, +diff, record, and `DriftReportStore` keeps the reports, so a drift seen last week is still +answerable. -**Quarantine** is last: acting on a lossy change by marking affected propositions stale rather than -deleting them. +**Quarantine** is last, and opt-in: it acts on a lossy change by marking affected propositions +stale rather than deleting them. Each tier builds on the one below it, and each is useful on its own. You can stamp for a year without detecting, and detect for a year without quarantining. Rejecting undeclared types at write -time is not on the list yet: extraction is LLM-driven, a type nobody declared is often a real +time is still not on the list: extraction is LLM-driven, a type nobody declared is often a real finding, and discarding it is the one thing that can't be undone later. + +The drift tier leans on `findVersion` from here, which turns a stored report's `versionHash` back +into a schema shape. So the drift runner stamps the declared version on every run, before it writes +the report. `saveVersion` upserts on `(schemaName, contentHash)`, so an unchanged schema costs one +idempotent write, and no report can name a hash nothing recorded. From a1c737607a4a057fe9cbc39e7c66e5816b067b08 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:03:32 -0400 Subject: [PATCH 02/11] docs(metamodel): voice pass on drift docs and KDoc Comment and doc text only; no code change. --- CHANGELOG.md | 32 +-- dice-metamodel/pom.xml | 6 +- .../dice/metamodel/DriftCheckRunner.kt | 38 ++-- .../dice/metamodel/DriftQuarantinePolicy.kt | 50 +++-- .../com/embabel/dice/metamodel/DriftReport.kt | 87 ++++---- .../support/DefaultDriftCheckRunner.kt | 69 +++--- .../MentionTypeDriftQuarantinePolicy.kt | 67 +++--- .../dice/metamodel/DriftCheckRunnerTest.kt | 52 ++--- .../metamodel/DriftQuarantinePolicyTest.kt | 13 +- .../dice/metamodel/DriftReportStoreTest.kt | 12 +- .../embabel/dice/metamodel/DriftReportTest.kt | 2 +- .../dice/metamodel/InMemoryMetamodelStores.kt | 10 +- docs/design/metamodel-drift.md | 200 ++++++++---------- 13 files changed, 303 insertions(+), 335 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd08e69..b27307be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,28 +173,28 @@ and the consumer PRs that deliver it). 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. - Drift checking and quarantine contracts in `dice-metamodel`, plus the default runner. - `DriftCheckRunner` sequences one check — declare → stamp → observe → diff → report → optionally - quarantine — and is dry-run by default: `run()` persists a `DriftReport` and touches no + `DriftCheckRunner` sequences one check: declare, stamp, observe, diff, report, and optionally + quarantine. It is dry-run by default, so `run()` persists a `DriftReport` and touches no proposition. `DefaultDriftCheckRunner` stamps the declared version into the - `MetamodelVersionStore` on every run *before* writing the report, so a report's `versionHash` + `MetamodelVersionStore` on every run, before writing the report, so a report's `versionHash` always resolves through `findVersion`; the write upserts on `(schemaName, contentHash)`, so an - unchanged schema costs one idempotent write. `DriftReportStore` is the durable log, separate from - the version store because stamps and reports have different volumes and lifetimes. Every read on - it is **bounded** — `driftReports`, `globalDriftReports` and `driftReportsInContext` each take a - `limit` and an optional `since`, and none has a default body, because filtering a limited page + unchanged schema costs one idempotent write. `DriftReportStore` is the durable log, kept separate + from the version store because stamps and reports have different volumes and lifetimes. Every + read on it is bounded: `driftReports`, `globalDriftReports` and `driftReportsInContext` each take + a `limit` and an optional `since`, and none has a default body, because filtering a limited page down to one scope in memory applies the limit before the filter and can report zero drift while - plenty sits in the store. Quarantine is non-destructive and idempotent: `DriftQuarantinePolicy` + plenty sits in the store. Quarantine is non-destructive and idempotent. `DriftQuarantinePolicy` returns `QuarantineDecision`s (`Conforming` / `Quarantined` / `AlreadyQuarantined`) as immutable `STALE` copies carrying a reason under `dice.metamodel.quarantine.reason`, and the caller - persists them. The shipped `MentionTypeDriftQuarantinePolicy` fires only on lossy changes — - a removed type, a type that lost labels or properties, or a property whose signature narrowed - (type changed, value ↔ reference, or cardinality shrank along `ONE` ⊂ `OPTIONAL` ⊂ `SET` ⊂ - `LIST`) — never on additive ones. An inherited label observed in the graph is declared, not - drift, so it never quarantines. A `ContextId` scopes the observation, the candidate propositions - and the persisted report alike, so a mis-declared schema in one context cannot reach another's - data. Still no Drivine implementation and no Spring wiring; both arrive in later slices. + persists them. The shipped `MentionTypeDriftQuarantinePolicy` fires on lossy changes only: a + removed type, a type that lost labels or properties, or a property whose signature narrowed (type + changed, value ↔ reference, or cardinality shrank along `ONE` ⊂ `OPTIONAL` ⊂ `SET` ⊂ `LIST`). + An inherited label observed in the graph counts as declared, so it never quarantines. A + `ContextId` scopes the observation, the candidate propositions and the persisted report alike, so + a mis-declared schema in one context cannot reach another's data. There is still no Drivine + implementation and no Spring wiring; both arrive in later slices. **Compatibility: additive.** New types in an existing module; no existing API touched. One dependency-graph change: `dice-metamodel` now depends on `dice` (core), because quarantine works - on the proposition model — anything depending on `dice-metamodel` alone now pulls `dice` in + on the proposition model, so anything depending on `dice-metamodel` alone now pulls `dice` in transitively. `dice-metamodel` is no longer a leaf module, and `embabel-agent-rag-core` joins `embabel-agent-api` as a `provided` dependency it expects the host to supply. diff --git a/dice-metamodel/pom.xml b/dice-metamodel/pom.xml index 0141e63f..41d7c653 100644 --- a/dice-metamodel/pom.xml +++ b/dice-metamodel/pom.xml @@ -15,9 +15,9 @@ com.embabel.dice diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt index f926c314..5e120343 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt @@ -21,16 +21,15 @@ import java.util.Objects /** * What one [DriftCheckRunner.run] call found and did. * - * The drift itself lives in [report] and is read back off it, rather than being copied into - * fields here. Every run persists a report, so the two would otherwise be two copies of the same - * answer — and the failure mode of two copies is that they disagree, leaving a caller who logged - * the result and an operator who read the stored report looking at different type sets for the same - * check. There is one set of drifted types per run, and it is the one that got written down. + * The drifted types are read off [report] rather than copied into fields here. Every run persists a + * report, so a copy would be a second version of the same answer, and two versions can disagree: a + * caller who logged the result and an operator who read the stored report would then see different + * type sets for the same check. * * @property dryRun Whether this was a preview. On a dry run the report is still persisted; no * proposition is touched. - * @property report The [DriftReport] this run saved. Every run saves one, including a clean one — - * "checked and found nothing" has to be as retrievable as "checked and found drift". + * @property report The [DriftReport] this run saved. Every run saves one, including a check that + * found nothing. * @property quarantinedCount How many propositions this run newly quarantined. Always 0 on a dry * run, and 0 whenever there was no entity-type drift. */ @@ -69,25 +68,24 @@ class DriftCheckResult( /** * Runs a drift check end to end: takes the declared schema, stamps it, snapshots what a live graph - * actually holds, compares the two, writes the result down, and — only if you ask — quarantines the - * propositions the drift stranded. + * holds, compares the two, writes the result down, and quarantines the propositions the drift + * stranded when asked to. * - * Dry-run by default, and that default is the whole design stance. Observing and reporting is - * useful on its own and can't hurt anything; changing proposition state is a separate decision - * somebody has to make on purpose. Nothing here schedules itself either — a consuming application - * decides when [run] is called, the same way it does for the collector. + * Dry-run by default. Observing and reporting changes nothing; moving propositions to `STALE` is a + * separate decision a caller opts into. Nothing here schedules itself, so a consuming application + * decides when [run] is called, as it does for the collector. * * The shorter [run] forms are real overloads with bodies rather than Kotlin default arguments, * because Java can't see a default argument: `runner.run()` has to exist as a method for a Java * caller to write it. Implementations override the two-argument form and get the other two free. - * Those two shorter forms are also the whole Java surface — `ContextId` is a Kotlin value class, so - * the two-argument form compiles to a mangled JVM name Java can't call. + * Those two shorter forms are also the whole Java surface, since `ContextId` is a Kotlin value class + * and the two-argument form compiles to a mangled JVM name Java can't call. */ interface DriftCheckRunner { /** - * Declare, stamp, observe, diff, report — and, when [dryRun] is `false` and drift touched any - * entity type, quarantine. + * Declare, stamp, observe, diff, report, and quarantine when [dryRun] is `false` and drift + * touched an entity type. * * @param dryRun When `true`, the check runs and its [DriftReport] is persisted, but no * proposition is touched. When `false`, propositions whose mentions reference a drifted @@ -96,15 +94,13 @@ interface DriftCheckRunner { * @param contextId `null` means the check covers the whole graph. Non-null scopes everything * the check touches to that one context: the observed snapshot, the candidate propositions * read for quarantine, and the persisted [DriftReport]. A mis-declared schema in one context - * can then only ever quarantine propositions in that same context — it has no way to reach - * another one. + * can only quarantine propositions in that same context. * @return What was found, and what was quarantined if this was a live run. */ fun run(dryRun: Boolean, contextId: ContextId?): DriftCheckResult /** - * Run a dry check over the whole graph — the safe default. Nothing is quarantined; the report - * is still persisted. + * Run a dry check over the whole graph. Nothing is quarantined; the report is still persisted. * * @return What was found. */ diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt index 06749f4f..7b70beb3 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt @@ -20,10 +20,10 @@ import com.embabel.dice.proposition.Proposition /** * What a policy decided about one [Proposition]. * - * Three outcomes, not two, and the third is the one that's easy to miss: a proposition an earlier - * sweep already quarantined. It isn't clean, so calling it conforming would overstate how healthy - * the set is, and it isn't newly quarantined either, since this sweep deliberately left it alone to - * preserve its original reason. It gets its own variant so `conforming.size` means what it says. + * Three outcomes. The third covers a proposition an earlier sweep already quarantined: counting it + * as conforming would overstate how clean the set is, and it isn't newly quarantined either, + * because this sweep leaves it alone to preserve its original reason. Its own variant keeps + * `conforming.size` accurate. * * Sealed, so a `when` over the outcomes is exhaustive and the compiler speaks up if a fourth * ever lands. @@ -38,16 +38,16 @@ sealed interface QuarantineDecision { data class Conforming(val proposition: Proposition) : QuarantineDecision /** - * An earlier sweep already quarantined this one — it is `STALE` and carries a - * `DiceMetadataKeys.QUARANTINE_REASON` — so this sweep left it exactly as it found it. Nothing - * needs persisting for these. + * An earlier sweep already quarantined this one: it is `STALE` and carries a + * `DiceMetadataKeys.QUARANTINE_REASON`, so this sweep left it as it found it. Nothing needs + * persisting for these. * * To force one back through evaluation, clear its `QUARANTINE_REASON` metadata and pass it in * again. * * @property proposition The proposition, unchanged. * @property originalReason The reason the earlier sweep recorded, when it is still readable as - * text. `null` when the metadata value is present but isn't a string — the proposition still + * text. `null` when the metadata value is present but isn't a string. The proposition still * counts as already quarantined; only the explanation is unrecoverable. */ data class AlreadyQuarantined( @@ -60,7 +60,7 @@ sealed interface QuarantineDecision { * * [proposition] is an immutable copy already moved to `STALE` and annotated with the reason * under `DiceMetadataKeys.QUARANTINE_REASON`. The original is never mutated, and nothing is - * written anywhere — persisting the copy is the caller's job. + * written anywhere; persisting the copy is the caller's job. * * @property proposition The flagged, `STALE` copy. * @property reason A human-readable explanation of why it was quarantined. @@ -76,7 +76,7 @@ sealed interface QuarantineDecision { /** * What a whole sweep decided, with one decision per proposition it was given. * - * @property conforming Propositions the change doesn't touch — genuinely clean. + * @property conforming Propositions the change doesn't touch. * @property quarantined Propositions this sweep flagged, as `STALE` copies waiting to be persisted. * @property alreadyQuarantined Propositions an earlier sweep had already flagged, left untouched by * this one. Empty unless the input contained some. @@ -100,16 +100,14 @@ data class QuarantineResult @JvmOverloads constructor( /** * Decides which propositions a schema change has stranded, and flags them. * - * Quarantining is **non-destructive**. An affected proposition comes back as an immutable copy - * moved to [com.embabel.dice.proposition.PropositionStatus.STALE] with a metadata note explaining - * why; the original is untouched and nothing is written to any store. Persisting the copies is - * deliberately the caller's job — the policy is a decision, not an effect, which is what lets a - * drift check preview one without changing anything. + * Quarantining is non-destructive. An affected proposition comes back as an immutable copy moved + * to [com.embabel.dice.proposition.PropositionStatus.STALE] with a metadata note explaining why; + * the original is untouched and nothing is written to any store. Persisting the copies is the + * caller's job, which is what lets a drift check preview a sweep without changing anything. * - * It takes a [MetamodelDiff] — a comparison of two *declared* versions — because "what did the - * schema stop recognising?" is the question that matters, and a diff answers it precisely. A drift - * check, which compares a declaration against a live graph instead, synthesizes the equivalent diff - * rather than re-deciding quarantine on its own terms. + * It takes a [MetamodelDiff], a comparison of two declared versions, which is what says exactly + * which types the schema stopped recognising. A drift check compares a declaration against a live + * graph, and synthesizes the equivalent diff rather than deciding quarantine on its own terms. * * ```kotlin * val diff = differ.diff(previousVersion, currentVersion) @@ -122,19 +120,19 @@ interface DriftQuarantinePolicy { /** * Evaluate every proposition against [diff]. * - * Implementations must be **idempotent**: a proposition already quarantined by a prior sweep - * (`STALE` with a `QUARANTINE_REASON`) must not have its original reason overwritten. Those come - * back unchanged as [QuarantineDecision.AlreadyQuarantined] — not as conforming, which would - * misreport them as clean. Clear the metadata key to force one back through evaluation. + * Implementations must be idempotent: a proposition already quarantined by a prior sweep + * (`STALE` with a `QUARANTINE_REASON`) must keep its original reason. Those come back unchanged + * as [QuarantineDecision.AlreadyQuarantined], not as conforming, which would report them as + * clean. Clear the metadata key to force one back through evaluation. * * That classification does not depend on [diff]. Being already quarantined is a fact about the * proposition, so an empty or purely additive diff must still sort those into * [QuarantineResult.alreadyQuarantined] rather than short-circuiting the whole input into - * [QuarantineResult.conforming]. Drift checks run on a schedule and most of them find nothing, - * so a shortcut there would make quarantined records look healthy nearly every run. + * [QuarantineResult.conforming]. Drift checks run on a schedule and most runs find nothing, so + * short-circuiting would report quarantined records as conforming on those runs. * * @param diff What changed between the old and new schema. - * @param propositions The propositions to evaluate. Any [Iterable] will do — a list, a + * @param propositions The propositions to evaluate. Any [Iterable] will do: a list, a * repository page, a lazy sequence. * @return One decision per input proposition. */ diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt index 39354f94..e0da011d 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt @@ -23,14 +23,13 @@ import java.util.Objects * One drift check, written down: what a live graph held that nobody had declared, at one moment, * measured against one declared schema version. * - * A report is a fact about a point in time, not a running total. Keeping every one of them is what - * lets you answer "when did this start?" and "is it getting worse?" later — a single mutable - * "current drift" field could only ever answer "right now", and would quietly lose the answer the - * moment somebody fixed the schema. + * A report records one point in time. Keeping every one is what lets you answer "when did this + * start?" and "is it getting worse?" later; a single mutable "current drift" field could only answer + * "right now", and would lose the history the moment somebody fixed the schema. * * [versionHash] is the tie back to the schema this was judged against. A `DriftCheckRunner` stamps * the declared version into a [MetamodelVersionStore] before writing the report, so the hash always - * resolves through [MetamodelVersionStore.findVersion] — you can pull a year-old report and still + * resolves through [MetamodelVersionStore.findVersion]. Pull a year-old report and you can still * recover the exact shape that was expected when it was taken. * * @property schemaName The declared schema's name at check time. Together with [versionHash] this @@ -40,8 +39,8 @@ import java.util.Objects * @property driftedEntityTypes Entity type names (labels) the graph held but the schema never * declared, sorted the way the diff produced them. * @property driftedRelationshipTypes Relationship type names observed with no matching declaration. - * @property capturedAt When the observation was taken — the [ObservedSchema.capturedAt] of the - * snapshot it was computed from, not the time of the write. + * @property capturedAt When the observation was taken: the [ObservedSchema.capturedAt] of the + * snapshot it was computed from, rather than the time of the write. * @property contextId The context the check was scoped to, or `null` when it covered the whole * graph. */ @@ -54,11 +53,11 @@ class DriftReport @JvmOverloads constructor( val contextId: ContextId? = null, ) { - // Both sets are copied into genuinely immutable ones that keep the order they arrived in, and - // this is a plain class rather than a `data class`, for the same reason as everything else in - // this module: a record of a moment must not be reshapeable afterwards, Kotlin's read-only - // `Set` is a compile-time promise a Java caller sees straight through, and a generated `copy()` - // would hand its arguments to the fields and skip the copying entirely. + // Both sets are copied into JVM-immutable ones that keep the order they arrived in, and this + // is a plain class rather than a `data class`, for the same reason as the rest of this module: + // a record of a moment must not be reshapeable afterwards, Kotlin's read-only `Set` is a + // compile-time promise a Java caller sees straight through, and a generated `copy()` would hand + // its arguments to the fields and skip the copying. val driftedEntityTypes: Set = immutableCopy(driftedEntityTypes) @@ -99,44 +98,39 @@ class DriftReport @JvmOverloads constructor( } /** - * Durable log of drift checks. Append-only in spirit: nothing is ever deleted, so the history of - * what a graph looked like against what was declared accumulates and stays answerable. + * Durable log of drift checks. Nothing is ever deleted, so the history of what a graph held against + * what was declared accumulates and stays answerable. * * Kept apart from [MetamodelVersionStore] on purpose. Stamps and reports have different lifetimes - * and very different volumes — a schema gets stamped when somebody changes it, while a scheduled - * drift check writes a report every run whether it found anything or not. Folding both into one - * interface would force any backend to serve both access patterns, and would make "I only want to - * record versions" impossible to express. + * and volumes: a schema gets stamped when somebody changes it, while a scheduled drift check writes + * a report every run. Folding both into one interface would force any backend to serve both access + * patterns, and would make "I only want to record versions" impossible to express. * - * **What "save" means here.** [saveDriftReport] is an upsert on the natural key `(schemaName, - * versionHash, capturedAt, contextId)`. Two observations differing in any of those are separate - * records; re-saving one with the same key overwrites its drifted type sets rather than adding a - * duplicate. + * [saveDriftReport] is an upsert on the natural key `(schemaName, versionHash, capturedAt, + * contextId)`. Two observations differing in any of those are separate records; re-saving one with + * the same key overwrites its drifted type sets rather than adding a duplicate. * * ## Every read is bounded * - * There is no "give me all of them". A drift log grows once per check per schema forever, so an - * unbounded read is a query that works on a laptop and falls over in production after a month of - * hourly checks — and the caller who wrote it had no way to know. Every read here therefore takes a - * `limit`, and optionally a `since` instant to bound the window as well. Callers ask for a page; - * they never ask for a table. + * There is no unbounded read. A drift log grows once per check per schema forever, so an unbounded + * query works on a laptop and falls over in production after a month of hourly checks. Every read + * here takes a `limit`, and optionally a `since` instant to bound the window. * - * ## Three reads, no defaults + * ## Three reads, each explicit about scope * - * The scope is explicit at the call site: [driftReports] is everything, [globalDriftReports] is - * only unscoped whole-graph checks, and [driftReportsInContext] is one context's. Three names - * rather than one method with a nullable context, because `driftReports(schema, null)` would have - * quietly meant "the global ones" while `driftReports(schema)` meant "all of them" — the same - * looking call with a different answer and nothing but the doc to tell them apart. Splitting them - * also gives Java callers a way to reach the global reports at all: `ContextId` is a Kotlin value - * class, so [driftReportsInContext] compiles to a mangled JVM name Java can't call, while the other - * two stay callable. + * The scope is named at the call site: [driftReports] is everything, [globalDriftReports] is only + * unscoped whole-graph checks, and [driftReportsInContext] is one context's. Three names rather than + * one method with a nullable context, because `driftReports(schema, null)` would have meant "the + * global ones" while `driftReports(schema)` meant "all of them", two near-identical calls with + * different answers. Splitting them also gives Java callers a way to reach the global reports: + * `ContextId` is a Kotlin value class, so [driftReportsInContext] compiles to a mangled JVM name + * Java can't call, while the other two stay callable. * - * None of the three has a default body, and that is the direct consequence of bounding the reads. - * Filtering `driftReports(schema, limit)` down to the global ones in memory would return at most - * `limit` rows *before* the filter, so a schema whose recent history is mostly context-scoped could - * report zero global drift while plenty sat in the store — a wrong answer that looks like a right - * one. The scope has to be pushed down into the query, so each implementation writes all three. + * None of the three has a default body, which follows from bounding the reads. Filtering + * `driftReports(schema, limit)` down to the global ones in memory would return at most `limit` rows + * *before* the filter, so a schema whose recent history is mostly context-scoped could report zero + * global drift while plenty sat in the store. The scope has to be pushed down into the query, so + * each implementation writes all three. */ interface DriftReportStore { @@ -148,8 +142,8 @@ interface DriftReportStore { fun saveDriftReport(report: DriftReport) /** - * Reports for a schema at any scope — global checks and every context's, mixed together — - * newest first by [DriftReport.capturedAt]. + * Reports for a schema at any scope: global checks and every context's, mixed together, newest + * first by [DriftReport.capturedAt]. * * @param schemaName The schema to look up. * @param limit The most reports to return. Must be positive. @@ -162,8 +156,7 @@ interface DriftReportStore { /** * The same read with no time window. * - * A real overload with a body rather than a Kotlin default argument, so Java callers can write - * it too — Java cannot see a Kotlin default argument. + * A real overload with a body rather than a Kotlin default argument, which Java cannot see. * * @param schemaName The schema to look up. * @param limit The most reports to return. Must be positive. @@ -173,8 +166,8 @@ interface DriftReportStore { driftReports(schemaName, limit, null) /** - * Reports from unscoped, whole-graph checks only — those whose [DriftReport.contextId] is - * `null`. A check scoped to a context is excluded no matter which context it was. + * Reports from unscoped, whole-graph checks only: those whose [DriftReport.contextId] is + * `null`. A check scoped to a context is excluded whichever context it was. * * @param schemaName The schema to look up. * @param limit The most reports to return. Must be positive. diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt index 3c672183..bf4ff3a0 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt @@ -32,25 +32,23 @@ import com.embabel.dice.proposition.PropositionStore import org.slf4j.LoggerFactory /** - * The shipped [DriftCheckRunner]. It sequences the collaborators and decides nothing itself: the - * comparison belongs to the differ, the quarantine call to the policy, and this class only makes - * sure they happen in an order that leaves a coherent record behind. + * The shipped [DriftCheckRunner]. It sequences the collaborators and makes no decisions of its own: + * the comparison belongs to the differ and the quarantine call to the policy, and this class puts + * them in an order that leaves a coherent record behind. * * Stateless, so calling it repeatedly or for different schemas at once is fine. Two concurrent - * checks of the *same* schema aren't corrupting — each captures its own complete snapshot — but - * they are wasteful; serialize at the scheduling layer if that matters. + * checks of the same schema don't corrupt anything, since each captures its own complete snapshot, + * but they duplicate work; serialize at the scheduling layer if that matters. * - * ## Stamp before you report + * ## The version is stamped before the report is written * - * The declared version is saved to [versionStore] on **every** run, before the report is written. - * That looks redundant, because a version that hasn't changed re-saves onto its own key and stores - * nothing new. The point is the guarantee it buys: a [DriftReport] records the - * [MetamodelVersion.contentHash] it was judged against, and that hash is only useful if it resolves - * back to a real stamp through [MetamodelVersionStore.findVersion]. Stamping last, or only when the - * schema moved, leaves the first check after a schema change pointing at a hash nothing has ever - * recorded — the reports that matter most are exactly the ones that would dangle. Since - * `saveVersion` upserts on `(schemaName, contentHash)`, paying for it every run costs one idempotent - * write and removes the failure mode entirely. + * The declared version is saved to [versionStore] on every run, before the report is written, even + * when it hasn't changed and re-saves onto its own key. A [DriftReport] records the + * [MetamodelVersion.contentHash] it was judged against, and that hash is only useful when it + * resolves back to a real stamp through [MetamodelVersionStore.findVersion]. Stamping last, or only + * when the schema moved, would leave the first check after a schema change pointing at a hash + * nothing has recorded. `saveVersion` upserts on `(schemaName, contentHash)`, so doing it every run + * costs one idempotent write. * * @param declaredSchemaSource Supplies the schema as declared. Read first, so everything downstream * is judged against one declaration. @@ -58,14 +56,14 @@ import org.slf4j.LoggerFactory * resolve. * @param observedSchemaSource Snapshots what the live graph actually contains. * @param differ Compares the declaration against the observation. - * @param driftReportStore Durable log the report is written to — every run, drift or not. + * @param driftReportStore Durable log the report is written to, on every run. * @param quarantinePolicy Decides which stranded propositions to quarantine. Consulted only on a - * live run that found entity-type drift; this runner never reimplements the decision. + * live run that found entity-type drift. * @param propositionStore Where candidate propositions are read from and quarantined copies are - * saved back to. The base persistence port, not `PropositionRepository`: a drift check only ever - * reads by context or in bulk and saves, so asking for vector search, graph traversal and - * temporal query alongside would shut a plain store-and-retrieve backend out of drift checking - * for capabilities it is never asked to use. + * saved back to. The base persistence port rather than `PropositionRepository`: a drift check only + * reads by context or in bulk and saves, so requiring vector search, graph traversal and temporal + * query alongside would shut a plain store-and-retrieve backend out of drift checking for + * capabilities it never uses. */ class DefaultDriftCheckRunner( private val declaredSchemaSource: DeclaredSchemaSource, @@ -82,8 +80,8 @@ class DefaultDriftCheckRunner( override fun run(dryRun: Boolean, contextId: ContextId?): DriftCheckResult { val declared = declaredSchemaSource.declare() - // Stamp first — see the class doc. This has to happen before the report is written, so the - // hash the report carries is already resolvable by the time anyone can read it. + // Before the report is written, so the hash the report carries is already resolvable by the + // time anyone can read it. See the class doc. versionStore.saveVersion(declared.version) val observed = observedSchemaSource.observe(contextId) @@ -94,12 +92,12 @@ class DefaultDriftCheckRunner( versionHash = declared.version.contentHash, driftedEntityTypes = diff.driftedEntityTypes, driftedRelationshipTypes = diff.driftedRelationshipTypes, - // The instant the graph was looked at, not the instant this write happens: the report - // is a statement about the snapshot. + // The instant the graph was looked at, rather than the instant of this write: the + // report is a statement about the snapshot. capturedAt = observed.capturedAt, contextId = contextId, ) - // Written unconditionally. A zero-drift check is a fact worth having on record, not a no-op. + // Written on every run, including checks that found nothing. driftReportStore.saveDriftReport(report) val quarantinedCount = if (!dryRun && diff.driftedEntityTypes.isNotEmpty()) { @@ -125,16 +123,15 @@ class DefaultDriftCheckRunner( /** * Hand the drifted types to [quarantinePolicy] and persist whatever it flags. * - * The policy takes a [MetamodelDiff] — two *declared* versions compared — but what a drift check - * has is a declaration compared against a live observation, which is a different question. They - * agree on the part the policy cares about, though: a mention whose type the declared schema - * doesn't recognise is stranded either way, whether the type was dropped from a newer - * declaration or was never declared at all. So we synthesize the equivalent diff — nothing but a - * [MetamodelChange.EntityTypeRemoved] per drifted type — and let the real policy decide, rather - * than re-deciding quarantine here with a second, subtly different rule. + * The policy takes a [MetamodelDiff], which compares two declared versions, while a drift check + * has a declaration compared against a live observation. On the part the policy uses they agree: + * a mention whose type the declared schema doesn't recognise is stranded whether the type was + * dropped from a newer declaration or never declared at all. So this synthesizes the equivalent + * diff, one [MetamodelChange.EntityTypeRemoved] per drifted type, and lets the policy decide, + * instead of applying a second quarantine rule here. * * Both ends of the synthesized diff point at the same declared version. There was no old-to-new - * transition; the two sides are there only so the policy's reason string has something to name. + * transition; the two sides are there so the policy's reason string has something to name. */ private fun quarantineDriftedEntityTypes( declaredVersion: MetamodelVersion, @@ -146,8 +143,8 @@ class DefaultDriftCheckRunner( toVersion = declaredVersion, changes = driftedEntityTypes.sorted().map { MetamodelChange.EntityTypeRemoved(it) }, ) - // Scoping is the whole blast radius: a proposition in another context is never a candidate, - // so nothing this run does can reach it, whatever its mentions say. + // A proposition in another context is never a candidate, whatever its mentions say, so a + // scoped run cannot reach outside its context. val propositions = if (contextId != null) { propositionStore.findByContextId(contextId) } else { diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt index 8c774f36..7ab40cef 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt @@ -32,33 +32,31 @@ import org.slf4j.LoggerFactory * names a type the schema change made **lossy**. Lossy means the change can strand data that was * already extracted: * - * - the type was **removed** — nothing describes those mentions any more; - * - the type kept its name but **lost** labels or whole properties; - * - a property kept its name but its shape **narrowed** — its value type or reference target - * changed, it flipped between holding a value and pointing at another type, or its cardinality - * shrank (a list collapsing to a single value, an optional becoming required). + * - the type was **removed**, so nothing describes those mentions any more; + * - the type kept its name and **lost** labels or whole properties; + * - a property kept its name and its shape **narrowed**: its value type or reference target changed, + * it flipped between holding a value and pointing at another type, or its cardinality shrank (a + * list collapsing to a single value, an optional becoming required). * - * Everything else is additive and never triggers quarantine: new types, new labels, new properties, - * and cardinality moving the other way (a single value becoming a list holds everything it held - * before). This is where the diff's deliberate refusal to judge gets resolved — - * [MetamodelChange.PropertySignatureChanged] states that `age` went from `string` to `integer`, and - * this policy is what decides that stranding is possible and the affected propositions should be - * pulled out of normal use until a person looks. + * Additive changes never trigger quarantine: new types, new labels, new properties, and cardinality + * moving the other way, since a single value becoming a list still holds everything it held before. + * The diff itself makes no judgement. [MetamodelChange.PropertySignatureChanged] states that `age` + * went from `string` to `integer`, and this policy decides that stranding is possible and pulls the + * affected propositions out of normal use until a person looks. * - * The conservative call on a type change is to treat *any* move as lossy, in either direction. We - * know the declared type names moved; we don't know how the backend stored the values or whether - * the new type can read the old ones, and guessing wrong in the permissive direction leaves - * unreadable data looking healthy. Swap in a different policy if your storage makes some widenings - * provably safe. + * A type change counts as lossy in either direction. We know the declared type names moved; we don't + * know how the backend stored the values or whether the new type can read the old ones, and guessing + * wrong in the permissive direction leaves unreadable data looking healthy. Swap in a different + * policy if your storage makes some widenings provably safe. * - * Quarantining transitions the proposition to [PropositionStatus.STALE] and annotates it under - * [DiceMetadataKeys.QUARANTINE_REASON]. Both produce an immutable copy — the original is never + * Quarantining moves the proposition to [PropositionStatus.STALE] and annotates it under + * [DiceMetadataKeys.QUARANTINE_REASON]. Both produce an immutable copy; the original is never * mutated, and persisting the copies is the caller's job. * * A proposition an earlier sweep already quarantined comes back as - * [QuarantineDecision.AlreadyQuarantined], untouched: never re-flagged, original reason preserved, - * and never counted as conforming. That holds whatever the diff in front of us looks like, an empty - * one included — being already quarantined is a fact about the proposition, not about this check. + * [QuarantineDecision.AlreadyQuarantined], untouched, with its original reason preserved and outside + * the conforming bucket. That holds for any diff, an empty one included, because being already + * quarantined is a fact about the proposition. */ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { @@ -67,8 +65,8 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { override fun evaluate(diff: MetamodelDiff, propositions: Iterable): QuarantineResult { val removedTypes = diff.removedEntityTypes - // Types whose name survived but which lost labels or whole properties. Also lossy: a - // mention may have relied on a label or property that is simply gone. Keyed by type name. + // Types whose name survived and which lost labels or whole properties. Also lossy, because + // a mention may have relied on a label or property that is now gone. Keyed by type name. val lossyModified = diff.modifiedEntityTypes .filter { it.removedLabels.isNotEmpty() || it.removedProperties.isNotEmpty() } .associateBy { it.typeName } @@ -80,18 +78,15 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { .groupBy { it.typeName } // There is deliberately no "nothing lossy, so everything conforms" shortcut here. Whether a - // proposition is already quarantined has nothing to do with the diff in front of us — it is - // a fact about the proposition — and a shortcut that skipped the check would report an - // earlier sweep's quarantined records as Conforming the moment a later check happened to - // find nothing new. Since drift checks run on a schedule and most of them find nothing, that - // is the common case, not the rare one: quarantined data would look healthy almost always. - // One code path, always classified. + // proposition is already quarantined is a fact about the proposition and doesn't depend on + // the diff, so a shortcut would report an earlier sweep's quarantined records as Conforming + // on any check that found nothing new. Drift checks run on a schedule and most runs find + // nothing, so that would be the common case. Every proposition goes down one code path. val conforming = mutableListOf() val quarantined = mutableListOf() // Propositions left alone because a previous sweep already quarantined them. Their own - // bucket rather than folded into conforming: they aren't clean, and a caller reading - // conforming.size as a health number would be wrong about them. + // bucket rather than folded into conforming, so conforming.size counts only clean ones. val alreadyQuarantined = mutableListOf() for (proposition in propositions) { @@ -158,8 +153,8 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { /** * Whether a proposition is one an earlier sweep already handled: `STALE` *and* carrying a - * quarantine reason. Both halves matter — a proposition made stale by ordinary decay carries no - * reason and is still a live candidate here. + * quarantine reason. Both halves matter, because a proposition made stale by ordinary decay + * carries no reason and is still a live candidate here. */ private fun isAlreadyQuarantined(proposition: Proposition): Boolean = proposition.status == PropositionStatus.STALE && @@ -179,7 +174,7 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { change.kindChanged || breadth(change.after.cardinality) < breadth(change.before.cardinality) - /** How much a cardinality can hold, as a rank — bigger holds everything smaller can. */ + /** How much a cardinality can hold, as a rank: bigger holds everything smaller can. */ private fun breadth(cardinality: Cardinality): Int = when (cardinality) { Cardinality.ONE -> 0 Cardinality.OPTIONAL -> 1 @@ -206,8 +201,8 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { losses += "label(s) [${change.removedLabels.sorted().joinToString(", ")}]" } if (change.removedProperties.isNotEmpty()) { - // Names, not full signatures: a reason is read by a person deciding whether to - // rescue the proposition, and a rendered PropertySignature buries the name in + // Names rather than full signatures: a person reads this reason to decide whether + // to rescue the proposition, and a rendered PropertySignature buries the name in // constructor noise. losses += "propert${if (change.removedPropertyNames.size == 1) "y" else "ies"} " + "[${change.removedPropertyNames.sorted().joinToString(", ")}]" diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt index 5193124b..b2a5b8c8 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt @@ -36,9 +36,9 @@ import org.junit.jupiter.api.Test import java.time.Instant /** - * [DefaultDriftCheckRunner] against fake sources and stores, but the *real* - * [StructuralMetamodelDiffer] and [MentionTypeDriftQuarantinePolicy] — both covered on their own - * elsewhere — so these tests exercise the actual delegation rather than a stand-in for it. + * [DefaultDriftCheckRunner] against fake sources and stores, with the real + * [StructuralMetamodelDiffer] and [MentionTypeDriftQuarantinePolicy], each covered on its own + * elsewhere. These tests exercise the actual delegation rather than a stand-in for it. */ class DriftCheckRunnerTest { @@ -75,8 +75,8 @@ class DriftCheckRunnerTest { relationshipNames = declaredRelationshipTypeNames.map { "Person-[$it]->Company" }, ) - // Typed as the base persistence port, not PropositionRepository: whatever a test passes in, - // the runner only ever gets store-and-retrieve out of it. + // Typed as the base persistence port rather than PropositionRepository, so whatever a test + // passes in, the runner only gets store-and-retrieve out of it. private fun buildRunner(store: PropositionStore = propositionStore): DriftCheckRunner { val declaredSchema = DeclaredSchema( version = declaredVersion(), @@ -85,9 +85,9 @@ class DriftCheckRunnerTest { return DefaultDriftCheckRunner( declaredSchemaSource = DeclaredSchemaSource { declaredSchema }, versionStore = versionStore, - // Each snapshot gets its own instant, a minute apart, the way real observations do. It - // matters: a report's natural key includes the capture instant, so two checks sharing - // one really are the same observation and collapse to a single record. + // Each snapshot gets its own instant, a minute apart, the way real observations do. A + // report's natural key includes the capture instant, so two checks sharing one count as + // the same observation and collapse to a single record. observedSchemaSource = object : ObservedSchemaSource { private var observations = 0L @@ -134,9 +134,9 @@ class DriftCheckRunnerTest { @Test fun `the stamp is written before the report, not after`() { - // The ordering is the whole point of stamping every run: a report written first would, for - // the length of that window and forever if the second write failed, name a version hash - // nothing had ever recorded. + // The ordering is why the stamp is written every run. A report written first would name a + // version hash nothing had recorded, for the length of that window, and permanently if the + // second write failed. val runner = buildRunner() runner.run(dryRun = true) @@ -262,7 +262,7 @@ class DriftCheckRunnerTest { @Test fun `a plain store-and-retrieve backend can drive a live run`() { // The runner asks for the base persistence port, so a backend with no vector search, graph - // traversal or temporal query to offer is still allowed to check for drift. + // traversal or temporal query can still check for drift. observedEntityTypes = setOf("Person", "Company", "GhostType") val affected = propositionStore.save(proposition("a ghost was mentioned", "GhostType")) val bareStore: PropositionStore = RecordingPropositionStore(propositionStore) @@ -276,7 +276,7 @@ class DriftCheckRunnerTest { @Test fun `a live run with only relationship drift never quarantines`() { - // Nothing a mention's type could ever match, so a live run must still touch nothing. + // Nothing a mention's type can match, so a live run must touch nothing. observedRelationshipTypeNames = setOf("WORKS_AT", "UNDECLARED_LINK") propositionStore.save(proposition("Alice works at Acme", "Person", "Company")) val runner = buildRunner() @@ -294,8 +294,8 @@ class DriftCheckRunnerTest { @Test fun `an inherited label observed in the graph is not drift and never quarantines`() { // Declaring Person with parent Agent puts both labels on every Person node, so the graph - // reports Agent too. Comparing observed labels against type names alone would call Agent - // undeclared and quarantine perfectly good propositions on a schema nobody had touched. + // reports Agent too. Comparing observed labels against type names alone would report Agent + // as undeclared and quarantine sound propositions on a schema nobody had touched. declaredEntityTypes = listOf("Person") declaredEntityTypeLabels = mapOf("Person" to setOf("Person", "Agent")) observedEntityTypes = setOf("Person", "Agent", "GhostType") @@ -338,8 +338,8 @@ class DriftCheckRunnerTest { val result = runner.run(dryRun = true) - // The declared names must reach the differ exactly as supplied — no splitting, trimming or - // delimiter parsing — so only the genuinely undeclared name shows up as drift. + // The declared names must reach the differ exactly as supplied, with no splitting, trimming + // or delimiter parsing, so only the undeclared name shows up as drift. assertEquals(setOf("UNDECLARED|ALSO\tDELIMITED"), result.driftedRelationshipTypes) assertEquals(setOf("UNDECLARED|ALSO\tDELIMITED"), savedReports().single().driftedRelationshipTypes) } @@ -378,7 +378,7 @@ class DriftCheckRunnerTest { @Test fun `a scoped live run leaves another context's propositions completely alone`() { - // Both propositions mention the drifted type and a global run would quarantine both. + // Both propositions mention the drifted type, and a global run would quarantine both. // Scoping to one context must reach exactly one of them. observedEntityTypes = setOf("Person", "Company", "GhostType") val inScope = propositionStore.save(proposition("a ghost in context A", "GhostType")) @@ -422,13 +422,13 @@ class DriftCheckRunnerTest { } /** - * Records which candidate-read the runner actually called, so a test can assert the scoped or - * global read path directly rather than inferring it from a side effect. Everything else is - * delegated unchanged. + * Records which candidate-read the runner called, so a test can assert the scoped or global read + * path directly rather than inferring it from a side effect. Everything else is delegated + * unchanged. * - * Deliberately a bare [PropositionStore] and not a `PropositionRepository`: passing one of these - * to the runner is what proves a plain store-and-retrieve backend, with no vector search or - * graph traversal to offer, can still drive a live drift check. + * A bare [PropositionStore] rather than a `PropositionRepository`: passing one of these to the + * runner is what shows a plain store-and-retrieve backend, with no vector search or graph + * traversal, can drive a live drift check. */ private class RecordingPropositionStore( private val delegate: PropositionStore, @@ -453,8 +453,8 @@ class DriftCheckRunnerTest { /** * An [InMemoryDriftReportStore] that also notes, at the moment each report is written, whether - * the version store could already resolve the hash that report carries. That is the stamp-first - * guarantee, observed at the only instant where it can actually be violated. + * the version store can already resolve the hash that report carries. That is the only instant + * at which the stamp-before-report ordering can be observed to hold or fail. */ private class OrderRecordingDriftReportStore( private val versionStore: MetamodelVersionStore, diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt index 3d0d97c1..1b80bd04 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt @@ -351,10 +351,10 @@ class DriftQuarantinePolicyTest { @Test fun `an empty diff still reports an already-quarantined proposition as quarantined`() { - // The regression this guards: a "nothing lossy, so everything conforms" shortcut would - // skip the already-quarantined check entirely. Drift checks run on a schedule and most - // of them find nothing, so that shortcut is the common path — quarantined records would - // come back Conforming on almost every run and look healthy. + // Guards against a "nothing lossy, so everything conforms" shortcut, which would skip + // the already-quarantined check. Drift checks run on a schedule and most runs find + // nothing, so that path is the common one, and quarantined records would come back + // Conforming on almost every run. val lossyDiff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) val stale = policy .evaluate(lossyDiff, listOf(proposition("entity with removed type", "RemovedType"))) @@ -413,8 +413,9 @@ class DriftQuarantinePolicyTest { @Test fun `a proposition made stale by something other than quarantine is still evaluated`() { - // STALE alone isn't enough to skip one — decay makes propositions stale too, and those - // carry no quarantine reason. Skipping on status alone would let drifted data through. + // STALE alone isn't enough to skip one, because decay also makes propositions stale and + // those carry no quarantine reason. Skipping on status alone would let drifted data + // through. val diff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) val staleByDecay = proposition("aged out", "RemovedType", status = PropositionStatus.STALE) diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportStoreTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportStoreTest.kt index b37dc213..b8e5e186 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportStoreTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportStoreTest.kt @@ -24,9 +24,9 @@ import org.junit.jupiter.api.Test import java.time.Instant /** - * Pins the [DriftReportStore] contract against [InMemoryDriftReportStore], which is the reference - * reading of it. A Drivine-backed store lands in a later slice and has to answer these same - * questions the same way — in Cypher rather than in memory, but with identical semantics. + * Pins the [DriftReportStore] contract against [InMemoryDriftReportStore], the reference reading of + * it. A Drivine-backed store lands in a later slice and has to answer these same questions the same + * way, in Cypher rather than in memory. */ class DriftReportStoreTest { @@ -112,9 +112,9 @@ class DriftReportStoreTest { @Test fun `scoping happens before limiting, not after`() { - // The failure this guards against: a store that reads a limited page and then filters it - // would answer "no global drift" here, because the newest three reports are all - // context-scoped and the one global report never survives to the filter. + // Guards against a store that reads a limited page and then filters it. Such a store would + // answer "no global drift" here, because the newest three reports are all context-scoped and + // the one global report never survives to the filter. val global = save(1) save(2, contextA) save(3, contextA) diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportTest.kt index 1d923bca..151162af 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportTest.kt @@ -84,7 +84,7 @@ class DriftReportTest { @Test fun `the drifted type sets cannot be changed after the fact`() { // A record of a moment has to stay that record. The caller's set is copied in, and what - // comes back out refuses mutation rather than merely discouraging it. + // comes back out throws on mutation rather than relying on Kotlin's read-only view. val mutable = mutableSetOf("Ghost") val report = report(driftedEntityTypes = mutable) diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt index ba438f86..62a59637 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt @@ -26,7 +26,7 @@ internal class InMemoryMetamodelVersionStore : MetamodelVersionStore { private val versions = mutableListOf() - /** How many writes reached the store, idempotent ones included — lets a test see stamp order. */ + /** How many writes reached the store, idempotent ones included, so a test can see stamp order. */ var saveCount: Int = 0 private set @@ -50,8 +50,8 @@ internal class InMemoryMetamodelVersionStore : MetamodelVersionStore { /** * In-memory [DriftReportStore] for tests, and the reference reading of the bounded contract. * - * The thing worth copying into a real backend is that each read applies its scope **before** its - * limit. Filtering a limited page afterwards would let a schema whose recent history is mostly + * Each read applies its scope before its limit, which is the part a real backend has to copy. + * Filtering a limited page afterwards would let a schema whose recent history is mostly * context-scoped report zero global drift while plenty sat in the store, which is why the interface * has no default bodies for the three reads. */ @@ -60,8 +60,8 @@ internal class InMemoryDriftReportStore : DriftReportStore { private val reports = mutableListOf() override fun saveDriftReport(report: DriftReport) { - // Upsert on the natural key: same schema, version, capture instant and context is the same - // observation, so it replaces rather than duplicating. + // Upsert on the natural key: the same schema, version, capture instant and context is the + // same observation, so it replaces rather than duplicating. val existing = reports.indexOfFirst { it.schemaName == report.schemaName && it.versionHash == report.versionHash && diff --git a/docs/design/metamodel-drift.md b/docs/design/metamodel-drift.md index f34e2984..19216cc9 100644 --- a/docs/design/metamodel-drift.md +++ b/docs/design/metamodel-drift.md @@ -1,12 +1,12 @@ -# Metamodel drift: checking a live graph against what you declared +# Metamodel drift: checking a live graph against a declared schema -A stamp says whether the schema moved. A diff says what moved. Neither of them looks at the graph. +A drift check takes the schema an application declares, observes what a live graph is holding, +records where the two disagree, and, when asked to, pulls the propositions that disagreement +stranded out of normal use. -This note is about the step that does: a **drift check**, which takes the schema an application says -it governs, goes and sees what a live graph is actually holding, writes down where the two disagree, -and — only if you ask it to — pulls the propositions that disagreement stranded out of normal use. +Stamping and diffing both work on declarations. A drift check is the step that reads the graph. -The shape of a run is fixed, and every step of it leaves something behind: +The shape of a run is fixed, and every step leaves something behind: ```mermaid sequenceDiagram @@ -25,13 +25,13 @@ sequenceDiagram Runner->>Declared: declare() Declared-->>Runner: DeclaredSchema (stamp + bare rel names) Runner->>Versions: saveVersion(stamp) - Note over Runner,Versions: stamp first, so the hash the report
carries always resolves later + Note over Runner,Versions: saved before the report, so the
report's hash always resolves later Runner->>Observed: observe(contextId) Observed-->>Runner: ObservedSchema (labels + rel types, one instant) Runner->>Differ: diffAgainstObserved(declared, observed) Differ-->>Runner: DeclaredObservedDiff (drifted vs unobserved) Runner->>Reports: saveDriftReport(report) - Note over Runner,Reports: written every run — a clean check
is a fact worth having + Note over Runner,Reports: written on every run, including
checks that find nothing alt live run and entity-type drift Runner->>Props: candidates (scoped or all) Runner->>Policy: evaluate(diff, candidates) @@ -43,50 +43,44 @@ sequenceDiagram Runner-->>Caller: DriftCheckResult ``` -## Three tiers, and why quarantine is last +## The three tiers -Schema governance in DICE escalates in three steps, and they shipped in this order deliberately. +Schema governance in DICE has three tiers, shipped in that order. -**Stamp and observe.** Capture the schema as a content hash and keep the history. Identity, no -opinions. See [metamodel-versioning.md](metamodel-versioning.md). +**Stamp and observe.** Capture the schema as a content hash and keep the history. See +[metamodel-versioning.md](metamodel-versioning.md). -**Detect and report.** Compare — two declarations against each other, or a declaration against a -live graph — and write down what you find. This is where the drift check sits, and it is the -default: `run()` with no arguments is a dry, whole-graph check that persists a report and changes -nothing. Reporting is safe to leave running forever, so it should be the thing you never have to -think about. +**Detect and report.** Compare two declarations against each other, or a declaration against a live +graph, and record what you find. The drift check sits here, and it is the default: `run()` with no +arguments is a dry, whole-graph check that persists a report and changes nothing. -**Quarantine.** Act on a lossy change by marking the affected propositions stale, never deleting -them. Opt-in, off by default, and one argument away: `run(dryRun = false)`. The contracts land here; -the wiring that actually schedules a live run arrives with the autoconfigure slice. +**Quarantine.** Act on a lossy change by marking the affected propositions stale rather than +deleting them. Off by default; `run(dryRun = false)` turns it on. This slice adds the contracts, and +the wiring that schedules a live run arrives with the autoconfigure slice. -Each tier is only safe on top of the one below, and each is worth having alone. You can stamp for a -year without detecting, and detect for a year without quarantining. What is still not on the list is -*rejecting* undeclared types at write time: extraction is LLM-driven, and a type nobody declared is -often a real finding, so throwing it away at the door is the one thing that can't be undone later. +Each tier builds on the one below it, and each is useful on its own. You can stamp for a year +without detecting, and detect for a year without quarantining. Rejecting undeclared types at write +time is still not on the list: extraction is LLM-driven, a type nobody declared is often a real +finding, and discarding it is the one thing that can't be undone later. -The prior art is worth naming, because it is the same instinct. RDF's SHACL doesn't refuse data that -violates a shape; it produces a **validation report** — a document listing each violation, what was -expected, and where. Validation is a thing you run against data that already exists, and its output -is evidence for a person, not a gate. A `DriftReport` is the same idea for a property graph: it names -the undeclared types, records the schema version they were judged against, and stays on file whether -or not anybody acts on it. +The prior art is RDF's SHACL, which validates data that already exists and reports each violation, +what was expected, and where, without blocking the write. A `DriftReport` is the same idea for a +property graph: it names the undeclared types, records the schema version they were judged against, +and stays on file whether or not anybody acts on it. -## Stamp before you report +## Stamping the version before writing the report -A `DriftReport` records the `versionHash` of the declared schema it was measured against. That hash -is what turns an old report back into something meaningful — pull a report from six months ago, look -its hash up with `MetamodelVersionStore.findVersion`, and you get the exact shape that was expected -when the observation was taken. +A `DriftReport` records the `versionHash` of the declared schema it was measured against. Look that +hash up with `MetamodelVersionStore.findVersion` and a report from six months ago resolves back to +the exact schema shape expected when the observation was taken. -That only works if the stamp is already in the store. So `DefaultDriftCheckRunner` saves the declared -version on **every** run, before it writes the report, even when the schema hasn't moved. +That works only while the stamp is in the store, so `DefaultDriftCheckRunner` saves the declared +version on every run, before it writes the report, including runs where the schema hasn't moved. -It looks wasteful and isn't. `saveVersion` upserts on `(schemaName, contentHash)`, so an unchanged -schema re-saves onto its own key and stores nothing new — the cost is one idempotent write per check. -What it buys is that a report can never name a hash nothing has recorded. Stamping afterwards, or -only when the schema changed, would leave exactly the reports that matter most — the first check -after somebody changed the schema — pointing at nothing. +The cost is one idempotent write per check: `saveVersion` upserts on `(schemaName, contentHash)`, so +an unchanged schema re-saves onto its own key and stores nothing new. Stamping afterwards, or only +when the schema changed, would leave the first check after a schema change pointing at a hash +nothing recorded. ## What counts as drift @@ -94,42 +88,39 @@ The comparison itself is [`DeclaredObservedDiffer`](metamodel-diff.md), and it i purpose: - **Drifted** — observed in the graph, never declared. Actionable. Data is sitting there whose - declaring integration has been removed, or was never registered, so nothing can tell it apart as - valid or explain its shape. -- **Unobserved** — declared, but with no instances right now. Purely informational. A declared type - with no data yet is an ordinary state, not a problem. + declaring integration was removed or never registered, so nothing describes its shape or vouches + for it. +- **Unobserved** — declared, with no instances at the moment. Informational: a declared type with no + data yet is an ordinary state. -One subtlety decides whether a drift check is usable at all: **an inherited label is 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. Comparing observed labels against declared -*type names* would call `Agent` undeclared drift on a schema nobody had touched, and on a live run -would quarantine perfectly good propositions for it. So the declared side of the comparison is the -type names plus the full label closure those types declare. +Inherited labels count 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. +Comparing observed labels against declared *type names* would report `Agent` as drift on a schema +nobody had touched, and a live run would quarantine sound propositions for it. So the declared side +of the comparison is the type names plus the full label closure those types declare. -## Reports are bounded reads, always +## Every read of the drift log is bounded -`DriftReportStore` is the durable log: `saveDriftReport` plus three reads that name their scope at +`DriftReportStore` is the durable log: `saveDriftReport`, plus three reads that name their scope at the call site — `driftReports` (everything), `globalDriftReports` (unscoped whole-graph checks only), `driftReportsInContext` (one context). Three names rather than one method with a nullable context, -because `driftReports(schema, null)` would have quietly meant "the global ones" while -`driftReports(schema)` meant "all of them": the same-looking call with a different answer. +because `driftReports(schema, null)` would have meant "the global ones" while `driftReports(schema)` +meant "all of them": two near-identical calls with different answers. -Every read takes a `limit`, and optionally a `since` instant. There is no "give me all of them", and -that is not a convenience decision. A drift log grows once per check per schema forever, so an -unbounded read is a query that works on a laptop and falls over after a month of hourly checks — and -the caller who wrote it had no way to know. Callers ask for a page; they never ask for a table. +Every read takes a `limit`, and optionally a `since` instant. There is no unbounded read. A drift log +grows once per check per schema forever, so an unbounded query works on a laptop and falls over after +a month of hourly checks. -Bounding the reads is also why none of the three has a default implementation. Filtering a limited -page down to the global reports in memory would apply the limit *before* the filter, so a schema -whose recent history happened to be mostly context-scoped could report zero global drift while plenty -sat in the store — a wrong answer that looks like a right one. The scope has to go into the query, so -every backend writes all three. +That is also why none of the three has a default implementation. Filtering a limited page down to the +global reports in memory would apply the limit *before* the filter, so a schema whose recent history +was mostly context-scoped could report zero global drift while plenty sat in the store. The scope has +to go into the query, so every backend writes all three. -## Quarantine: what it does, and what it refuses to do +## Quarantine -A drift check that isn't a dry run hands the drifted types to a `DriftQuarantinePolicy`. The shipped -one, `MentionTypeDriftQuarantinePolicy`, quarantines a proposition when one of its entity mentions -names a type a **lossy** change touched: +A live run hands the drifted types to a `DriftQuarantinePolicy`. The shipped one, +`MentionTypeDriftQuarantinePolicy`, quarantines a proposition when one of its entity mentions names a +type a **lossy** change touched: | Change | Lossy? | | --- | --- | @@ -140,10 +131,10 @@ names a type a **lossy** change touched: | Cardinality widened (`ONE` → `OPTIONAL` → `SET` → `LIST`) | No — everything that fit before still fits | That last row is the ordering the policy uses: the four cardinalities line up by what they can hold, -so moving up is safe and moving down can strand something — a list of three doesn't fit in a single -value, and a list collapsing to a set drops duplicates. This is where the diff's deliberate refusal -to judge gets resolved. `MetamodelDiff` states that `age` went from `string` to `integer`; deciding -that this can strand data is policy, and it lives here. +so moving up is safe and moving down can strand something. A list of three doesn't fit in a single +value, and a list collapsing to a set drops duplicates. The diff itself makes no judgement. +`MetamodelDiff` states that `age` went from `string` to `integer`; deciding whether that can strand +data is this policy's job. Type changes count as lossy in **both** directions. We know the declared types moved; we don't know how a backend stored the values or whether the new type can read the old ones, and guessing wrong in @@ -152,36 +143,32 @@ the permissive direction leaves unreadable data looking healthy. Two properties make this safe to run as routine maintenance: - **Non-destructive.** Nothing is deleted and nothing is mutated. An affected proposition comes back - as an immutable copy moved to `STALE` and annotated with a human-readable reason under + as an immutable copy moved to `STALE`, annotated with a human-readable reason under `dice.metamodel.quarantine.reason`. Leaving drifted propositions in normal retrieval would corrupt - query results; deleting them would destroy something a person might want to rescue. Quarantine - takes the middle path — out of normal use, kept, and flagged with *why*. -- **Idempotent.** A proposition an earlier sweep already quarantined comes back in its own bucket, - `AlreadyQuarantined`, untouched and with its original reason intact — never re-flagged, and never - counted as conforming, so `conforming.size` stays an honest number. To force one back through - evaluation, clear its reason metadata first. Status alone isn't enough to skip a proposition: - ordinary decay makes propositions stale too, and those carry no reason and are still candidates. - And that classification never depends on the diff in front of it. Being already quarantined is a - fact about the proposition, so an empty or purely additive diff still sorts one into - `alreadyQuarantined` — a shortcut there would report quarantined records as clean on exactly the - runs that find nothing, which is most of them. - -The policy decides; it never writes. The `STALE` copies come back to the caller, and the runner is -what persists them. That separation is exactly what lets a dry run produce the same decisions without -changing anything. - -The runner reads and writes those propositions through `PropositionStore`, the base persistence -port — not `PropositionRepository`. A drift check only ever reads by context or in bulk and saves, so -demanding vector search, graph traversal and temporal query alongside would shut a plain + query results; deleting them would destroy something a person might want to rescue. +- **Idempotent.** A proposition an earlier sweep quarantined comes back in its own + `alreadyQuarantined` bucket, untouched and with its original reason intact, so it is neither + re-flagged nor counted in `conforming`. To force one back through evaluation, clear its reason + metadata first. Status alone is not enough to skip a proposition: ordinary decay also makes + propositions stale, and those carry no reason and are still candidates. The classification doesn't + depend on the diff in front of it: being already quarantined is a fact about the proposition, so + an empty or purely additive diff still sorts one into `alreadyQuarantined`. Skipping the check on + an empty diff would report quarantined records as conforming on every run that finds no drift. + +The policy decides and doesn't write. The `STALE` copies come back to the caller, and the runner +persists them, which is how a dry run produces the same decisions while changing nothing. + +The runner reads and writes those propositions through `PropositionStore`, the base persistence port, +rather than `PropositionRepository`. A drift check only reads by context or in bulk and saves; +requiring vector search, graph traversal and temporal query alongside would shut a plain store-and-retrieve backend out of drift checking over capabilities it never uses. -## Scope is the blast radius +## Scope Every part of a run takes the same optional `ContextId`, and it means the same thing throughout: the observed snapshot, the candidate propositions read for quarantine, and the persisted report are all -confined to that one context. A mis-declared schema in one context can then only ever quarantine -propositions in that same context — it has no way to reach another one. Pass `null` and the check -covers the whole graph. +confined to that one context. A mis-declared schema in one context can only quarantine propositions +in that same context. Pass `null` and the check covers the whole graph. ## Using it @@ -210,17 +197,18 @@ log.info("quarantined {} proposition(s)", live.quarantinedCount) driftReportStore.globalDriftReports(schemaName, limit = 50, since = Instant.now().minus(7, ChronoUnit.DAYS)) ``` -`DriftCheckResult` reads its drifted types straight off the `report` it saved rather than keeping a -second copy, so what you log and what an operator later reads out of the store can't disagree. +`DriftCheckResult` reads its drifted types off the `report` it saved rather than keeping a second +copy, so what you log and what an operator later reads out of the store can't disagree. The runner is stateless and schedules nothing. Running it repeatedly, or for different schemas at -once, is fine; two concurrent checks of the *same* schema aren't corrupting — each captures its own -complete snapshot — but they are wasteful, so serialize at the scheduling layer if that matters. +once, is fine. Two concurrent checks of the same schema don't corrupt anything, since each captures +its own complete snapshot, but they duplicate work; serialize at the scheduling layer if that +matters. ## What comes next -Two things are contracts here with no implementation yet. `DriftReportStore` and `ObservedSchemaSource` -need a graph-backed implementation — a Drivine-backed report store, and an observer that asks Neo4j -for its distinct labels and relationship types. And none of this is wired: there is no Spring -configuration in `dice-metamodel`, so a runner is an ordinary constructor call until the autoconfigure -slice assembles one, with quarantine still off unless a host turns it on. +`DriftReportStore` and `ObservedSchemaSource` are contracts here with no implementation yet. They +need a Drivine-backed report store, and an observer that asks Neo4j for its distinct labels and +relationship types. There is no Spring configuration in `dice-metamodel` either, so a runner is an +ordinary constructor call until the autoconfigure slice assembles one, with quarantine off unless a +host turns it on. From bbce781c346ad111888a80a2e6ca915d4dfc4439 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:33:21 -0400 Subject: [PATCH 03/11] Judge declared renames and safe widenings in drift quarantine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paired rename quarantines nothing by itself: the diff folds the rename's own propagation, so the policy only ever sees deltas that survived, and a renamed property's delta is judged by the same narrowing rules as any signature change. Candidate matching reads the declaration's accumulated former names, and a removed type's former names from the older stamp, so data labeled under any former name is caught however far the rename and the loss are separated — while a former name redeclared as a live type is excluded, and a deliberately retired one surfaces as undeclared drift instead. Four type widenings stop reading as lossy: int to long, float to double, and their boxed pair, pinned against the dictionary's real rendered names. --- CHANGELOG.md | 50 ++ .../embabel/dice/metamodel/MetamodelDiff.kt | 16 + .../MentionTypeDriftQuarantinePolicy.kt | 276 +++++++- .../metamodel/DriftQuarantinePolicyTest.kt | 643 ++++++++++++++++++ .../dice/metamodel/MetamodelDifferTest.kt | 83 ++- docs/design/metamodel-drift.md | 181 ++++- 6 files changed, 1204 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b27307be..fbf2a3f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -198,3 +198,53 @@ and the consumer PRs that deliver it). on the proposition model, so anything depending on `dice-metamodel` alone now pulls `dice` in transitively. `dice-metamodel` is no longer a leaf module, and `embabel-agent-rag-core` joins `embabel-agent-api` as a `provided` dependency it expects the host to supply. + +- Rename-aware quarantine and a type-widening allow-list in + `MentionTypeDriftQuarantinePolicy`, **EXPERIMENTAL** (behavior may change before 1.0). + A declared rename no longer quarantines anything on its own: `EntityTypeRenamed` and + `PropertyRenamed` are non-lossy per se, and `EntityTypeAliasesChanged` never quarantines. + A paired property rename whose two signatures also differ is judged on that delta by exactly + the `PropertySignatureChanged` narrowing rules, so `age: integer LIST` renamed to + `years: integer ONE` still quarantines. Candidate matching goes through former names: a + mention type is checked against its own name plus every current type name that used to go by + it, read off the newer version's whole `MetamodelVersion.entityTypeAliases` map rather than the + renames this particular diff carries — so a diff that only drops a property from a type renamed + two stamps ago still reaches data written under the old name. Reading the declaration is safe + because the reuse-collision refusal already guarantees an alias never names a live declared type. + A **removed** type resolves from the older version instead, since the newer one has no entry for + it: deleting `C` outright quarantines data labelled with every name `C` had gone by, excluding any + the newer version declares as a live type of its own (reusing a retired name is legal once its + claimant is gone, and data under it is judged as that live type's). Retiring a former name stops + it matching — retirement says the schema no longer claims the name, and data still carrying it is + reported by the observed-side comparison as ordinary undeclared drift. + Former names accumulate, so a type renamed `A` → `B` → `C` declares `{A, B}` and a lossy change + on `C`, or `C` being removed, quarantines data labelled `A`, `B` or `C` alike — however many + renames deep the old label sits, and whether or not the rename rides in the same diff as the + loss. A former name claimed by two live types is checked against both. The quarantine reason + names which schema type an old name resolved to. + Alongside it, four value type promotions are now treated as non-lossy: `int` → `long`, + `float` → `double`, `Integer` → `Long`, `Float` → `Double`. Iceberg defines two of these as safe + column promotions, `int` → `long` and `float` → `double`; the boxed pair is the same two as a JVM + dictionary spells them, and Iceberg's reason carries over: every value of the older type has an + exact representation in the newer one. Primitive-to-primitive and boxed-to-boxed only, so `int` → + `Long` (boxing) and `Integer` → `long` (nullability) stay lossy, as does every reversal and + every pair off the list. The list is scoped to `Kind.VALUE`, since one entity type is never a + promotion of another. It is published as + `MentionTypeDriftQuarantinePolicy.SAFE_TYPE_WIDENINGS` and pinned by a test that renders a + real eight-field declaration through `PropertySignature.of`, so a rendering change in the + upstream dictionary fails the build rather than quietly emptying the list. + `MetamodelDiff` gains `renamedEntityTypes`, `entityTypeAliasChanges` and `renamedProperties`, + the same convenience accessors the older change kinds already had. + **Compatibility: behavioral.** Which change you see depends on whether the schema declares + aliases. For a schema declaring none, matching is exactly what it was and the only move is + permissive: a property whose value type went along one of the four allow-listed pairs no longer + quarantines. Propositions an earlier sweep quarantined for one of those widenings stay + quarantined — the already-quarantined check runs before any matching and nothing clears the + reason key on its own, so no stored proposition changes state without an operator. To release + them, clear `dice.metamodel.quarantine.reason` on those propositions and re-run the check; under + the new rule they come back conforming. For a schema that declares aliases, matching now reaches + data under a type's former names, so a proposition mentioning an old type name can newly + quarantine when the renamed type lost something — which is the point: the old name is what the + graph stores. Aliases arrive in this same Unreleased block, so no consumer can be in that state + on a published build. The API is additive: three read-only accessors and one public constant, + and no existing signature changed. 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 e979bc9f..ec9932b9 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 @@ -500,6 +500,22 @@ class MetamodelDiff( val modifiedEntityTypes: List get() = changes.filterIsInstance() + /** + * Every [MetamodelChange.EntityTypeRenamed] entry, whole rather than as names: a rename has two + * of them, and a caller resolving old data against the new schema needs the pairing. + * [touchedEntityTypes] is where both names arrive flattened. + */ + val renamedEntityTypes: List + get() = changes.filterIsInstance() + + /** Every [MetamodelChange.EntityTypeAliasesChanged] entry. */ + val entityTypeAliasChanges: List + get() = changes.filterIsInstance() + + /** Every [MetamodelChange.PropertyRenamed] entry. */ + val renamedProperties: List + get() = changes.filterIsInstance() + /** Every [MetamodelChange.PropertySignatureChanged] entry. */ val propertySignatureChanges: List get() = changes.filterIsInstance() diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt index 7ab40cef..d3121029 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt @@ -34,9 +34,11 @@ import org.slf4j.LoggerFactory * * - the type was **removed**, so nothing describes those mentions any more; * - the type kept its name and **lost** labels or whole properties; - * - a property kept its name and its shape **narrowed**: its value type or reference target changed, - * it flipped between holding a value and pointing at another type, or its cardinality shrank (a - * list collapsing to a single value, an optional becoming required). + * - a property's shape **narrowed**: its value type or reference target changed to something outside + * [SAFE_TYPE_WIDENINGS], it flipped between holding a value and pointing at another type, or its + * cardinality shrank (a list collapsing to a single value, an optional becoming required). This + * covers a property that kept its name and one that was renamed under a declared alias; both carry + * a before and an after signature, and both are judged by the same rule. * * Additive changes never trigger quarantine: new types, new labels, new properties, and cardinality * moving the other way, since a single value becoming a list still holds everything it held before. @@ -44,10 +46,44 @@ import org.slf4j.LoggerFactory * went from `string` to `integer`, and this policy decides that stranding is possible and pulls the * affected propositions out of normal use until a person looks. * - * A type change counts as lossy in either direction. We know the declared type names moved; we don't - * know how the backend stored the values or whether the new type can read the old ones, and guessing - * wrong in the permissive direction leaves unreadable data looking healthy. Swap in a different - * policy if your storage makes some widenings provably safe. + * ## Declared renames + * + * A rename is a declared fact about one type or property, so on its own it strands nothing. + * [MetamodelChange.EntityTypeRenamed] and [MetamodelChange.PropertyRenamed] are non-lossy per se, + * and [MetamodelChange.EntityTypeAliasesChanged] never quarantines at all: it says the declaration's + * list of former names moved, and no label, property or relationship went with it. + * + * Whatever else moved on a renamed type is reported under the type's **new** name, and the data in + * the graph still carries the old one. So a mention type is matched against its own name plus every + * current type name that used to go by it, read off the newer version's whole declared alias map. + * Those accumulate, so a type renamed `A` → `B` → `C` declares `{A, B}` and a lossy change on `C` + * quarantines propositions mentioning `A`, `B` or `C` alike. + * + * Matching reads the declaration rather than this diff's rename entries, so it holds when the rename + * and the loss land in different releases: a diff that only drops a property from a type renamed two + * stamps ago still reaches data written under the old name. + * + * A **removed** type is resolved from the older version instead, since it has no entry on the newer + * side at all. Removing `C` after it had gone by `{A, B}` strands data under all three names, and + * the removal is matched under all three. The exception is a former name the newer version declares + * as a live type of its own: reusing a retired name is legal once its claimant is gone, and data + * under it is judged as that live type's. + * + * A former name the declaration deliberately **retires** stops matching. Retiring is a statement + * that the schema no longer claims the name, and from then on data still carrying it is reported by + * the observed-side comparison as ordinary undeclared drift. + * + * A rename's own propagation — the type's own label swapping, a referrer's signature pointing at the + * new name, a child's inherited label — folds into [MetamodelChange.EntityTypeRenamed] in the diff + * and never reaches this policy as loss. + * + * ## Value types + * + * A changed value type counts as lossy in both directions except for the four promotions in + * [SAFE_TYPE_WIDENINGS]. Outside those, we know the declared type names moved and we don't know how + * the backend stored the values or whether the new type can read the old ones, and guessing wrong in + * the permissive direction leaves unreadable data looking healthy. A changed reference target is + * always lossy: it names a different entity type, which is not a promotion of anything. * * Quarantining moves the proposition to [PropositionStatus.STALE] and annotates it under * [DiceMetadataKeys.QUARANTINE_REASON]. Both produce an immutable copy; the original is never @@ -57,6 +93,8 @@ import org.slf4j.LoggerFactory * [QuarantineDecision.AlreadyQuarantined], untouched, with its original reason preserved and outside * the conforming bucket. That holds for any diff, an empty one included, because being already * quarantined is a fact about the proposition. + * + * Rename awareness and the widening allow-list are experimental: behavior may change before 1.0. */ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { @@ -74,9 +112,22 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { // Types carrying a property that kept its name but narrowed. Grouped by type name, since // one type can have several such properties and the reason should name them all. val narrowedProperties = diff.propertySignatureChanges - .filter { isNarrowing(it) } + .filter { isNarrowing(it.before, it.after) } + .groupBy { it.typeName } + + // Types carrying a property that was renamed and narrowed in the same step. The rename is + // harmless; the shape move underneath it is judged by the same rule as any other. + val narrowedRenames = diff.renamedProperties + .filter { isNarrowing(it.before, it.after) } .groupBy { it.typeName } + // Every name a surviving type has gone by, pointing at the name its changes are reported + // under, and every name a removed type has gone by, pointing at the removal. Two maps, read + // off opposite sides of the diff, because a removed type is absent from the newer side. + // Extracted once per sweep rather than per proposition. + val currentNamesByFormerName = formerTypeNames(diff) + val formerNamesOfRemovedTypes = formerNamesOfRemovedTypes(diff) + // There is deliberately no "nothing lossy, so everything conforms" shortcut here. Whether a // proposition is already quarantined is a fact about the proposition and doesn't depend on // the diff, so a shortcut would report an earlier sweep's quarantined records as Conforming @@ -85,6 +136,10 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { val conforming = mutableListOf() val quarantined = mutableListOf() + // Former names that actually matched something, for the summary line. The map above holds + // every former name the declaration knows; this holds the ones a proposition was labelled + // with, which is what an operator reading the log is trying to find out. + val formerNamesMatched = sortedSetOf() // Propositions left alone because a previous sweep already quarantined them. Their own // bucket rather than folded into conforming, so conforming.size counts only clean ones. val alreadyQuarantined = mutableListOf() @@ -103,10 +158,52 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { } val mentionTypes = proposition.mentions.mapTo(mutableSetOf()) { it.type } - val removedHit = mentionTypes intersect removedTypes - val lossyHit = mentionTypes intersect lossyModified.keys - val narrowedHit = mentionTypes intersect narrowedProperties.keys - val affectedTypes = removedHit + lossyHit + narrowedHit + + val affectedTypes = sortedSetOf() + val removedHit = sortedSetOf() + val lossyHit = LinkedHashSet() + val narrowedHit = LinkedHashSet() + val renamedHit = LinkedHashSet() + // Mention types that only matched because a type used to go by them, and what that type + // is called now. The reason says so; without it an operator reads a complaint about + // 'Human' on a proposition whose mentions all say 'Person'. + val matchedByFormerName = sortedMapOf>() + + for (mentionType in mentionTypes) { + var affected = false + + // Note that this mention type isn't the schema's own name for the type it hit. + fun recordFormerName(schemaName: String) { + if (schemaName != mentionType) { + matchedByFormerName.getOrPut(mentionType) { sortedSetOf() } += schemaName + formerNamesMatched += mentionType + } + } + + // Removals resolve through the OLDER version's aliases. A removed type takes its + // former names down with it, and the newer version has no record they were ever + // this type's, so the surviving-type map above can't see them. + val removals = sortedSetOf() + if (mentionType in removedTypes) removals += mentionType + removals += formerNamesOfRemovedTypes[mentionType].orEmpty() + if (removals.isNotEmpty()) { + removedHit += removals + affected = true + removals.forEach(::recordFormerName) + } + + for (currentName in setOf(mentionType) + currentNamesByFormerName[mentionType].orEmpty()) { + var lossyUnderThisName = false + lossyModified[currentName]?.let { lossyHit += it; lossyUnderThisName = true } + narrowedProperties[currentName]?.let { narrowedHit += it; lossyUnderThisName = true } + narrowedRenames[currentName]?.let { renamedHit += it; lossyUnderThisName = true } + if (lossyUnderThisName) { + affected = true + recordFormerName(currentName) + } + } + if (affected) affectedTypes += mentionType + } if (affectedTypes.isEmpty()) { conforming += QuarantineDecision.Conforming(proposition) @@ -115,8 +212,10 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { val reason = buildReason( removedTypes = removedHit, - lossyChanges = lossyHit.map { lossyModified.getValue(it) }, - narrowedChanges = narrowedHit.flatMap { narrowedProperties.getValue(it) }, + lossyChanges = lossyHit.toList(), + narrowedChanges = narrowedHit.toList(), + renamedChanges = renamedHit.toList(), + matchedByFormerName = matchedByFormerName, fromSchema = diff.fromVersion.schemaName, toSchema = diff.toVersion.schemaName, ) @@ -135,13 +234,16 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { logger.info( "Drift quarantine sweep complete: {} conforming, {} already quarantined from a prior sweep, " + - "{} newly quarantined (removed types: {}, lossy-modified types: {}, narrowed-property types: {})", + "{} newly quarantined (removed types: {}, lossy-modified types: {}, narrowed-property types: {}, " + + "narrowed renamed-property types: {}, former names data was matched under: {})", conforming.size, alreadyQuarantined.size, quarantined.size, removedTypes, lossyModified.keys, narrowedProperties.keys, + narrowedRenames.keys, + formerNamesMatched, ) return QuarantineResult( @@ -160,19 +262,96 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { proposition.status == PropositionStatus.STALE && proposition.metadata.containsKey(DiceMetadataKeys.QUARANTINE_REASON) + /** + * Every name an entity type has gone by, mapped to what that type is called now. + * + * Read off the **newer version's whole declared alias map**, not just the renames this diff + * happens to contain. A rename and a loss usually land in different releases: stamp 2 renames + * `Person` to `Human`, stamp 3 drops a property, and the stamp-2-to-stamp-3 diff holds no rename + * at all while the graph still holds nodes labelled `Person` and the declaration still says + * `Human` used to be one. Keying off the diff's renames would let that loss pass over every + * proposition it stranded, silently, which is the direction this policy exists to avoid. + * + * Safe to read unconditionally because of the declaration guard: an alias may not name a type + * the schema still declares, so no key here can shadow a live type name. + * + * A former name can point at more than one current type — the same guard says nothing about two + * live types both claiming one retired name. Nothing distinguishes which of the two a piece of + * data under that name belongs to, so it is checked against both and a lossy change on either + * quarantines. + */ + private fun formerTypeNames(diff: MetamodelDiff): Map> { + val byFormerName = mutableMapOf>() + diff.toVersion.entityTypeAliases.forEach { (typeName, formerNames) -> + for (formerName in formerNames - typeName) { + byFormerName.getOrPut(formerName) { sortedSetOf() } += typeName + } + } + // A diff assembled by hand rather than by the differ can carry a rename whose old name the + // stamp's alias map doesn't hold. + for (rename in diff.renamedEntityTypes) { + byFormerName.getOrPut(rename.before) { sortedSetOf() } += rename.after + } + return byFormerName + } + + /** + * Every name a **removed** type had gone by, mapped to the removed type it belonged to. + * + * Read off the OLDER version, which is the only side that still has the entry. A removed type + * takes its former names with it: `C` with former names `{A, B}` disappearing leaves + * `removedEntityTypes = [C]` and nothing in the newer version recording that `A` and `B` were + * ever `C`'s. Matching the removal on the current name alone would return propositions labelled + * `A` or `B` conforming, when no declared type describes them at all — the same evasion the + * surviving-type map closes, on the one path that map can't see. + * + * A former name the newer version declares as a live type of its own is left out. Reusing a + * retired name is legal once the type that claimed it is gone, and the removal's rationale is + * that nothing describes those mentions any more, which is false when the schema declares a type + * by that exact name. Data under it is judged as that type's, like any other mention. + */ + private fun formerNamesOfRemovedTypes(diff: MetamodelDiff): Map> { + val removed = diff.removedEntityTypes + if (removed.isEmpty()) return emptyMap() + + val stillDeclared = diff.toVersion.entityTypeNames.toSet() + val byFormerName = mutableMapOf>() + for (typeName in removed) { + val formerNames = diff.fromVersion.entityTypeAliases[typeName].orEmpty() + for (formerName in formerNames - typeName - stillDeclared) { + byFormerName.getOrPut(formerName) { sortedSetOf() } += typeName + } + } + return byFormerName + } + /** * Whether a property's new shape might not hold what its old shape did. * - * A changed value type or a flip between value and reference always counts. Cardinality counts - * only when it shrank: the four cardinalities line up as `ONE` ⊂ `OPTIONAL` ⊂ `SET` ⊂ `LIST` by - * what they can hold, so moving up that order is safe (one value fits in a list) and moving - * down can strand something (a list of three doesn't fit in a single value; a list collapsing - * to a set drops duplicates). + * A flip between value and reference always counts. A changed type counts unless it is one of + * the promotions in [SAFE_TYPE_WIDENINGS]. Cardinality counts only when it shrank: the four + * cardinalities line up as `ONE` ⊂ `OPTIONAL` ⊂ `SET` ⊂ `LIST` by what they can hold, so moving + * up that order is safe (one value fits in a list) and moving down can strand something (a list + * of three doesn't fit in a single value; a list collapsing to a set drops duplicates). + * + * Takes the two signatures rather than a change entry, so a renamed property and one that kept + * its name are judged by the same code. + */ + private fun isNarrowing(before: PropertySignature, after: PropertySignature): Boolean = + before.kind != after.kind || + (before.type != after.type && !isSafeWidening(before, after)) || + breadth(after.cardinality) < breadth(before.cardinality) + + /** + * Whether a value type moved to one that holds everything the old one held. + * + * Scoped to [PropertySignature.Kind.VALUE] on both sides. A reference target names an entity + * type, and one entity type is never a promotion of another. */ - private fun isNarrowing(change: MetamodelChange.PropertySignatureChanged): Boolean = - change.typeChanged || - change.kindChanged || - breadth(change.after.cardinality) < breadth(change.before.cardinality) + private fun isSafeWidening(before: PropertySignature, after: PropertySignature): Boolean = + before.kind == PropertySignature.Kind.VALUE && + after.kind == PropertySignature.Kind.VALUE && + SAFE_TYPE_WIDENINGS[before.type] == after.type /** How much a cardinality can hold, as a rank: bigger holds everything smaller can. */ private fun breadth(cardinality: Cardinality): Int = when (cardinality) { @@ -186,6 +365,8 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { removedTypes: Set, lossyChanges: List, narrowedChanges: List, + renamedChanges: List, + matchedByFormerName: Map>, fromSchema: String, toSchema: String, ): String { @@ -217,10 +398,57 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { "(${describe(change.before)} -> ${describe(change.after)})" } + renamedChanges + .sortedWith(compareBy({ it.typeName }, { it.after.name })) + .forEach { change -> + clauses += "type '${change.typeName}' renamed property '${change.before.name}' to " + + "'${change.after.name}' and narrowed it " + + "(${describe(change.before)} -> ${describe(change.after)})" + } + + matchedByFormerName.forEach { (mentionType, currentNames) -> + clauses += "mention type '$mentionType' is a declared former name of " + + "[${currentNames.sorted().joinToString(", ")}]" + } + return "Schema drift '$fromSchema' → '$toSchema': ${clauses.joinToString("; ")}" } /** A property signature as a person would read it: `string ONE`, `Company LIST`. */ private fun describe(signature: PropertySignature): String = "${signature.type.ifEmpty { signature.kind.name.lowercase() }} ${signature.cardinality}" + + companion object { + + /** + * Value type promotions this policy accepts as safe, keyed by the older type name. + * + * The names are the ones a stamp actually carries: `PropertySignature.of` copies the + * dictionary's type string verbatim, and for a JVM-reflected type that string is + * `Class.getSimpleName()`. So a Kotlin `Int` field renders as `int` and a nullable `Int?` as + * `Integer`. `DriftQuarantinePolicyTest` pins all four pairs against signatures rendered + * from real declarations, so a rendering change upstream fails the build instead of quietly + * emptying this list. + * + * Iceberg defines two of these as safe column promotions, `int` → `long` and `float` → + * `double`; the boxed pair is the same two promotions as a JVM dictionary spells them. The + * reason carries over: every value of the older type has an exact representation in the + * newer one, so nothing already written needs rewriting or can fail to read back. + * + * Primitive to primitive and boxed to boxed only. `int` → `Long` is a boxing change and + * `Integer` → `long` a nullability change; both alter what the property can hold beyond the + * numeric range, so neither is here. Narrowing is never safe, so no pair appears reversed. + * + * Experimental: the list may grow before 1.0. + */ + @JvmField + val SAFE_TYPE_WIDENINGS: Map = java.util.Collections.unmodifiableMap( + linkedMapOf( + "int" to "long", + "float" to "double", + "Integer" to "Long", + "Float" to "Double", + ), + ) + } } diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt index 1b80bd04..e803703c 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt @@ -18,6 +18,7 @@ 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.JvmType import com.embabel.agent.core.ValuePropertyDefinition import com.embabel.dice.common.DiceMetadataKeys import com.embabel.dice.metamodel.support.MentionTypeDriftQuarantinePolicy @@ -29,6 +30,7 @@ import com.embabel.dice.proposition.PropositionStatus import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested @@ -66,6 +68,35 @@ class DriftQuarantinePolicyTest { private fun reasonOf(decision: QuarantineDecision.Quarantined): String = decision.proposition.metadata[DiceMetadataKeys.QUARANTINE_REASON] as String + /** + * A stamp built field by field. The rename cases need declared former names and exact property + * signatures, which a `DynamicType` round-trip can't express. A type's own name is one of its + * labels, which is what a real stamp holds and what makes a rename's own-name label swap appear. + */ + private fun versionOf( + types: List, + properties: Map> = emptyMap(), + aliases: Map> = emptyMap(), + ): MetamodelVersion = MetamodelVersion( + schemaName = "test", + entityTypeNames = types, + entityTypeLabels = types.associateWith { setOf(it) }, + entityTypeProperties = types.associateWith { properties[it].orEmpty() }, + relationshipNames = emptyList(), + entityTypeAliases = aliases, + ) + + /** The same stamp, given as `typeName to signatures` pairs. */ + private fun versionOf(vararg types: Pair>): MetamodelVersion = + versionOf(types.map { it.first }, types.toMap()) + + private fun valueProperty( + name: String, + type: String = "string", + cardinality: Cardinality = Cardinality.ONE, + aliases: Set = emptySet(), + ): PropertySignature = PropertySignature(name, PropertySignature.Kind.VALUE, type, cardinality, aliases) + @Nested inner class NothingLossy { @@ -426,6 +457,599 @@ class DriftQuarantinePolicyTest { } } + /** + * A declared rename strands nothing on its own, and whatever else moved on a renamed type is + * reported under the new name while the data still carries the old one. + */ + @Nested + inner class DeclaredRenames { + + @Test + fun `a pure type rename quarantines nothing, under either name`() { + val diff = differ.diff( + versionOf(listOf("Person")), + versionOf(listOf("Human"), aliases = mapOf("Human" to setOf("Person"))), + ) + assertEquals( + listOf(MetamodelChange.EntityTypeRenamed("Person", "Human")), + diff.changes, + "sanity: the rename is the only change", + ) + + val result = policy.evaluate( + diff, + listOf(proposition("written before the rename", "Person"), proposition("written after", "Human")), + ) + + assertEquals(2, result.conforming.size) + assertEquals(0, result.quarantined.size) + } + + @Test + fun `a pure property rename quarantines nothing`() { + val diff = differ.diff( + versionOf(listOf("Person"), properties = mapOf("Person" to setOf(valueProperty("age")))), + versionOf( + listOf("Person"), + properties = mapOf("Person" to setOf(valueProperty("years", aliases = setOf("age")))), + ), + ) + assertEquals(1, diff.renamedProperties.size, "sanity: the rename paired: ${diff.changes}") + + val result = policy.evaluate(diff, listOf(proposition("Alice is 40", "Person"))) + + assertEquals(1, result.conforming.size) + assertEquals(0, result.quarantined.size) + } + + @Test + fun `an alias-only change never quarantines`() { + val diff = differ.diff( + versionOf(listOf("Person")), + versionOf(listOf("Person"), aliases = mapOf("Person" to setOf("Individual"))), + ) + assertEquals(1, diff.entityTypeAliasChanges.size, "sanity: ${diff.changes}") + + val result = policy.evaluate( + diff, + listOf(proposition("Alice is a person", "Person"), proposition("Bob", "Individual")), + ) + + assertEquals(2, result.conforming.size) + assertEquals(0, result.quarantined.size) + } + + @Test + fun `a retired alias never quarantines`() { + val diff = differ.diff( + versionOf(listOf("Person"), aliases = mapOf("Person" to setOf("Individual"))), + versionOf(listOf("Person")), + ) + assertEquals(1, diff.entityTypeAliasChanges.size, "sanity: ${diff.changes}") + + val result = policy.evaluate(diff, listOf(proposition("Bob", "Individual"))) + + assertEquals(1, result.conforming.size) + } + + @Test + fun `a lossy change on a renamed type quarantines the old name and the new one`() { + val diff = differ.diff( + versionOf( + listOf("Person"), + properties = mapOf("Person" to setOf(valueProperty("age"), valueProperty("email"))), + ), + versionOf( + listOf("Human"), + properties = mapOf("Human" to setOf(valueProperty("age"))), + aliases = mapOf("Human" to setOf("Person")), + ), + ) + + val result = policy.evaluate( + diff, + listOf( + proposition("old data", "Person"), + proposition("new data", "Human"), + proposition("untouched", "Company"), + ), + ) + + assertEquals(2, result.quarantined.size) + assertEquals(1, result.conforming.size) + + val underOldName = result.quarantined.first { it.affectedMentionTypes.contains("Person") } + val reason = reasonOf(underOldName) + assertTrue(reason.contains("email"), "the reason should name the lost property: $reason") + assertTrue( + reason.contains("Person") && reason.contains("Human"), + "and should say which current type the old name resolves to: $reason", + ) + } + + @Test + fun `a lossy change two renames deep still catches the oldest name`() { + // A became B became C, and the stamp for B was never diffed against. Former names + // accumulate, so C declares both and every name the type has gone by is checked. + val diff = differ.diff( + versionOf("A" to setOf(valueProperty("x"), valueProperty("y"))), + versionOf( + listOf("C"), + properties = mapOf("C" to setOf(valueProperty("x"))), + aliases = mapOf("C" to setOf("A", "B")), + ), + ) + assertEquals(1, diff.renamedEntityTypes.size, "sanity: A paired with C: ${diff.changes}") + + val result = policy.evaluate( + diff, + listOf( + proposition("oldest", "A"), + proposition("intermediate", "B"), + proposition("current", "C"), + ), + ) + + assertEquals(3, result.quarantined.size, "every former name labels data the change stranded") + assertEquals(0, result.conforming.size) + } + + @Test + fun `a lossy change in a later diff still reaches data under the name the type dropped`() { + // The rename and the loss land in different releases. Stamp 2 renamed Person to Human; + // stamp 3 only drops a property, so this diff carries no rename at all — while the graph + // still holds nodes labelled Person and the declaration still says Human used to be one. + // Matching reads the declared alias map, so it doesn't depend on the rename being here. + val renamed = versionOf( + listOf("Human"), + properties = mapOf("Human" to setOf(valueProperty("age"), valueProperty("email"))), + aliases = mapOf("Human" to setOf("Person")), + ) + val trimmed = versionOf( + listOf("Human"), + properties = mapOf("Human" to setOf(valueProperty("age"))), + aliases = mapOf("Human" to setOf("Person")), + ) + val diff = differ.diff(renamed, trimmed) + assertTrue(diff.renamedEntityTypes.isEmpty(), "sanity: no rename in this diff: ${diff.changes}") + + val result = policy.evaluate( + diff, + listOf(proposition("written before the rename", "Person"), proposition("written after", "Human")), + ) + + assertEquals(2, result.quarantined.size) + assertTrue(reasonOf(result.quarantined.first { it.affectedMentionTypes.contains("Person") }) + .contains("Human"), "the reason should still resolve the old name") + } + + @Test + fun `a later diff reaches every former name, however many renames deep`() { + val settled = { properties: Set -> + versionOf( + listOf("C"), + properties = mapOf("C" to properties), + aliases = mapOf("C" to setOf("A", "B")), + ) + } + val diff = differ.diff( + settled(setOf(valueProperty("x"), valueProperty("y"))), + settled(setOf(valueProperty("x"))), + ) + assertTrue(diff.renamedEntityTypes.isEmpty(), "sanity: no rename in this diff: ${diff.changes}") + + val result = policy.evaluate( + diff, + listOf(proposition("oldest", "A"), proposition("intermediate", "B"), proposition("current", "C")), + ) + + assertEquals(3, result.quarantined.size) + } + + @Test + fun `a later diff with nothing lossy leaves former-name data alone`() { + val settled = { properties: Set -> + versionOf( + listOf("Human"), + properties = mapOf("Human" to properties), + aliases = mapOf("Human" to setOf("Person")), + ) + } + val diff = differ.diff( + settled(setOf(valueProperty("age"))), + settled(setOf(valueProperty("age"), valueProperty("email"))), + ) + assertTrue(diff.changes.isNotEmpty(), "sanity: the diff is non-empty but additive") + + val result = policy.evaluate(diff, listOf(proposition("old data", "Person"))) + + assertEquals(1, result.conforming.size) + assertEquals(0, result.quarantined.size) + } + + @Test + fun `a removed type takes its former names down with it`() { + // A became C two stamps ago, so C carries {A, B}. Now C is deleted outright. The newer + // version has no entry for C at all, so its former names have to come off the older + // side; otherwise data labelled A or B conforms while nothing declared describes it. + val from = versionOf( + listOf("C", "Keep"), + aliases = mapOf("C" to setOf("A", "B")), + ) + val diff = differ.diff(from, versionOf(listOf("Keep"))) + assertEquals(setOf("C"), diff.removedEntityTypes, "sanity: only C is named as removed") + + val result = policy.evaluate( + diff, + listOf( + proposition("oldest", "A"), + proposition("intermediate", "B"), + proposition("current", "C"), + proposition("unaffected", "Keep"), + ), + ) + + assertEquals(3, result.quarantined.size) + assertEquals(1, result.conforming.size) + + val underOldestName = result.quarantined.first { it.affectedMentionTypes.contains("A") } + val reason = reasonOf(underOldestName) + assertTrue(reason.contains("C"), "the reason should name the removed type: $reason") + assertTrue(reason.contains("A"), "and the former name it was matched under: $reason") + } + + @Test + fun `a former name the newer version reuses as a live type is not swept by the removal`() { + // Retiring C frees the name A, and declaring a fresh type called A is legal from then + // on. Something declared does describe data labelled A, so the removal doesn't reach it. + val from = versionOf(listOf("C", "Keep"), aliases = mapOf("C" to setOf("A", "B"))) + val diff = differ.diff(from, versionOf(listOf("A", "Keep"))) + assertEquals(setOf("C"), diff.removedEntityTypes, "sanity: C is removed, not renamed") + assertEquals(setOf("A"), diff.addedEntityTypes) + + val result = policy.evaluate( + diff, + listOf(proposition("reused name", "A"), proposition("intermediate", "B")), + ) + + assertEquals(1, result.conforming.size, "A names a type the schema declares") + assertEquals(1, result.quarantined.size) + assertTrue(result.quarantined.single().affectedMentionTypes.contains("B")) + } + + @Test + fun `a removed type with no former names behaves as it always did`() { + val diff = differ.diff(versionOf(listOf("Gone", "Keep")), versionOf(listOf("Keep"))) + + val result = policy.evaluate( + diff, + listOf(proposition("stranded", "Gone"), proposition("fine", "Keep")), + ) + + assertEquals(1, result.quarantined.size) + assertEquals(1, result.conforming.size) + } + + @Test + fun `a deliberately retired former name stops matching`() { + // Retiring an alias is a declaration that the schema no longer claims the name. From + // then on, data still carrying it is reported by the observed-side comparison as + // ordinary undeclared drift rather than resolved back to the type that dropped it. + val settled = { formerNames: Set, properties: Set -> + versionOf( + listOf("Human"), + properties = mapOf("Human" to properties), + aliases = if (formerNames.isEmpty()) emptyMap() else mapOf("Human" to formerNames), + ) + } + val diff = differ.diff( + settled(setOf("Person"), setOf(valueProperty("age"), valueProperty("email"))), + settled(emptySet(), setOf(valueProperty("age"))), + ) + assertEquals(1, diff.modifiedEntityTypes.size, "sanity: email was dropped: ${diff.changes}") + + val result = policy.evaluate( + diff, + listOf(proposition("old data", "Person"), proposition("new data", "Human")), + ) + + assertEquals(1, result.quarantined.size) + assertTrue(result.quarantined.single().affectedMentionTypes.contains("Human")) + assertEquals(1, result.conforming.size, "the retired name is no longer the schema's to claim") + } + + @Test + fun `a former name claimed by two renamed types is checked against both`() { + // The declaration guard refuses an alias naming a type the schema still declares, and + // says nothing about two live types both claiming one retired name. B is ambiguous, so + // a lossy change on either type quarantines data labelled B. + val diff = differ.diff( + versionOf( + listOf("A", "E"), + properties = mapOf("E" to setOf(valueProperty("kept"), valueProperty("dropped"))), + ), + versionOf( + listOf("C", "D"), + properties = mapOf("D" to setOf(valueProperty("kept"))), + aliases = mapOf("C" to setOf("A", "B"), "D" to setOf("B", "E")), + ), + ) + assertEquals(2, diff.renamedEntityTypes.size, "sanity: both types paired: ${diff.changes}") + + val result = policy.evaluate( + diff, + listOf(proposition("ambiguous", "B"), proposition("unambiguous", "A")), + ) + + assertEquals(1, result.quarantined.size) + assertTrue(result.quarantined.single().affectedMentionTypes.contains("B")) + assertEquals(1, result.conforming.size, "C lost nothing, so data under A is fine") + } + + @Test + fun `a narrowing disguised inside a property rename still quarantines`() { + val diff = differ.diff( + versionOf("Person" to setOf(valueProperty("age", "integer", Cardinality.LIST))), + versionOf( + "Person" to setOf( + valueProperty("years", "integer", Cardinality.ONE, aliases = setOf("age")), + ), + ), + ) + assertEquals(1, diff.renamedProperties.size, "sanity: it paired as a rename: ${diff.changes}") + assertTrue(diff.modifiedEntityTypes.isEmpty(), "sanity: nothing reported as removed") + + val result = policy.evaluate(diff, listOf(proposition("Alice is 40", "Person"))) + + assertEquals(1, result.quarantined.size, "a list of values does not fit in a single one") + val reason = reasonOf(result.quarantined.single()) + assertTrue(reason.contains("age") && reason.contains("years"), "name both sides: $reason") + } + + @Test + fun `a type change disguised inside a property rename still quarantines`() { + val diff = differ.diff( + versionOf("Person" to setOf(valueProperty("age", "integer"))), + versionOf("Person" to setOf(valueProperty("years", "string", aliases = setOf("age")))), + ) + + assertEquals(1, policy.evaluate(diff, listOf(proposition("Alice", "Person"))).quarantined.size) + } + + @Test + fun `a value-to-reference flip disguised inside a property rename still quarantines`() { + val diff = differ.diff( + versionOf("Person" to setOf(valueProperty("employer", "string"))), + versionOf( + "Person" to setOf( + PropertySignature( + "worksAt", + PropertySignature.Kind.REFERENCE, + "Company", + Cardinality.ONE, + setOf("employer"), + ), + ), + ), + ) + + assertEquals(1, policy.evaluate(diff, listOf(proposition("Alice", "Person"))).quarantined.size) + } + + @Test + fun `a property renamed and safely widened in one step does not quarantine`() { + val diff = differ.diff( + versionOf("Person" to setOf(valueProperty("age", "int"))), + versionOf("Person" to setOf(valueProperty("years", "long", aliases = setOf("age")))), + ) + assertEquals(1, diff.renamedProperties.size, "sanity: ${diff.changes}") + + assertEquals(0, policy.evaluate(diff, listOf(proposition("Alice", "Person"))).quarantined.size) + } + + @Test + fun `a lossy renamed property on a renamed type reaches data under the old type name`() { + val diff = differ.diff( + versionOf("Person" to setOf(valueProperty("age", "integer", Cardinality.LIST))), + versionOf( + listOf("Human"), + properties = mapOf( + "Human" to setOf( + valueProperty("years", "integer", Cardinality.ONE, aliases = setOf("age")), + ), + ), + aliases = mapOf("Human" to setOf("Person")), + ), + ) + + val result = policy.evaluate(diff, listOf(proposition("old data", "Person"))) + + assertEquals(1, result.quarantined.size) + assertTrue(result.quarantined.single().affectedMentionTypes.contains("Person")) + } + + @Test + fun `renaming a referenced type does not quarantine the referrer`() { + // The rename propagates into every referrer's signature and every child's labels. The + // differ folds that propagation into the rename, so none of it reaches this policy. + val employer = { target: String -> + PropertySignature("employer", PropertySignature.Kind.REFERENCE, target, Cardinality.ONE) + } + val diff = differ.diff( + versionOf(listOf("Company", "Person"), properties = mapOf("Person" to setOf(employer("Company")))), + versionOf( + listOf("Person", "Employer"), + properties = mapOf("Person" to setOf(employer("Employer"))), + aliases = mapOf("Employer" to setOf("Company")), + ), + ) + + val result = policy.evaluate( + diff, + listOf(proposition("Alice works at Acme", "Person", "Company")), + ) + + assertEquals(1, result.conforming.size, "the rename's own propagation is not loss") + assertEquals(0, result.quarantined.size) + } + + @Test + fun `an unrelated label lost on a renamed type still quarantines`() { + // Only the own-name swap folds into the rename. A parent label that genuinely went away + // is reported on the paired type and is judged normally. + val from = MetamodelVersion( + schemaName = "test", + entityTypeNames = listOf("Person"), + entityTypeLabels = mapOf("Person" to setOf("Person", "Agent")), + entityTypeProperties = mapOf("Person" to emptySet()), + relationshipNames = emptyList(), + ) + val to = MetamodelVersion( + schemaName = "test", + entityTypeNames = listOf("Human"), + entityTypeLabels = mapOf("Human" to setOf("Human")), + entityTypeProperties = mapOf("Human" to emptySet()), + relationshipNames = emptyList(), + entityTypeAliases = mapOf("Human" to setOf("Person")), + ) + + val result = policy.evaluate(differ.diff(from, to), listOf(proposition("old data", "Person"))) + + assertEquals(1, result.quarantined.size) + assertTrue(reasonOf(result.quarantined.single()).contains("Agent")) + } + } + + /** + * The type-widening allow-list. Iceberg permits the same four promotions on a table column: every + * value of the older type has an exact representation in the newer one, so nothing already + * written needs rewriting or can fail to read back. + */ + @Nested + inner class TypeWideningAllowList { + + private fun evaluateTypeChange( + fromType: String, + toType: String, + cardinality: Cardinality = Cardinality.ONE, + kind: PropertySignature.Kind = PropertySignature.Kind.VALUE, + ): QuarantineResult { + val diff = differ.diff( + versionOf("Person" to setOf(PropertySignature("age", kind, fromType, Cardinality.ONE))), + versionOf("Person" to setOf(PropertySignature("age", kind, toType, cardinality))), + ) + assertTrue(diff.propertySignatureChanges.isNotEmpty(), "sanity: the differ saw a change") + return policy.evaluate(diff, listOf(proposition("Alice is a person", "Person"))) + } + + /** + * The allow-list is written in terms of the type names a stamp actually carries, and those + * come out of the upstream dictionary's JVM reflection. This renders real declarations + * through the same `PropertySignature.of` a stamp uses and asserts each of the four pairs is + * spelled the way the list spells it, so a rendering change upstream fails here instead of + * quietly emptying the list. + */ + @Test + fun `the allow-list is spelled the way real declarations render`() { + val rendered = JvmType(WideningFixture::class.java).properties + .associate { it.name to PropertySignature.of(it) } + + assertEquals("int", rendered.getValue("primitiveInt").type) + assertEquals("long", rendered.getValue("primitiveLong").type) + assertEquals("float", rendered.getValue("primitiveFloat").type) + assertEquals("double", rendered.getValue("primitiveDouble").type) + assertEquals("Integer", rendered.getValue("boxedInt").type) + assertEquals("Long", rendered.getValue("boxedLong").type) + assertEquals("Float", rendered.getValue("boxedFloat").type) + assertEquals("Double", rendered.getValue("boxedDouble").type) + + rendered.values.forEach { + assertEquals(PropertySignature.Kind.VALUE, it.kind, "a number is a value, not a reference: $it") + } + + assertEquals( + mapOf("int" to "long", "float" to "double", "Integer" to "Long", "Float" to "Double"), + MentionTypeDriftQuarantinePolicy.SAFE_TYPE_WIDENINGS, + ) + + val renderedNames = rendered.values.mapTo(mutableSetOf()) { it.type } + MentionTypeDriftQuarantinePolicy.SAFE_TYPE_WIDENINGS.forEach { (before, after) -> + assertTrue(before in renderedNames, "'$before' is no longer a name any declaration renders") + assertTrue(after in renderedNames, "'$after' is no longer a name any declaration renders") + } + } + + @Test + fun `each allow-listed widening leaves the proposition alone`() { + MentionTypeDriftQuarantinePolicy.SAFE_TYPE_WIDENINGS.forEach { (before, after) -> + assertEquals( + 0, + evaluateTypeChange(before, after).quarantined.size, + "$before -> $after holds everything it held before", + ) + } + } + + @Test + fun `the same pairs reversed are narrowing and quarantine`() { + MentionTypeDriftQuarantinePolicy.SAFE_TYPE_WIDENINGS.forEach { (before, after) -> + assertEquals( + 1, + evaluateTypeChange(after, before).quarantined.size, + "$after -> $before drops range", + ) + } + } + + @Test + fun `a boxing or nullability flip is not a widening`() { + assertEquals(1, evaluateTypeChange("int", "Long").quarantined.size, "int -> Long boxes") + assertEquals(1, evaluateTypeChange("Integer", "long").quarantined.size, "Integer -> long drops null") + assertEquals(1, evaluateTypeChange("float", "Double").quarantined.size) + assertEquals(1, evaluateTypeChange("Float", "double").quarantined.size) + } + + @Test + fun `a promotion outside the list still quarantines`() { + assertEquals(1, evaluateTypeChange("int", "double").quarantined.size) + assertEquals(1, evaluateTypeChange("int", "float").quarantined.size) + assertEquals(1, evaluateTypeChange("long", "double").quarantined.size) + assertEquals(1, evaluateTypeChange("Integer", "Double").quarantined.size) + assertEquals(1, evaluateTypeChange("string", "integer").quarantined.size) + } + + @Test + fun `a widening that also shrinks cardinality quarantines`() { + val diff = differ.diff( + versionOf("Person" to setOf(valueProperty("age", "int", Cardinality.LIST))), + versionOf("Person" to setOf(valueProperty("age", "long", Cardinality.ONE))), + ) + + assertEquals(1, policy.evaluate(diff, listOf(proposition("Alice", "Person"))).quarantined.size) + } + + @Test + fun `the allow-list does not reach reference targets`() { + // Contrived names, deliberately: the guard being tested is that the allow-list is scoped + // to VALUE properties. An entity type is never a promotion of another entity type, whatever + // the two are called. + assertEquals( + 1, + evaluateTypeChange("int", "long", kind = PropertySignature.Kind.REFERENCE).quarantined.size, + "a reference target naming a different type is lossy however it is spelled", + ) + } + + @Test + fun `the allow-list is not mutable through the getter`() { + assertThrows(UnsupportedOperationException::class.java) { + @Suppress("UNCHECKED_CAST") + (MentionTypeDriftQuarantinePolicy.SAFE_TYPE_WIDENINGS as MutableMap) + .put("short", "int") + } + } + } + @Nested inner class WithoutMentions { @@ -441,3 +1065,22 @@ class DriftQuarantinePolicyTest { } } } + +/** + * A real declaration carrying one field per type in the widening allow-list, in both the primitive + * and the boxed spelling. Reflected through `JvmType`, which is the path a `DataDictionary` takes to + * reach `PropertySignature.of`, so the type names it produces are the ones a stamp stores. + * + * A Kotlin `Int` compiles to a primitive field and an `Int?` to a boxed one, which is how one class + * yields all eight names. + */ +private data class WideningFixture( + val primitiveInt: Int, + val primitiveLong: Long, + val primitiveFloat: Float, + val primitiveDouble: Double, + val boxedInt: Int?, + val boxedLong: Long?, + val boxedFloat: Float?, + val boxedDouble: Double?, +) 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 dd47c7c6..bd44c1cd 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 @@ -1875,53 +1875,107 @@ class MetamodelDifferTest { assertTrue(diff.ambiguousEntityTypeRenames.isEmpty(), "a property claim is not a type claim") } + @Test + fun `renamedEntityTypes carries the whole pairing`() { + val diff = differ.diff( + versionOf(listOf("Person")), + versionOf(listOf("Human"), aliases = mapOf("Human" to setOf("Person"))), + ) + + assertEquals(listOf(MetamodelChange.EntityTypeRenamed("Person", "Human")), diff.renamedEntityTypes) + } + + @Test + fun `entityTypeAliasChanges carries alias-only edits`() { + val diff = differ.diff( + versionOf(listOf("Person")), + versionOf(listOf("Person"), aliases = mapOf("Person" to setOf("Individual"))), + ) + + assertEquals( + listOf(MetamodelChange.EntityTypeAliasesChanged("Person", emptySet(), setOf("Individual"))), + diff.entityTypeAliasChanges, + ) + } + + @Test + fun `renamedProperties carries paired property renames`() { + val diff = differ.diff( + versionOf(listOf("Person"), properties = mapOf("Person" to setOf(valueProperty("age")))), + versionOf( + listOf("Person"), + properties = mapOf("Person" to setOf(valueProperty("years", aliases = setOf("age")))), + ), + ) + + assertEquals( + listOf( + MetamodelChange.PropertyRenamed( + typeName = "Person", + before = valueProperty("age"), + after = valueProperty("years", aliases = setOf("age")), + ), + ), + diff.renamedProperties, + ) + } + /** - * 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. + * The fixture holds at least one change of every kind the differ can emit — contested + * claims, paired renames, alias edits, 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. */ @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"), + listOf("Client", "Gone", "Holder", "Keeper", "Person", "Stable"), properties = mapOf( "Holder" to setOf(valueProperty("a"), valueProperty("age", "string")), + "Keeper" to setOf(valueProperty("born")), ), relationships = listOf("Stable-[knew]->Gone"), ), versionOf( - listOf("Customer", "Employee", "Holder", "New", "Stable"), + listOf("Customer", "Employee", "Holder", "Keeper", "New", "Patron", "Stable"), properties = mapOf( "Holder" to setOf( valueProperty("b", aliases = setOf("a")), valueProperty("c", aliases = setOf("a")), valueProperty("age", "integer"), ), + "Keeper" to setOf(valueProperty("birthYear", aliases = setOf("born"))), + ), + aliases = mapOf( + "Customer" to setOf("Person"), + "Employee" to setOf("Person"), + "Patron" to setOf("Client"), + "Keeper" to setOf("Individual"), ), - 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.renamedEntityTypes.size) + assertEquals(1, diff.entityTypeAliasChanges.size) assertEquals(1, diff.ambiguousEntityTypeRenames.size) assertEquals(1, diff.modifiedEntityTypes.size) + assertEquals(1, diff.renamedProperties.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.renamedEntityTypes.size + diff.entityTypeAliasChanges.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}") + diff.renamedProperties.size + diff.ambiguousPropertyRenames.size + + diff.propertySignatureChanges.size + diff.addedRelationships.size + + diff.removedRelationships.size + assertEquals(14, diff.changes.size, "the fixture should exercise every kind: ${diff.changes}") assertEquals(diff.changes.size, throughAccessors, "the accessors should partition ${diff.changes}") } @@ -1932,7 +1986,10 @@ class MetamodelDifferTest { assertTrue(diff.isEmpty) assertTrue(diff.removedEntityTypes.isEmpty()) assertTrue(diff.addedEntityTypes.isEmpty()) + assertTrue(diff.renamedEntityTypes.isEmpty()) + assertTrue(diff.entityTypeAliasChanges.isEmpty()) assertTrue(diff.modifiedEntityTypes.isEmpty()) + assertTrue(diff.renamedProperties.isEmpty()) assertTrue(diff.propertySignatureChanges.isEmpty()) assertTrue(diff.ambiguousEntityTypeRenames.isEmpty()) assertTrue(diff.ambiguousPropertyRenames.isEmpty()) diff --git a/docs/design/metamodel-drift.md b/docs/design/metamodel-drift.md index 19216cc9..d1758946 100644 --- a/docs/design/metamodel-drift.md +++ b/docs/design/metamodel-drift.md @@ -126,19 +126,127 @@ type a **lossy** change touched: | --- | --- | | Type removed | Yes — nothing describes those mentions any more | | Type lost labels or whole properties | Yes — a mention may have relied on what's gone | -| Property narrowed: type changed, value ↔ reference, or cardinality shrank | Yes — the new shape may not hold the old data | +| Property narrowed: value ↔ reference, cardinality shrank, or the type moved outside the widening allow-list | Yes — the new shape may not hold the old data | | Type, label or property added | No | | Cardinality widened (`ONE` → `OPTIONAL` → `SET` → `LIST`) | No — everything that fit before still fits | - -That last row is the ordering the policy uses: the four cardinalities line up by what they can hold, -so moving up is safe and moving down can strand something. A list of three doesn't fit in a single -value, and a list collapsing to a set drops duplicates. The diff itself makes no judgement. +| Value type promoted within the allow-list (`int` → `long`, and three more) | No — every old value has an exact representation | +| Type renamed under a declared alias | No on its own; whatever else moved on the type is judged separately | +| Property renamed under a declared alias | No on its own; the paired signatures are judged by the narrowing row | +| Declared former names added or retired | No — the declaration's list of old names moved, and no data went with it | + +The cardinality row is an ordering the policy uses: the four cardinalities line up by what they can +hold, so moving up is safe and moving down can strand something. A list of three doesn't fit in a +single value, and a list collapsing to a set drops duplicates. The diff itself makes no judgement. `MetamodelDiff` states that `age` went from `string` to `integer`; deciding whether that can strand data is this policy's job. -Type changes count as lossy in **both** directions. We know the declared types moved; we don't know -how a backend stored the values or whether the new type can read the old ones, and guessing wrong in -the permissive direction leaves unreadable data looking healthy. +Outside the widening allow-list, a type change counts as lossy in **both** directions. We know the +declared types moved; we don't know how a backend stored the values or whether the new type can read +the old ones, and guessing wrong in the permissive direction leaves unreadable data looking healthy. + +### Declared renames + +A rename is a fact the declaration states, so on its own it strands nothing. +`EntityTypeRenamed` and `PropertyRenamed` are non-lossy per se, and `EntityTypeAliasesChanged` never +quarantines at all. + +That holds for a type rename **by construction**, because of what the differ does upstream. A type's +own name is one of its labels, so `Person` becoming `Human` mechanically loses the label `Person`; +the same swap propagates into every referrer's signature and every child's inherited label. The +differ folds all of it into `EntityTypeRenamed`, so none of it reaches this policy as a removed label +or a changed signature. See [metamodel-diff.md](metamodel-diff.md#comparison-modulo-renames). A +parent label that genuinely went away survives the fold, reports on the paired type, and quarantines +normally. + +A property rename carries a before and an after signature, and those two can differ in more than the +name. `PropertyRenamed` exposes the same `typeChanged` / `cardinalityChanged` / `kindChanged` that +`PropertySignatureChanged` does, and the policy runs one narrowing rule over both. So `age: integer +LIST` renamed to `years: integer ONE` quarantines, and `age: int` renamed to `years: long` does not. + +**Matching goes through every former name.** Whatever else moved on a renamed type is reported under +the type's **new** name, and the data in the graph still carries the old one. Matching mention types +against the new name alone would let a lossy change escape every proposition it stranded — the +old-name evasion hole. So a mention type is checked against its own name plus every current type +name that used to go by it, read off `MetamodelVersion.entityTypeAliases` on the newer side. + +The whole declared alias map is read, not just the renames the diff in hand happens to contain. A +rename and a loss usually land in different releases: stamp 2 renames `Person` to `Human`, stamp 3 +drops a property, and the stamp-2-to-stamp-3 diff holds no rename at all — while the graph still +holds nodes labelled `Person`, and the observed-side rule keeps that old label legally in the graph +indefinitely. Keying off the diff's rename entries would let that loss pass silently over every +proposition it stranded. Reading the declaration instead is safe because of the reuse-collision +refusal: an alias may not name a type the schema still declares, so no former name can shadow a live +type. + +A **removed** type is resolved from the older version instead. Deleting `C` outright leaves +`removedEntityTypes = [C]`, and the newer version has no entry for `C` at all, so nothing on that +side records that `A` and `B` were ever its names. Reading the removal's former names off the older +side is what keeps data labelled `A` from conforming while no declared type describes it. The one +exclusion is a former name the newer version declares as a live type of its own: reusing a retired +name is legal once the type that claimed it is gone, and the removal's rationale — nothing describes +those mentions any more — is false when the schema declares a type by that exact name, so data under +it is judged as that live type's. + +Retiring a former name stops it matching. Retirement is a declaration that the schema no longer +claims the name, and from then on data still carrying it is reported by the observed-side comparison +as ordinary undeclared drift, which is the louder signal of the two. + +Former names accumulate rather than shifting one hop at a time. A type renamed `A` → `B` → `C` +declares `{A, B}`, so a lossy change on `C` — or `C` being removed — quarantines data labelled `A`, +`B` or `C` alike: however many renames deep the old label sits, whether or not the intermediate stamp +was ever diffed against, and whether or not the rename rides in the same diff as the loss: + +```mermaid +flowchart TD + M["mention type on a proposition
(what the graph actually stores)"] --> N["check the name itself"] + M --> S{"is it a declared former
name of some type?"} + S -- "of a surviving type
(newer version's aliases)" --> V["check that type
under its current name"] + S -- "of a removed type
(older version's aliases,
unless the name is now live)" --> D["check that removal"] + S -- "no, or retired" --> N + N --> L{"removed, lost members,
or narrowed?"} + V --> L + D --> L + L -- "no" --> C["conforming"] + L -- "yes" --> Q["quarantine, reason names the
loss and the former-name mapping"] +``` + +A former name can point at more than one current type. The declaration guard refuses an alias naming +a type the schema still declares, and says nothing about two live types both claiming one retired +name. Nothing distinguishes which of the two a piece of data under that name belongs to, so it is +checked against both and a lossy change on either quarantines it. + +The reason text says which current type an old name resolved to. Without that, an operator reads a +complaint about `Human` on a proposition whose mentions all say `Person`. + +### The type-widening allow-list + +Four value type changes are treated as safe: + +| Before | After | +| --- | --- | +| `int` | `long` | +| `float` | `double` | +| `Integer` | `Long` | +| `Float` | `Double` | + +Iceberg defines two of these as safe column promotions, `int` → `long` and `float` → `double`. The +boxed pair is the same two promotions as a JVM dictionary spells them, and Iceberg's reason carries +over: every value of the older type has an exact representation in the newer one, so nothing already +written needs rewriting or can fail to read back. + +Primitive to primitive and boxed to boxed only. `int` → `Long` is a boxing change and `Integer` → +`long` a nullability change; both move what the property can hold beyond its numeric range. Narrowing +is never safe, so no pair appears reversed. The allow-list is scoped to `Kind.VALUE`, since a +reference target names an entity type and one entity type is never a promotion of another. + +The names in the table are the ones a stamp actually carries. `PropertySignature.of` copies the +dictionary's type string verbatim, and for a JVM-reflected type that string is +`Class.getSimpleName()` — so a Kotlin `Int` field renders as `int` and a nullable `Int?` as +`Integer`. `DriftQuarantinePolicyTest` renders a real eight-field declaration through the same path +and asserts each of the eight names, so a rendering change upstream fails the build instead of +quietly emptying the list. + +Swap in a different `DriftQuarantinePolicy` if your storage makes more promotions provably safe. Two properties make this safe to run as routine maintenance: @@ -163,6 +271,50 @@ rather than `PropositionRepository`. A drift check only reads by context or in b requiring vector search, graph traversal and temporal query alongside would shut a plain store-and-retrieve backend out of drift checking over capabilities it never uses. +## Prior art + +SHACL is the model for the report half, and it is already described above: validate data that exists, +record each violation with what was expected and where, don't block the write. + +**Delta Lake's `_rescued_data`** is the model for the quarantine half. When a Delta read finds a +column whose value doesn't fit the declared schema, the value is captured into a `_rescued_data` +column rather than dropped, and the row still lands. The stance is that data an extraction already +produced is evidence: a schema that no longer describes it sets that data aside for a person to look +at rather than deleting it. Quarantine is the same move on a proposition: `STALE`, annotated +with a reason, still in the store, still readable, and reversible by clearing one metadata key. + +**Enforcement and evolution are separate settings**, which is how Delta and the Snowflake-style +lakehouses organize this. Enforcement asks whether an incoming write matches; evolution asks whether +the schema should move to accommodate it. DICE splits them the same way, and both halves are opt-in: +the declared schema — which types a `GovernedTypeSelector` governs and what `SchemaAliases` says they +used to be called — is the enforcement side, and the drift mode (`run()` dry versus +`run(dryRun = false)`) is what a check is allowed to do about a mismatch. A schema that governs +nothing enforces nothing, and a dry check changes nothing whatever it finds. + +### Not adopted: auto-adopting additive drift + +Snowflake can evolve a table to accept a column an incoming file carries but the table doesn't, +adding it automatically when the change is purely additive. The equivalent here would be a drift +check that saw an undeclared entity type, decided it was additive, and folded it into the declared +schema on its own. + +DICE does not do this, and the reason is the input. A Snowflake load is a file whose columns a person +or a pipeline produced deliberately. A DICE type comes out of an LLM reading raw text, and a +misparse, a hallucinated type name and a genuine new domain concept are indistinguishable at the +moment they appear. Auto-adoption would write the misparse into the declared schema, move +`contentHash`, and produce a stamp nobody chose that then reads as authoritative. It also removes the +signal: a drift report exists to tell a person the graph is holding something nobody declared, and a +check that declares it has nothing left to report. + +If it is ever wanted, the shape that would be safe: + +- **Opt-in per type**, on the same `GovernedTypeSelector` seam governance already uses, with no + global switch; +- **additive only**, and refused for anything that removes or reshapes; +- **capped** per run, so a bad extraction batch can't rewrite a schema wholesale; +- **provenance-recorded**, with `StampProvenance.trigger` naming the check that caused the stamp, so + the history says which stamps a machine wrote. + ## Scope Every part of a run takes the same optional `ContextId`, and it means the same thing throughout: the @@ -212,3 +364,16 @@ need a Drivine-backed report store, and an observer that asks Neo4j for its dist relationship types. There is no Spring configuration in `dice-metamodel` either, so a runner is an ordinary constructor call until the autoconfigure slice assembles one, with quarantine off unless a host turns it on. + +**Registration-time compatibility evaluation** is deferred design, tracked under the metamodel epic +(`embabel/dice#45`) until it gets its own issue. A registry-style compatibility check would grade a +new stamp against the one before it — backward, forward, full — at the moment it is registered. The +shape that fits DICE is advisory and post-stamp: the stamp is always taken, and the grade is recorded +beside it rather than gating the write, because stamping is what makes the history usable and a +rejecting gate is bypassable by anything that writes to the graph directly. The grading itself would +be a decision table over `MetamodelChange` kinds, since each kind already carries what a grade needs: +`EntityTypeAdded` is backward-compatible, `EntityTypeRemoved` is not, a `PropertySignatureChanged` +grades on the same narrowing rule this policy uses, and a paired rename grades on the delta inside +it. The two rev-1 design reviews archived under the roadmap dish are the input: both showed that +transplanting a registry's mode taxonomy wholesale fails here, so the taxonomy is the part that needs +designing. From 66d251bd7af5b13701c7e6301067308c73da3be5 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:38:29 -0400 Subject: [PATCH 04/11] Track the swept baseline apart from the version history The drift runner compared each declaration against the newest stored version, which is the version it had just written itself, so a lossy declared change was invisible to the very run that recorded it. MetamodelVersionStore gains a sweptVersion/markSwept pair. The runner reads the baseline before its history write and advances it last, only when a live unscoped sweep completed. Both methods default-forward to latestVersion/saveVersion, so existing stores compile unchanged and keep the old behaviour until they override; InMemoryMetamodelVersionStore tracks the pointer independently. Alongside that: - QuarantineDecision.Protected and QuarantineResult.protected report propositions a pin held back, which used to look like a plain skip. - PropositionStatusChanged is emitted per quarantined proposition, and only when the status actually moved. - The merged diff keeps MetamodelDiff's global ordering: removals lead as one sorted block whichever source produced them. - @JvmOverloads on the runner constructor so Java callers can omit the listener. --- CHANGELOG.md | 77 ++- .../dice/metamodel/DriftCheckRunner.kt | 29 +- .../dice/metamodel/DriftQuarantinePolicy.kt | 47 +- .../InMemoryMetamodelVersionStore.kt | 19 + .../dice/metamodel/MetamodelVersionStore.kt | 51 ++ .../support/DefaultDriftCheckRunner.kt | 175 ++++++- .../MentionTypeDriftQuarantinePolicy.kt | 37 +- .../dice/metamodel/DriftCheckRunnerTest.kt | 477 +++++++++++++++++- .../metamodel/DriftQuarantinePolicyTest.kt | 79 ++- .../dice/metamodel/InMemoryMetamodelStores.kt | 11 + .../metamodel/MetamodelVersionStoreTest.kt | 123 ++++- docs/design/metamodel-drift.md | 143 +++++- 12 files changed, 1190 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbf2a3f2..e3c3eb9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,21 +183,68 @@ and the consumer PRs that deliver it). read on it is bounded: `driftReports`, `globalDriftReports` and `driftReportsInContext` each take a `limit` and an optional `since`, and none has a default body, because filtering a limited page down to one scope in memory applies the limit before the filter and can report zero drift while - plenty sits in the store. Quarantine is non-destructive and idempotent. `DriftQuarantinePolicy` - returns `QuarantineDecision`s (`Conforming` / `Quarantined` / `AlreadyQuarantined`) as immutable - `STALE` copies carrying a reason under `dice.metamodel.quarantine.reason`, and the caller - persists them. The shipped `MentionTypeDriftQuarantinePolicy` fires on lossy changes only: a - removed type, a type that lost labels or properties, or a property whose signature narrowed (type - changed, value ↔ reference, or cardinality shrank along `ONE` ⊂ `OPTIONAL` ⊂ `SET` ⊂ `LIST`). - An inherited label observed in the graph counts as declared, so it never quarantines. A - `ContextId` scopes the observation, the candidate propositions and the persisted report alike, so - a mis-declared schema in one context cannot reach another's data. There is still no Drivine - implementation and no Spring wiring; both arrive in later slices. - **Compatibility: additive.** New types in an existing module; no existing API touched. One - dependency-graph change: `dice-metamodel` now depends on `dice` (core), because quarantine works - on the proposition model, so anything depending on `dice-metamodel` alone now pulls `dice` in - transitively. `dice-metamodel` is no longer a leaf module, and `embabel-agent-rag-core` joins - `embabel-agent-api` as a `provided` dependency it expects the host to supply. + plenty sits in the store. + A live run's quarantine candidates come from two independent comparisons merged into one diff: + declared-vs-observed (the `DriftReport` signal) and declared-vs-previous-declared, compared with a + `MetamodelDiffer` against `MetamodelVersionStore.sweptVersion` — a new pointer, tracked apart from + the ordinary stamp history, naming the declaration the *last completed live, unscoped* sweep + reconciled against. The second comparison is what lets a property removed, narrowed, or a whole + type dropped from the declaration itself reach quarantine even when the live graph and the new + declaration still agree on everything the graph currently holds — declared-vs-observed alone is + blind to that case, since nothing about the type is undeclared, only its shape moved. The pointer + only advances via the new `MetamodelVersionStore.markSwept`, called once, last, by a live run with + no `ContextId`: a dry run decides nothing so must not retire it, a scoped run only ever reconciles + one context so retiring it there would strand every other context, and a crash before that final + call leaves it exactly where it was, so the next check retries the same comparison and no + interrupted sweep is ever treated as finished. `sweptVersion`/`markSwept` default to + `latestVersion`/`saveVersion` on the interface, which is the pre-existing (buggy) behavior for a + store that doesn't override them — a real backend should track the pointer independently, the way + the new `InMemoryMetamodelVersionStore` override now does; `latestVersion` alone gets the wrong + answer once a schema's declaration cycles back to a stamp it already used (`A` → `B` → `A` leaves + `B` as the write-order latest, per `saveVersion`'s existing re-save contract, even though `A` is + what's declared again). Quarantine is non-destructive, idempotent, and honors pinning. + `DriftQuarantinePolicy` returns `QuarantineDecision`s (`Conforming` / `Quarantined` / + `AlreadyQuarantined` / `Protected`); only `Quarantined` is an immutable `STALE` copy carrying a + reason under `dice.metamodel.quarantine.reason` for the caller to persist — the other three carry + the proposition back untouched. A pinned proposition a lossy change would otherwise catch comes + back `Protected`, untouched, per DICE's cross-cutting pin promise, with the same reason text so an + operator can still see what it would have caught. The shipped `MentionTypeDriftQuarantinePolicy` + fires on lossy changes only: a removed type, a type that lost labels or properties, or a property + whose signature narrowed (type changed, value ↔ reference, or cardinality shrank along `ONE` ⊂ + `OPTIONAL` ⊂ `SET` ⊂ `LIST`). An inherited label observed in the graph counts as declared, so it + never quarantines. A `ContextId` scopes the observation, the candidate propositions and the + persisted report alike, so a mis-declared schema in one context cannot reach another's data. Each + proposition the sweep actually moves to `STALE` is announced to the runner's `DiceEventListener` as + a `PropositionStatusChanged`, emitted by the runner itself so the signal doesn't depend on whether + the injected `PropositionStore` happens to be wrapped in something like + `EventEmittingPropositionRepository` — the default auto-configured store isn't. A proposition + already `STALE` from ordinary decay that the sweep quarantines (writing the reason, not moving the + status) emits no event, since none of its status actually changed. This is what lets a listener + such as `ProjectionLineageStaleCascade` mark a quarantined proposition's projection records stale + in turn. There is still no Drivine implementation of the new pointer and no Spring wiring; both + arrive in later slices — `DrivineMetamodelVersionStore` compiles unchanged against the new interface + defaults today, and its own round should override `sweptVersion`/`markSwept` the way the in-memory + store does, or it keeps the `latestVersion`-based gap described above. + **Compatibility: additive, with two source-breaking exceptions.** New types in an existing module; + no existing API touched except the two below. `DefaultDriftCheckRunner`'s constructor gains a + *required* `metamodelDiffer: MetamodelDiffer` parameter (the declared-vs-previous comparison) — + every existing caller must start supplying one — alongside a defaulted `listener: + DiceEventListener = DiceEventListener.DEV_NULL` parameter, which does not force a change on its + own. The constructor carries `@JvmOverloads`, so a Java caller updating for the now-required + `metamodelDiffer` does not also have to start supplying `listener`; a Kotlin caller using named + arguments never had to either — but `@JvmOverloads` does not paper over the required parameter + itself, only the defaulted one. `QuarantineDecision` is a sealed interface gaining a fourth + member, `Protected`, so an external exhaustive `when` over it needs a new branch to keep + compiling — the same shape of change already accepted for `MetamodelChange` in this same + Unreleased block. Everything else here stays additive: `MetamodelVersionStore` gains + `sweptVersion`/`markSwept`, both defaulted on the interface (see above), so every existing + implementation keeps compiling unchanged; `QuarantineResult` gains a `protected: + List` parameter defaulted to empty, so existing callers of its + constructor are unaffected. One dependency-graph change: `dice-metamodel` now depends on `dice` + (core), because quarantine works on the proposition model, so anything depending on + `dice-metamodel` alone now pulls `dice` in transitively. `dice-metamodel` is no longer a leaf + module, and `embabel-agent-rag-core` joins `embabel-agent-api` as a `provided` dependency it + expects the host to supply. - Rename-aware quarantine and a type-widening allow-list in `MentionTypeDriftQuarantinePolicy`, **EXPERIMENTAL** (behavior may change before 1.0). diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt index 5e120343..050e4b11 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt @@ -31,7 +31,9 @@ import java.util.Objects * @property report The [DriftReport] this run saved. Every run saves one, including a check that * found nothing. * @property quarantinedCount How many propositions this run newly quarantined. Always 0 on a dry - * run, and 0 whenever there was no entity-type drift. + * run. On a live run it can be non-zero even when [driftedEntityTypes] and + * [driftedRelationshipTypes] are both empty: quarantine reacts to two independent signals, not + * just observed drift — see [DriftCheckRunner.run]'s `dryRun` parameter doc for the second one. */ class DriftCheckResult( val dryRun: Boolean, @@ -84,13 +86,28 @@ class DriftCheckResult( interface DriftCheckRunner { /** - * Declare, stamp, observe, diff, report, and quarantine when [dryRun] is `false` and drift - * touched an entity type. + * Declare, stamp, observe, diff, report, and quarantine when [dryRun] is `false` and either + * source has *any* diff to evaluate: an undeclared *entity* type observed in the graph, or a + * non-empty declared-vs-previous comparison. Either one starts an evaluation sweep, whether or + * not what it found is actually lossy — a purely additive or rename-only declared change still + * runs the policy over the candidate propositions, and the policy is what decides nothing about + * them needs to move. Only the propositions the policy actually judges affected end up + * quarantined. An undeclared *relationship* type alone (observed drift with + * [DriftReport.driftedRelationshipTypes] non-empty but [DriftReport.driftedEntityTypes] empty) + * does not start a sweep at all — only entity mentions are quarantine candidates today, so + * there is nothing for a relationship-only observed drift to catch. * * @param dryRun When `true`, the check runs and its [DriftReport] is persisted, but no - * proposition is touched. When `false`, propositions whose mentions reference a drifted - * entity type are handed to the configured [DriftQuarantinePolicy] and whatever it flags is - * persisted. + * proposition is touched and nothing is swept against. When `false`, quarantine runs against + * two independent signals merged into one sweep: propositions whose mentions reference an + * entity type the graph holds but the declaration doesn't ([DriftReport.driftedEntityTypes]), + * and propositions caught by a lossy change to the declaration itself since it was last swept + * — a property removed or narrowed, a whole type dropped — even when the live graph and the + * new declaration already agree on everything the graph currently holds. Whatever the + * configured [DriftQuarantinePolicy] flags from either signal is persisted. A dry run + * computes neither signal's quarantine effect, so it cannot preview what a live run would + * catch from the second source: running dry, then live, can still find something the dry + * run reported as clean. * @param contextId `null` means the check covers the whole graph. Non-null scopes everything * the check touches to that one context: the observed snapshot, the candidate propositions * read for quarantine, and the persisted [DriftReport]. A mis-declared schema in one context diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt index 7b70beb3..6823f658 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt @@ -20,12 +20,14 @@ import com.embabel.dice.proposition.Proposition /** * What a policy decided about one [Proposition]. * - * Three outcomes. The third covers a proposition an earlier sweep already quarantined: counting it - * as conforming would overstate how clean the set is, and it isn't newly quarantined either, - * because this sweep leaves it alone to preserve its original reason. Its own variant keeps - * `conforming.size` accurate. + * Four outcomes. [AlreadyQuarantined] covers a proposition an earlier sweep already quarantined: + * counting it as conforming would overstate how clean the set is, and it isn't newly quarantined + * either, because this sweep leaves it alone to preserve its original reason. [Protected] covers a + * pinned proposition that a lossy change would otherwise have caught: pinning is DICE's + * cross-cutting "must retain" promise, so this sweep leaves it alone too, and still says what the + * schema change would have done to it. Each keeps `conforming.size` accurate. * - * Sealed, so a `when` over the outcomes is exhaustive and the compiler speaks up if a fourth + * Sealed, so a `when` over the outcomes is exhaustive and the compiler speaks up if a fifth * ever lands. */ sealed interface QuarantineDecision { @@ -71,6 +73,27 @@ sealed interface QuarantineDecision { val reason: String, val affectedMentionTypes: Set, ) : QuarantineDecision + + /** + * A pinned proposition that a lossy schema change would otherwise have quarantined. Pinning + * promises cross-cutting immunity from reclamation (see `PropositionStore.pin`), so this sweep + * leaves it exactly as it was — an unpinned match on the same change gets flipped to `STALE`, + * this one doesn't — and reports it here so an operator reading the sweep can still see it was + * affected. + * + * [proposition] is the original, completely untouched: no status change, no metadata written. + * Persisting it is never necessary, unlike [Quarantined]'s copy. + * + * @property proposition The pinned proposition, unchanged. + * @property reason The same explanation an unpinned match would have carried, so an operator + * knows what the schema change was. + * @property affectedMentionTypes The entity type names that would have triggered quarantine. + */ + data class Protected( + val proposition: Proposition, + val reason: String, + val affectedMentionTypes: Set, + ) : QuarantineDecision } /** @@ -80,21 +103,25 @@ sealed interface QuarantineDecision { * @property quarantined Propositions this sweep flagged, as `STALE` copies waiting to be persisted. * @property alreadyQuarantined Propositions an earlier sweep had already flagged, left untouched by * this one. Empty unless the input contained some. + * @property protected Pinned propositions a lossy change would otherwise have caught, left + * untouched because pinning promises immunity. Empty unless the input contained some. */ data class QuarantineResult @JvmOverloads constructor( val conforming: List, val quarantined: List, val alreadyQuarantined: List = emptyList(), + val protected: List = emptyList(), ) { /** How many propositions the sweep looked at. */ - val total: Int get() = conforming.size + quarantined.size + alreadyQuarantined.size + val total: Int get() = conforming.size + quarantined.size + alreadyQuarantined.size + protected.size /** Every proposition the sweep saw, in one flat list. */ val allPropositions: List get() = conforming.map { it.proposition } + quarantined.map { it.proposition } + - alreadyQuarantined.map { it.proposition } + alreadyQuarantined.map { it.proposition } + + protected.map { it.proposition } } /** @@ -131,6 +158,12 @@ interface DriftQuarantinePolicy { * [QuarantineResult.conforming]. Drift checks run on a schedule and most runs find nothing, so * short-circuiting would report quarantined records as conforming on those runs. * + * A pinned proposition a lossy change would otherwise catch must never be flipped to `STALE`: + * implementations report it as [QuarantineResult.protected] instead, leaving the proposition + * itself untouched. This holds even for one an earlier sweep already quarantined before it was + * pinned; that one is [QuarantineResult.alreadyQuarantined], since idempotency (above) takes + * priority over the pin. + * * @param diff What changed between the old and new schema. * @param propositions The propositions to evaluate. Any [Iterable] will do: a list, a * repository page, a lazy sequence. diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelVersionStore.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelVersionStore.kt index c758d728..3ae79884 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelVersionStore.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelVersionStore.kt @@ -28,6 +28,11 @@ class InMemoryMetamodelVersionStore : MetamodelVersionStore { private val saved = mutableListOf() + // Tracked apart from `saved`'s write order on purpose: the reconciled baseline a sweep last + // completed against is a *pointer*, one per schema, that moves only on markSwept -- unlike + // versionHistory, which never forgets a stamp's original position. See sweptVersion's doc. + private val swept = mutableMapOf() + /** * Upsert on `(schemaName, contentHash)`. A stamp that is already there keeps its place in the * write order, so re-saving an old version doesn't make it the latest; the incoming stamp @@ -50,4 +55,18 @@ class InMemoryMetamodelVersionStore : MetamodelVersionStore { override fun versionHistory(schemaName: String): List = synchronized(saved) { saved.filter { it.schemaName == schemaName }.reversed() } + + /** + * Also saves [version] into the ordinary history, the way the interface default does, so a + * caller that only ever calls [markSwept] for a brand-new stamp still gets it stored -- but the + * reconciled-baseline pointer itself is kept in [swept], last-write-wins per schema, which is + * what lets it answer correctly after a schema cycles back to an earlier stamp. + */ + override fun markSwept(version: MetamodelVersion) { + saveVersion(version) + synchronized(swept) { swept[version.schemaName] = version } + } + + override fun sweptVersion(schemaName: String): MetamodelVersion? = + synchronized(swept) { swept[schemaName] } } diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt index 6e2a0190..637d2605 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt @@ -102,4 +102,55 @@ interface MetamodelVersionStore { */ fun findVersion(schemaName: String, contentHash: String): MetamodelVersion? = versionHistory(schemaName).firstOrNull { it.contentHash == contentHash } + + /** + * The version the last COMPLETED, unscoped live drift sweep reconciled against — the correct + * baseline for the next declared-vs-previous comparison. + * + * This is a different question from [latestVersion], which answers "what's the newest stamp by + * write order" and gets the wrong answer once a declaration cycles back to a stamp that already + * exists: [saveVersion]'s own contract says a re-saved stamp keeps its *original* place in write + * order, so after a schema goes `A` → `B` → `A` again, [latestVersion] still answers `B`, even + * though the schema is back to declaring `A`. A drift check that diffed against [latestVersion] + * would compare the reverted `A` against `B` — the wrong pair — and could miss a lossy change + * that came back. [sweptVersion] tracks the actual reconciled baseline instead, moved forward + * only by [markSwept], so it always answers the version a sweep genuinely finished comparing + * against, whatever order the schema's stamps arrived in. + * + * The default answers [latestVersion]. That is a real gap, not a placeholder pretending the gap + * is closed, and it is wider than the `A` → `B` → `A` case above: [saveVersion] runs on every + * check regardless of [DriftCheckRunner]'s `dryRun` or `contextId`, so if this method still + * answers [latestVersion], every path that reading the reconciled baseline separately was meant + * to close reopens for a non-overriding store — a dry run's save moves what the next live run + * treats as "already reconciled," a scoped live run's save does the same for the contexts it + * never touched, and a crash between the save and the sweep finishing leaves the moved pointer + * behind with nothing having actually been swept against it. A store must override this method + * and [markSwept] together to get an independently-tracked baseline; overriding only one leaves + * the other inconsistent. [InMemoryMetamodelVersionStore] overrides both; a durable backend + * should do the same. + * + * @param schemaName The schema to look up. + * @return The reconciled baseline, or `null` if no live sweep has ever completed for it. + */ + fun sweptVersion(schemaName: String): MetamodelVersion? = latestVersion(schemaName) + + /** + * Record [version] as the new reconciled baseline for its schema, once a live, unscoped drift + * sweep has finished comparing the whole schema against it. See [sweptVersion] for why this is + * tracked apart from [saveVersion]'s write-order history. + * + * Call this only after every candidate the sweep was going to touch has actually been handled — + * calling it earlier (or on a dry run, or a run scoped to one context) would let a later check + * believe a comparison happened that a crash interrupted, or that covered contexts it never + * touched. [DefaultDriftCheckRunner] calls this last, after persisting every proposition its + * sweep quarantined. + * + * The default forwards to [saveVersion]. That keeps the stamp itself recorded (harmless, since a + * completed sweep's version is normally already stored by the time this runs) but does not give + * [sweptVersion] independent tracking on its own — see that method's doc for what a store needs + * to override to close the gap. + * + * @param version The version to record as reconciled. + */ + fun markSwept(version: MetamodelVersion) = saveVersion(version) } diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt index bf4ff3a0..55179491 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt @@ -16,6 +16,8 @@ package com.embabel.dice.metamodel.support import com.embabel.agent.core.ContextId +import com.embabel.dice.common.DiceEventListener +import com.embabel.dice.common.PropositionStatusChanged import com.embabel.dice.metamodel.DeclaredObservedDiffer import com.embabel.dice.metamodel.DeclaredSchemaSource import com.embabel.dice.metamodel.DriftCheckResult @@ -25,6 +27,7 @@ import com.embabel.dice.metamodel.DriftReport import com.embabel.dice.metamodel.DriftReportStore 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.MetamodelVersionStore import com.embabel.dice.metamodel.ObservedSchemaSource @@ -48,31 +51,96 @@ import org.slf4j.LoggerFactory * resolves back to a real stamp through [MetamodelVersionStore.findVersion]. Stamping last, or only * when the schema moved, would leave the first check after a schema change pointing at a hash * nothing has recorded. `saveVersion` upserts on `(schemaName, contentHash)`, so doing it every run - * costs one idempotent write. + * costs one idempotent write. Every run does this, dry or live — it's a history write, not the diff + * baseline update below, for any store that tracks [MetamodelVersionStore.sweptVersion] + * independently of write order (see that method's doc). A store that doesn't override it inherits + * the interface default, which answers [MetamodelVersionStore.latestVersion], and that method's own + * contract draws the exact line: saving a stamp that isn't already recorded moves `latestVersion` — + * and so the forwarded baseline — to it; re-saving a stamp that's already there keeps its original + * write-order position, leaving `latestVersion`, and the forwarded baseline, unmoved. So on such a + * store, this write moves the baseline precisely when the current run's declaration is a genuinely + * new stamp, dry, scoped, or crashed run alike — see [MetamodelVersionStore.sweptVersion]'s doc for + * what that costs. + * + * ## Two sources of drift + * + * A live run's quarantine candidates come from two independent comparisons, merged into one diff + * before the policy ever sees them: + * + * - **Declared vs. observed** ([differ]): what the live graph holds that this declaration doesn't + * recognise. This is what [DriftReport.driftedEntityTypes] records, and it is blind to a property + * that quietly narrowed or disappeared on a type the graph and the declaration still agree on. + * - **Declared vs. previous declared** ([metamodelDiffer]): what changed in the declaration itself + * since [MetamodelVersionStore.sweptVersion] — a removed property, a narrowed cardinality, a whole + * type dropped — regardless of what the graph currently holds. `null` when no live sweep has ever + * completed for this schema. + * + * Without the second comparison, a schema edit that silently strands previously-extracted data + * (say, a value type narrowing from `long` to `int`) would never reach [quarantinePolicy] at all + * until the graph itself drifted out of step with the *new* declaration — which, if nothing else + * changes, is never. [DriftReport] itself is unaffected: it still reports only declared-vs-observed + * drift, since that is the graph-truth signal an operator watching for undeclared shapes wants; the + * declared-vs-previous comparison feeds quarantine only. + * + * ## The diff baseline only advances when a sweep actually finishes + * + * The declared-vs-previous baseline comes from [MetamodelVersionStore.sweptVersion], read before + * [versionStore]'s history write above, and this class only calls + * [MetamodelVersionStore.markSwept] to advance it once — after every candidate a **live, unscoped** + * sweep was going to touch has genuinely been handled. Three things follow, each a real hazard the + * earlier "save on every run" design had: + * + * - A **dry run** decides nothing, so it must not retire a lossy declared change either — the next + * run, live or dry, still needs to see it. `run()` with no arguments stays what the class doc for + * [DriftCheckRunner] promises: reports, changes nothing. + * - A run **scoped to one context** only sweeps that context's candidates. Retiring the schema-wide + * baseline after it would strand every other context's candidates against a change nothing ever + * swept them for. A scoped run still computes and acts on the same diff — that context's + * candidates do get quarantined — it just leaves the baseline where it was, so a later run (scoped + * to another context, or unscoped) still sees the same declared-vs-previous drift and finishes the + * job. The already-quarantined check makes that safe to repeat: nothing already handled gets + * touched twice. + * - A **crash between the history write and the end of the sweep** must not look like a completed + * reconciliation. `markSwept` is the last thing this class does, strictly after every quarantined + * proposition is saved, so an interrupted run leaves the baseline exactly where it was and the next + * run retries the same comparison and finishes the job. + * + * [MetamodelVersionStore.sweptVersion] is a different question from `latestVersion`, which the + * store's own doc covers: `latestVersion` tracks write order and gives the wrong answer once a + * declaration cycles back to an earlier stamp. * * @param declaredSchemaSource Supplies the schema as declared. Read first, so everything downstream * is judged against one declaration. * @param versionStore Where the declared stamp is recorded each run, so report hashes always - * resolve. + * resolve, and where the reconciled baseline is read from and advanced. See "The diff baseline + * only advances when a sweep actually finishes" above. * @param observedSchemaSource Snapshots what the live graph actually contains. * @param differ Compares the declaration against the observation. + * @param metamodelDiffer Compares the declaration against its reconciled baseline. The same + * [StructuralMetamodelDiffer] instance ordinarily implements both this and [differ]. * @param driftReportStore Durable log the report is written to, on every run. * @param quarantinePolicy Decides which stranded propositions to quarantine. Consulted only on a - * live run that found entity-type drift. + * live run that found drift from either source in "Two sources of drift" above. * @param propositionStore Where candidate propositions are read from and quarantined copies are * saved back to. The base persistence port rather than `PropositionRepository`: a drift check only * reads by context or in bulk and saves, so requiring vector search, graph traversal and temporal * query alongside would shut a plain store-and-retrieve backend out of drift checking for * capabilities it never uses. + * @param listener Told about each quarantine as a [PropositionStatusChanged], so a consumer like + * `ProjectionLineageStaleCascade` hears about the transition without depending on whichever + * concrete [propositionStore] happens to be wired in. Defaults to a no-op: most of what + * [DefaultDriftCheckRunner] promises holds with nobody listening at all. */ -class DefaultDriftCheckRunner( +class DefaultDriftCheckRunner @JvmOverloads constructor( private val declaredSchemaSource: DeclaredSchemaSource, private val versionStore: MetamodelVersionStore, private val observedSchemaSource: ObservedSchemaSource, private val differ: DeclaredObservedDiffer, + private val metamodelDiffer: MetamodelDiffer, private val driftReportStore: DriftReportStore, private val quarantinePolicy: DriftQuarantinePolicy, private val propositionStore: PropositionStore, + private val listener: DiceEventListener = DiceEventListener.DEV_NULL, ) : DriftCheckRunner { private val logger = LoggerFactory.getLogger(DefaultDriftCheckRunner::class.java) @@ -80,32 +148,55 @@ class DefaultDriftCheckRunner( override fun run(dryRun: Boolean, contextId: ContextId?): DriftCheckResult { val declared = declaredSchemaSource.declare() - // Before the report is written, so the hash the report carries is already resolvable by the - // time anyone can read it. See the class doc. + // The reconciled baseline, read before this run's own history write below so it can never + // read back its own stamp. Null when no live sweep has ever completed for this schema. See + // "The diff baseline only advances when a sweep actually finishes" on the class doc. + val previousVersion = versionStore.sweptVersion(declared.version.schemaName) + + // Every run stamps its declaration into history, dry or live, so a report's hash always + // resolves — see the class doc. On a store that tracks the reconciled baseline + // independently, this alone never moves it; only markSwept does, at the end of a live, + // unscoped run. On a store that doesn't, this can move it too -- see the class doc. versionStore.saveVersion(declared.version) val observed = observedSchemaSource.observe(contextId) val diff = differ.diffAgainstObserved(declared = declared, observed = observed) + // What moved in the declaration since the reconciled baseline — property removals, narrowed + // cardinality, a whole type dropped — which diff above never sees, since it only compares + // the current declaration against the graph as it stands right now. + val declaredDiff = previousVersion?.let { metamodelDiffer.diff(it, declared.version) } + val report = DriftReport( schemaName = declared.version.schemaName, versionHash = declared.version.contentHash, driftedEntityTypes = diff.driftedEntityTypes, driftedRelationshipTypes = diff.driftedRelationshipTypes, // The instant the graph was looked at, rather than the instant of this write: the - // report is a statement about the snapshot. + // report is a statement about the snapshot. Declared-vs-previous drift isn't part of + // this report; see the class doc. capturedAt = observed.capturedAt, contextId = contextId, ) // Written on every run, including checks that found nothing. driftReportStore.saveDriftReport(report) - val quarantinedCount = if (!dryRun && diff.driftedEntityTypes.isNotEmpty()) { - quarantineDriftedEntityTypes(declared.version, diff.driftedEntityTypes, contextId) + val quarantinedCount = if (!dryRun && (diff.driftedEntityTypes.isNotEmpty() || declaredDiff?.isEmpty == false)) { + quarantineAffectedPropositions(declared.version, diff.driftedEntityTypes, declaredDiff, contextId) } else { 0 } + // This call advances the baseline only for a live, unscoped run: a dry run acted on + // nothing, and a scoped run only ever sweeps one context's candidates against it. + // Unconditional on whether anything was actually quarantined -- "nothing needed doing" is + // still a completed reconciliation against this declaration, and the next check should + // start from here. The saveVersion call above can also move the baseline, on any run, on a + // store that doesn't track it independently -- see the class doc. + if (!dryRun && contextId == null) { + versionStore.markSwept(declared.version) + } + logger.info( "Drift check for '{}' complete (dryRun={}, contextId={}): {} drifted entity type(s), " + "{} drifted relationship type(s), {} quarantined", @@ -121,28 +212,43 @@ class DefaultDriftCheckRunner( } /** - * Hand the drifted types to [quarantinePolicy] and persist whatever it flags. + * Hand every quarantine-worthy change to [quarantinePolicy] and persist whatever it flags, + * announcing each real transition to [listener] along the way. * - * The policy takes a [MetamodelDiff], which compares two declared versions, while a drift check - * has a declaration compared against a live observation. On the part the policy uses they agree: - * a mention whose type the declared schema doesn't recognise is stranded whether the type was - * dropped from a newer declaration or never declared at all. So this synthesizes the equivalent - * diff, one [MetamodelChange.EntityTypeRemoved] per drifted type, and lets the policy decide, - * instead of applying a second quarantine rule here. + * [driftedEntityTypes] (observed but undeclared) and [declaredDiff] (declared-vs-previous) are + * merged into one [MetamodelDiff] before evaluation, one [MetamodelChange.EntityTypeRemoved] per + * drifted type standing in for the ones [declaredDiff] didn't already report as removed. The + * policy only ever sees one diff and decides once; this never runs two independent sweeps that + * could each quarantine, or skip, the same proposition for a different reason. * - * Both ends of the synthesized diff point at the same declared version. There was no old-to-new - * transition; the two sides are there so the policy's reason string has something to name. + * The merge keeps [MetamodelDiff]'s promised global ordering: [MetamodelChange.EntityTypeRemoved] + * is always the differ's first block, sorted by type name, so every removed name — declared or + * drifted — is gathered into that one sorted block. Any [MetamodelChange.EntityTypeRemoved] + * already present in [declaredDiff]'s own changes is explicitly filtered back out before the + * remainder is appended. This filter is what keeps a removal from showing up twice; the merged + * block is built from the two removal sources directly, and [declaredDiff]'s other changes keep + * their original relative order behind it. + * + * [declaredDiff]'s own [MetamodelDiff.fromVersion] carries forward as the merged diff's `from` + * side when it exists, so the policy can still resolve declared former names for a type removal + * that came from the declaration comparison. A drifted-but-undeclared type was never declared by + * either version, so it has no former names to resolve either way. */ - private fun quarantineDriftedEntityTypes( + private fun quarantineAffectedPropositions( declaredVersion: MetamodelVersion, driftedEntityTypes: Set, + declaredDiff: MetamodelDiff?, contextId: ContextId?, ): Int { - val syntheticDiff = MetamodelDiff( - fromVersion = declaredVersion, + val declaredChanges = declaredDiff?.changes.orEmpty() + val mergedRemovedTypeNames = (declaredDiff?.removedEntityTypes.orEmpty() union driftedEntityTypes).sorted() + val mergedRemovals = mergedRemovedTypeNames.map { MetamodelChange.EntityTypeRemoved(it) } + val mergedDiff = MetamodelDiff( + fromVersion = declaredDiff?.fromVersion ?: declaredVersion, toVersion = declaredVersion, - changes = driftedEntityTypes.sorted().map { MetamodelChange.EntityTypeRemoved(it) }, + changes = mergedRemovals + declaredChanges.filterNot { it is MetamodelChange.EntityTypeRemoved }, ) + // A proposition in another context is never a candidate, whatever its mentions say, so a // scoped run cannot reach outside its context. val propositions = if (contextId != null) { @@ -150,8 +256,29 @@ class DefaultDriftCheckRunner( } else { propositionStore.findAll() } - val result = quarantinePolicy.evaluate(syntheticDiff, propositions) - result.quarantined.forEach { decision -> propositionStore.save(decision.proposition) } + // Captured before evaluation, since QuarantineDecision.Quarantined only carries the copy + // already flipped to STALE — the emitted event needs to say what it moved from. + val statusById = propositions.associate { it.id to it.status } + + val result = quarantinePolicy.evaluate(mergedDiff, propositions) + result.quarantined.forEach { decision -> + val saved = propositionStore.save(decision.proposition) + // A proposition can arrive already STALE from ordinary decay (no quarantine reason yet, + // so the policy still treats it as a fresh candidate) and get quarantined without its + // status actually moving. Announcing a transition then would be a lie the listener has + // no way to catch, so this only fires when something really changed. + val previousStatus = statusById.getValue(decision.proposition.id) + if (previousStatus != decision.proposition.status) { + listener.onEvent( + PropositionStatusChanged( + proposition = saved, + previousStatus = previousStatus, + newStatus = decision.proposition.status, + reason = decision.reason, + ), + ) + } + } return result.quarantined.size } } diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt index d3121029..4ab7e102 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt @@ -94,6 +94,17 @@ import org.slf4j.LoggerFactory * the conforming bucket. That holds for any diff, an empty one included, because being already * quarantined is a fact about the proposition. * + * ## Pinned propositions + * + * A pinned proposition a lossy change would otherwise catch is never flipped to `STALE`. Pinning is + * DICE's cross-cutting promise that a proposition resists reclamation, the same promise the decay + * collector, the sweep policy and contradiction resolution already honor, and quarantine is one more + * reclamation path that has to keep it. The match still gets reported, as + * [QuarantineDecision.Protected], so an operator can see what the schema change would have caught + * without the proposition itself being touched. A proposition an earlier sweep already quarantined + * before it was pinned is unaffected by this: it still comes back as + * [QuarantineDecision.AlreadyQuarantined], since idempotency is checked first. + * * Rename awareness and the widening allow-list are experimental: behavior may change before 1.0. */ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { @@ -136,6 +147,7 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { val conforming = mutableListOf() val quarantined = mutableListOf() + val protected = mutableListOf() // Former names that actually matched something, for the summary line. The map above holds // every former name the declaration knows; this holds the ones a proposition was labelled // with, which is what an operator reading the log is trying to find out. @@ -219,6 +231,24 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { fromSchema = diff.fromVersion.schemaName, toSchema = diff.toVersion.schemaName, ) + + // Pinning promises cross-cutting immunity from reclamation, the same promise the decay + // collector, the sweep policy and contradiction resolution already honor. A pinned match + // is reported so an operator can still see what the schema change would have caught, but + // the proposition itself is never touched. + if (proposition.pinned) { + logger.debug( + "Protecting pinned proposition (id={}) from quarantine: {}", + proposition.id, reason, + ) + protected += QuarantineDecision.Protected( + proposition = proposition, + reason = reason, + affectedMentionTypes = affectedTypes, + ) + continue + } + val flagged = proposition .withStatus(PropositionStatus.STALE) .withMetadataValue(DiceMetadataKeys.QUARANTINE_REASON, reason) @@ -234,11 +264,13 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { logger.info( "Drift quarantine sweep complete: {} conforming, {} already quarantined from a prior sweep, " + - "{} newly quarantined (removed types: {}, lossy-modified types: {}, narrowed-property types: {}, " + - "narrowed renamed-property types: {}, former names data was matched under: {})", + "{} newly quarantined, {} protected by pin (removed types: {}, lossy-modified types: {}, " + + "narrowed-property types: {}, narrowed renamed-property types: {}, " + + "former names data was matched under: {})", conforming.size, alreadyQuarantined.size, quarantined.size, + protected.size, removedTypes, lossyModified.keys, narrowedProperties.keys, @@ -250,6 +282,7 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { conforming = conforming, quarantined = quarantined, alreadyQuarantined = alreadyQuarantined, + protected = protected, ) } diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt index b2a5b8c8..2ccf4eff 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt @@ -15,11 +15,20 @@ */ package com.embabel.dice.metamodel +import com.embabel.agent.core.Cardinality import com.embabel.agent.core.ContextId +import com.embabel.dice.common.DiceEvent +import com.embabel.dice.common.DiceEventListener import com.embabel.dice.common.DiceMetadataKeys +import com.embabel.dice.common.PropositionStatusChanged +import com.embabel.dice.common.SafeDiceEventListener import com.embabel.dice.metamodel.support.DefaultDriftCheckRunner import com.embabel.dice.metamodel.support.MentionTypeDriftQuarantinePolicy import com.embabel.dice.metamodel.support.StructuralMetamodelDiffer +import com.embabel.dice.projection.lineage.InMemoryProjectionRecordStore +import com.embabel.dice.projection.lineage.ProjectionLifecycle +import com.embabel.dice.projection.lineage.ProjectionLineageStaleCascade +import com.embabel.dice.projection.lineage.ProjectionRecord import com.embabel.dice.proposition.EntityMention import com.embabel.dice.proposition.MentionRole import com.embabel.dice.proposition.Proposition @@ -30,6 +39,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -54,6 +64,7 @@ class DriftCheckRunnerTest { /** Declared schema, overridable per test. Defaults to two types and one relationship. */ private var declaredEntityTypes = listOf("Person", "Company") private var declaredEntityTypeLabels: Map>? = null + private var declaredEntityTypeProperties: Map>? = null private var declaredRelationshipTypeNames = setOf("WORKS_AT") /** Observed schema, overridable per test. Defaults to matching the declaration exactly. */ @@ -71,17 +82,23 @@ class DriftCheckRunnerTest { schemaName = schemaName, entityTypeNames = declaredEntityTypes, entityTypeLabels = declaredEntityTypeLabels ?: declaredEntityTypes.associateWith { setOf(it) }, - entityTypeProperties = declaredEntityTypes.associateWith { emptySet() }, + entityTypeProperties = declaredEntityTypeProperties ?: declaredEntityTypes.associateWith { emptySet() }, relationshipNames = declaredRelationshipTypeNames.map { "Person-[$it]->Company" }, ) // Typed as the base persistence port rather than PropositionRepository, so whatever a test // passes in, the runner only gets store-and-retrieve out of it. - private fun buildRunner(store: PropositionStore = propositionStore): DriftCheckRunner { + private fun buildRunner( + store: PropositionStore = propositionStore, + listener: DiceEventListener = DiceEventListener.DEV_NULL, + quarantinePolicy: DriftQuarantinePolicy = MentionTypeDriftQuarantinePolicy(), + versionStore: MetamodelVersionStore = this.versionStore, + ): DriftCheckRunner { val declaredSchema = DeclaredSchema( version = declaredVersion(), relationshipTypeNames = declaredRelationshipTypeNames, ) + val differ = StructuralMetamodelDiffer() return DefaultDriftCheckRunner( declaredSchemaSource = DeclaredSchemaSource { declaredSchema }, versionStore = versionStore, @@ -97,13 +114,29 @@ class DriftCheckRunnerTest { capturedAt = capturedAt.plusSeconds(60 * observations++), ) }, - differ = StructuralMetamodelDiffer(), + // StructuralMetamodelDiffer implements both differ interfaces; one instance plays both + // roles here exactly as the class doc says a real wiring ordinarily does. + differ = differ, + metamodelDiffer = differ, driftReportStore = reportStore, - quarantinePolicy = MentionTypeDriftQuarantinePolicy(), + quarantinePolicy = quarantinePolicy, propositionStore = store, + listener = listener, ) } + /** A previous stamp for `schemaName`: `Person{age}` plus a bare `Company`. Overridable per test. */ + private fun previousVersionWithPersonAge( + entityTypeNames: List = listOf("Person", "Company"), + ): MetamodelVersion = MetamodelVersion( + schemaName = schemaName, + entityTypeNames = entityTypeNames, + entityTypeLabels = entityTypeNames.associateWith { setOf(it) }, + entityTypeProperties = entityTypeNames.associateWith { emptySet() } + + mapOf("Person" to setOf(PropertySignature("age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE))), + relationshipNames = declaredRelationshipTypeNames.map { "Person-[$it]->Company" }, + ) + private fun proposition( text: String, vararg mentionTypes: String, @@ -259,6 +292,78 @@ class DriftCheckRunnerTest { assertEquals(1, savedReports().size, "the report is written on a live run just the same") } + @Test + fun `quarantine's status change reaches projection lineage through the listener`() { + // ProjectionLineageStaleCascade is how a proposition going STALE is supposed to mark its + // projection records stale in turn (see that class); it reacts to PropositionStatusChanged. + // Wiring the runner's listener straight to it is what routes a quarantine transition there. + observedEntityTypes = setOf("Person", "Company", "GhostType") + val affected = propositionStore.save(proposition("a ghost was mentioned", "GhostType")) + val recordStore = InMemoryProjectionRecordStore() + recordStore.record( + ProjectionRecord( + propositionId = affected.id, + target = "test-target", + lifecycle = ProjectionLifecycle.PROJECTED, + runId = "run-1", + ), + ) + val cascade = ProjectionLineageStaleCascade(recordStore) + val runner = buildRunner(listener = SafeDiceEventListener(cascade)) + + val result = runner.run(dryRun = false) + + assertEquals(1, result.quarantinedCount, "sanity: the quarantine itself did happen") + assertEquals(PropositionStatus.STALE, propositionStore.findById(affected.id)!!.status) + assertEquals( + ProjectionLifecycle.STALE, + recordStore.findByProposition(affected.id).single().lifecycle, + "the cascade heard about the transition and marked its record stale in turn", + ) + } + + @Test + fun `a conforming proposition emits no status-changed event`() { + // The listener should hear about quarantines, not about every proposition the sweep looked + // at; a conforming proposition's status never moved and nothing should say it did. + observedEntityTypes = setOf("Person", "Company", "GhostType") + propositionStore.save(proposition("Alice works at Acme", "Person", "Company")) + val recording = RecordingDiceEventListener() + val runner = buildRunner(listener = recording) + + runner.run(dryRun = false) + + assertTrue(recording.events.isEmpty()) + } + + @Test + fun `a dry run never emits a status-changed event`() { + observedEntityTypes = setOf("Person", "Company", "GhostType") + propositionStore.save(proposition("a ghost was mentioned", "GhostType")) + val recording = RecordingDiceEventListener() + val runner = buildRunner(listener = recording) + + runner.run(dryRun = true) + + assertTrue(recording.events.isEmpty(), "a dry run must not announce a transition it never made") + } + + @Test + fun `the emitted event carries the quarantine reason and the previous status`() { + observedEntityTypes = setOf("Person", "Company", "GhostType") + propositionStore.save(proposition("a ghost was mentioned", "GhostType")) + val recording = RecordingDiceEventListener() + val runner = buildRunner(listener = recording) + + runner.run(dryRun = false) + + val event = recording.events.filterIsInstance().single() + assertEquals(PropositionStatus.ACTIVE, event.previousStatus) + assertEquals(PropositionStatus.STALE, event.newStatus) + assertNotNull(event.reason) + assertTrue(event.reason!!.contains("GhostType")) + } + @Test fun `a plain store-and-retrieve backend can drive a live run`() { // The runner asks for the base persistence port, so a backend with no vector search, graph @@ -289,6 +394,322 @@ class DriftCheckRunnerTest { assertEquals(PropositionStatus.ACTIVE, propositionStore.findAll().single().status) } + // ---- Declared-vs-previous drift ---- + + @Test + fun `a lossy declared change with no observed drift still reaches quarantine`() { + // Person was stamped with an `age` property on an earlier, completed live check. The + // CURRENT declaration has dropped it, but the observed graph matches the current + // declaration exactly (default observedEntityTypes), so diffAgainstObserved alone — + // declared vs. what the graph holds right now — finds nothing: there is no + // undeclared-but-observed type or label anywhere. Only a declared-vs-previous-declared + // comparison sees the property actually vanished. + val previousVersion = previousVersionWithPersonAge() + versionStore.markSwept(previousVersion) + val mentioning = propositionStore.save(proposition("Alice is 40", "Person")) + val runner = buildRunner() + + val result = runner.run(dryRun = false) + + assertTrue(result.driftedEntityTypes.isEmpty(), "sanity: no declared-vs-observed drift at all") + assertEquals(1, result.quarantinedCount, "the declared property removal must still reach quarantine") + val quarantined = propositionStore.findById(mentioning.id)!! + assertEquals(PropositionStatus.STALE, quarantined.status) + assertTrue( + (quarantined.metadata[DiceMetadataKeys.QUARANTINE_REASON] as String).contains("age"), + "the reason should name the property the declaration dropped", + ) + } + + @Test + fun `the baseline is read before this run's own history write, even on a store with no independent tracking`() { + // InMemoryMetamodelVersionStore (every other test in this class) tracks sweptVersion + // independently of latestVersion, so it can't expose a read-before-save ordering bug: even a + // buggy read-after-save would still see the old baseline through the independent pointer. + // DefaultForwardingVersionStore has no such safety net -- sweptVersion falls through to the + // interface default, latestVersion, which moves the instant saveVersion runs. If the runner + // ever read the baseline after stamping the current declaration into history, this store + // would hand back the declaration that write just made current, the declared-vs-previous + // diff would compare that against itself, and the lossy change below would go uncaught. + val forwardingStore = DefaultForwardingVersionStore() + val previousVersion = previousVersionWithPersonAge() + forwardingStore.saveVersion(previousVersion) + val mentioning = propositionStore.save(proposition("Alice is 40", "Person")) + val runner = buildRunner(versionStore = forwardingStore) + + val result = runner.run(dryRun = false) + + assertEquals( + 1, + result.quarantinedCount, + "the declared property removal must still reach quarantine, proving the baseline was " + + "read before this run's own stamp overwrote what latestVersion answers", + ) + assertEquals(PropositionStatus.STALE, propositionStore.findById(mentioning.id)!!.status) + } + + @Test + fun `establishing the baseline on the first live check means a later identical declaration finds nothing new`() { + // No prior sweep exists for this schema, so sweptVersion is null and the declared-vs- + // previous comparison doesn't run at all on the first check — it must not throw, and it + // must not just happen to find nothing because it never looked: the second run below + // proves the first run actually established a baseline, not merely that it stayed silent. + propositionStore.save(proposition("Alice is a person", "Person")) + val runner = buildRunner() + + val first = runner.run(dryRun = false) + + assertEquals(0, first.quarantinedCount, "nothing to compare the very first check against") + assertEquals( + declaredVersion(), + versionStore.sweptVersion(schemaName), + "completing the first live check must establish the baseline for the next one", + ) + + val second = runner.run(dryRun = false) + + assertEquals( + 0, + second.quarantinedCount, + "reading the baseline after it was overwritten, or never establishing it, could each " + + "produce a wrong non-zero result here just as easily as the correct zero", + ) + } + + @Test + fun `a purely additive declared change does not quarantine`() { + val previousVersion = MetamodelVersion( + schemaName = schemaName, + entityTypeNames = listOf("Person"), + entityTypeLabels = mapOf("Person" to setOf("Person")), + entityTypeProperties = mapOf("Person" to emptySet()), + relationshipNames = emptyList(), + ) + versionStore.markSwept(previousVersion) + // declaredEntityTypes defaults to Person, Company — an added type versus previousVersion. + propositionStore.save(proposition("Alice is a person", "Person")) + val runner = buildRunner() + + val result = runner.run(dryRun = false) + + assertEquals(0, result.quarantinedCount, "a purely additive declared change is not lossy") + } + + @Test + fun `a dry run does not consume a lossy declared change -- the next live run still catches it`() { + val previousVersion = previousVersionWithPersonAge() + versionStore.markSwept(previousVersion) + val mentioning = propositionStore.save(proposition("Alice is 40", "Person")) + val runner = buildRunner() + + val dry = runner.run(dryRun = true) + + assertEquals(0, dry.quarantinedCount, "sanity: a dry run never quarantines") + assertEquals( + previousVersion, + versionStore.sweptVersion(schemaName), + "a dry run only read the baseline; it must not retire it", + ) + assertEquals(PropositionStatus.ACTIVE, propositionStore.findById(mentioning.id)!!.status) + + val live = runner.run(dryRun = false) + + assertEquals(1, live.quarantinedCount, "the lossy declared change must still reach quarantine") + assertEquals(PropositionStatus.STALE, propositionStore.findById(mentioning.id)!!.status) + } + + @Test + fun `a context-scoped live run does not retire the baseline, so a later run still reaches other contexts`() { + val previousVersion = previousVersionWithPersonAge() + versionStore.markSwept(previousVersion) + val inA = propositionStore.save(proposition("Alice is 40", "Person", inContext = contextId)) + val inB = propositionStore.save(proposition("Bob is 50", "Person", inContext = otherContextId)) + val runner = buildRunner() + + val scoped = runner.run(dryRun = false, contextId = contextId) + + assertEquals(1, scoped.quarantinedCount, "context A's candidate is reachable straight away") + assertEquals(PropositionStatus.STALE, propositionStore.findById(inA.id)!!.status) + assertEquals( + PropositionStatus.ACTIVE, + propositionStore.findById(inB.id)!!.status, + "sanity: the scoped run never touched context B", + ) + assertEquals( + previousVersion, + versionStore.sweptVersion(schemaName), + "a run scoped to one context must not retire the schema-wide baseline", + ) + + val later = runner.run(dryRun = false, contextId = otherContextId) + + assertEquals(1, later.quarantinedCount, "the same declared-vs-previous drift is still there for B") + assertEquals(PropositionStatus.STALE, propositionStore.findById(inB.id)!!.status) + } + + @Test + fun `a crash mid-sweep leaves the baseline unmoved, so the next check retries the same comparison`() { + val previousVersion = previousVersionWithPersonAge() + versionStore.markSwept(previousVersion) + val mentioning = propositionStore.save(proposition("Alice is 40", "Person")) + val crashingStore = object : PropositionStore by propositionStore { + override fun save(proposition: Proposition): Proposition = + throw IllegalStateException("simulated crash mid-sweep") + } + val crashingRunner = buildRunner(store = crashingStore) + + assertThrows(IllegalStateException::class.java) { crashingRunner.run(dryRun = false) } + + assertEquals( + previousVersion, + versionStore.sweptVersion(schemaName), + "an interrupted sweep must not look like a completed reconciliation", + ) + assertEquals( + PropositionStatus.ACTIVE, + propositionStore.findById(mentioning.id)!!.status, + "sanity: the crashing save never actually landed", + ) + + // The retry: a fresh runner over the same stores, this time able to actually save. Nothing + // about the earlier crash should have consumed or altered the comparison it interrupted. + val retryRunner = buildRunner(store = propositionStore) + + val retried = retryRunner.run(dryRun = false) + + assertEquals(1, retried.quarantinedCount, "the retry must still catch the same lossy change") + assertEquals(PropositionStatus.STALE, propositionStore.findById(mentioning.id)!!.status) + assertEquals( + declaredVersion(), + versionStore.sweptVersion(schemaName), + "the retry's own completed sweep is what finally advances the baseline", + ) + } + + @Test + fun `a declaration reverted to an earlier stamp is still diffed against what was actually swept`() { + // A (with `age`) -> B (without, swept) -> A again (age restored, saved but never swept) -> + // B declared again. latestVersion answers B the whole way through, because re-saving A + // keeps its original write-order position (MetamodelVersionStore's own saveVersion + // contract), so a runner trusting it would diff B against B at the last step and miss that + // `age` just vanished again. sweptVersion must not make that mistake. + val a = previousVersionWithPersonAge() + val b = MetamodelVersion( + schemaName = schemaName, + entityTypeNames = listOf("Person", "Company"), + entityTypeLabels = mapOf("Person" to setOf("Person"), "Company" to setOf("Company")), + entityTypeProperties = mapOf("Person" to emptySet(), "Company" to emptySet()), + relationshipNames = declaredRelationshipTypeNames.map { "Person-[$it]->Company" }, + ) + versionStore.markSwept(a) + versionStore.markSwept(b) + versionStore.saveVersion(a) // re-save only -- not a sweep + assertEquals(b, versionStore.latestVersion(schemaName), "sanity: latestVersion still answers B") + assertEquals(b, versionStore.sweptVersion(schemaName), "sanity: B is still the reconciled baseline") + + versionStore.markSwept(a) + assertEquals( + a, + versionStore.sweptVersion(schemaName), + "sweptVersion tracks the pointer, not write order -- unlike latestVersion above", + ) + + // The schema drops `age` again (declares B's shape again). Diffing against sweptVersion + // (A) catches the reversion; diffing against latestVersion (B, unchanged since the last + // markSwept(b) two lines up) would compare B against B and find nothing. + declaredEntityTypeProperties = mapOf("Person" to emptySet(), "Company" to emptySet()) + val mentioning = propositionStore.save(proposition("Alice is 40", "Person")) + val runner = buildRunner() + + val result = runner.run(dryRun = false) + + assertEquals(1, result.quarantinedCount, "the reverted removal of `age` must be caught") + assertEquals(PropositionStatus.STALE, propositionStore.findById(mentioning.id)!!.status) + } + + @Test + fun `a proposition already STALE from decay emits no status-changed event when quarantined`() { + // The idempotency check only skips a proposition that's already quarantined (STALE with a + // reason); one that's STALE from ordinary decay, with no reason yet, is still a fresh + // candidate and does get quarantined -- but its status doesn't move, so no event should say + // it did. + val previousVersion = previousVersionWithPersonAge() + versionStore.markSwept(previousVersion) + val decayed = propositionStore.save( + proposition("Alice is 40", "Person").withStatus(PropositionStatus.STALE), + ) + val recording = RecordingDiceEventListener() + val runner = buildRunner(listener = recording) + + val result = runner.run(dryRun = false) + + assertEquals(1, result.quarantinedCount, "sanity: it was quarantined") + val quarantined = propositionStore.findById(decayed.id)!! + assertEquals(PropositionStatus.STALE, quarantined.status) + assertNotNull( + quarantined.metadata[DiceMetadataKeys.QUARANTINE_REASON], + "sanity: the reason was written even though the status didn't move", + ) + assertTrue( + recording.events.isEmpty(), + "previousStatus and newStatus are both STALE -- nothing actually transitioned", + ) + } + + @Test + fun `the merged diff keeps every removed type in one sorted block, ahead of other declared changes`() { + // Two removals from EACH source, interleaved alphabetically, so a merge that only + // concatenates per-source contributions -- synthetic removals, then declared ones, each in + // whatever order they arrived, without sorting the union as a whole -- would produce A, N, + // M, B or some other source-grouped order that happens to look plausible but isn't the one + // sorted run MetamodelDiff promises. "M" and "B" are declared-vs-previous (dropped from the + // declaration outright, filed as declared EntityTypeRemoved); "A" and "N" are + // observed-vs-declared (synthetic EntityTypeRemoved, never declared by either version). Z + // survives with a lost property (a declared EntityTypeModified, filed under "Z"). The only + // block ordering that survives both a true union-sort AND a naive per-source concatenation + // for two of these four names is indistinguishable from a bug; asserting the complete, + // alphabetically interleaved list -- A, B, M, N, then Z's modification -- is what makes the + // two indistinguishable orderings actually distinguishable. + declaredEntityTypes = listOf("Person", "Company", "Z") + val previousVersion = MetamodelVersion( + schemaName = schemaName, + entityTypeNames = declaredEntityTypes + listOf("M", "B"), + entityTypeLabels = (declaredEntityTypes + listOf("M", "B")).associateWith { setOf(it) }, + entityTypeProperties = mapOf( + "Person" to emptySet(), + "Company" to emptySet(), + "Z" to setOf(PropertySignature("p", PropertySignature.Kind.VALUE, "string", Cardinality.ONE)), + "M" to emptySet(), + "B" to emptySet(), + ), + relationshipNames = declaredRelationshipTypeNames.map { "Person-[$it]->Company" }, + ) + versionStore.markSwept(previousVersion) + // Current declaration: Z loses "p"; "M" and "B" are dropped outright (declared-vs-previous + // removals). + declaredEntityTypeProperties = declaredEntityTypes.associateWith { emptySet() } + observedEntityTypes = setOf("Person", "Company", "Z", "A", "N") // "A", "N" are undeclared drift + val recording = RecordingDriftQuarantinePolicy() + val runner = buildRunner(quarantinePolicy = recording) + + runner.run(dryRun = false) + + val changes = recording.lastDiff!!.changes + val zModification = changes.single { it !is MetamodelChange.EntityTypeRemoved } + assertEquals( + listOf( + MetamodelChange.EntityTypeRemoved("A"), + MetamodelChange.EntityTypeRemoved("B"), + MetamodelChange.EntityTypeRemoved("M"), + MetamodelChange.EntityTypeRemoved("N"), + zModification, + ), + changes, + "the removed-type block must merge both sources into one alphabetically sorted run, " + + "ahead of Z's modification, not a per-source grouping that happens to look sorted: $changes", + ) + } + // ---- Label closure ---- @Test @@ -421,6 +842,54 @@ class DriftCheckRunnerTest { ) } + /** + * Wraps a real [DriftQuarantinePolicy] and remembers the last [MetamodelDiff] it was asked to + * evaluate, so a test can inspect the diff the runner actually built and merged directly, + * instead of inferring its shape from quarantine outcomes alone. + */ + private class RecordingDriftQuarantinePolicy( + private val delegate: DriftQuarantinePolicy = MentionTypeDriftQuarantinePolicy(), + ) : DriftQuarantinePolicy { + var lastDiff: MetamodelDiff? = null + private set + + override fun evaluate(diff: MetamodelDiff, propositions: Iterable): QuarantineResult { + lastDiff = diff + return delegate.evaluate(diff, propositions) + } + } + + /** + * Implements only the three original [MetamodelVersionStore] members, so `sweptVersion` and + * `markSwept` fall through to the interface defaults -- `sweptVersion` answering `latestVersion`, + * which moves on every [saveVersion]. Unlike [InMemoryMetamodelVersionStore] (which every other + * test in this class uses, and which tracks the reconciled baseline independently), a store built + * this way is exactly what exposes a read-before-save ordering bug: reading the baseline after the + * current run's own history write would read back the stamp that write just made current. + */ + private class DefaultForwardingVersionStore : MetamodelVersionStore { + private val versions = mutableListOf() + + override fun saveVersion(version: MetamodelVersion) { + versions.removeIf { it.schemaName == version.schemaName && it.contentHash == version.contentHash } + versions.add(0, version) + } + + override fun latestVersion(schemaName: String): MetamodelVersion? = + versions.firstOrNull { it.schemaName == schemaName } + + override fun versionHistory(schemaName: String): List = + versions.filter { it.schemaName == schemaName } + } + + /** Captures every event handed to it, in order, so a test can assert on what the runner emits. */ + private class RecordingDiceEventListener : DiceEventListener { + val events = mutableListOf() + override fun onEvent(event: DiceEvent) { + events += event + } + } + /** * Records which candidate-read the runner called, so a test can assert the scoped or global read * path directly rather than inferring it from a side effect. Everything else is delegated diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt index e803703c..bf37086f 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt @@ -56,6 +56,7 @@ class DriftQuarantinePolicyTest { text: String, vararg mentionTypes: String, status: PropositionStatus = PropositionStatus.ACTIVE, + pinned: Boolean = false, ): Proposition = Proposition( contextId = contextId, text = text, @@ -63,7 +64,7 @@ class DriftQuarantinePolicyTest { EntityMention(span = type.lowercase(), type = type, role = MentionRole.SUBJECT) }, confidence = 0.9, - ).withStatus(status) + ).withStatus(status).withPinned(pinned) private fun reasonOf(decision: QuarantineDecision.Quarantined): String = decision.proposition.metadata[DiceMetadataKeys.QUARANTINE_REASON] as String @@ -1064,6 +1065,82 @@ class DriftQuarantinePolicyTest { assertEquals(PropositionStatus.ACTIVE, result.conforming.single().proposition.status) } } + + /** + * DICE promises pinned propositions cross-cutting immunity from reclamation (see + * `PropositionStore.pin`): the decay collector, the sweep policy, and contradiction resolution + * all leave them alone. Drift quarantine is another reclamation path and must honor the same + * promise: a pinned proposition stays untouched, never flipped to STALE. + */ + @Nested + inner class PinnedImmunity { + + @Test + fun `a pinned proposition mentioning a removed type is reported protected, not quarantined`() { + val diff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) + val pinned = proposition("legacy pinned fact", "RemovedType", pinned = true) + + val result = policy.evaluate(diff, listOf(pinned)) + + assertEquals(0, result.quarantined.size, "a pinned match must never be flipped to STALE") + assertEquals(1, result.protected.size) + val decision = result.protected.single() + assertEquals(PropositionStatus.ACTIVE, decision.proposition.status, "pin means untouched") + assertTrue(decision.affectedMentionTypes.contains("RemovedType")) + assertTrue(decision.reason.contains("RemovedType"), "the reason should still name what triggered it") + assertNull( + decision.proposition.metadata[DiceMetadataKeys.QUARANTINE_REASON], + "unlike an actual quarantine, the proposition itself carries no reason metadata", + ) + assertEquals(1, result.total, "protected propositions still count toward the sweep total") + assertTrue(result.allPropositions.contains(decision.proposition)) + } + + @Test + fun `a pinned proposition with nothing lossy still conforms`() { + val diff = differ.diff(schemaWith("Person", "Company"), schemaWith("Person", "Company")) + + val result = policy.evaluate(diff, listOf(proposition("Alice at Acme", "Person", pinned = true))) + + assertEquals(1, result.conforming.size) + assertEquals(0, result.protected.size, "protected is only for pins that would otherwise be caught") + } + + @Test + fun `an unpinned proposition next to a pinned one is still quarantined normally`() { + val diff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) + + val result = policy.evaluate( + diff, + listOf( + proposition("pinned", "RemovedType", pinned = true), + proposition("unpinned", "RemovedType"), + ), + ) + + assertEquals(1, result.protected.size) + assertEquals(1, result.quarantined.size) + assertEquals(PropositionStatus.STALE, result.quarantined.single().proposition.status) + } + + @Test + fun `a pinned proposition an earlier sweep already quarantined stays already-quarantined`() { + // Pin immunity only changes what a *fresh* match does. A proposition that is already + // STALE with a quarantine reason — however it got pinned since — is idempotency's + // concern, not this one's, and must not silently become Protected. + val diff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) + val stale = policy + .evaluate(diff, listOf(proposition("entity with removed type", "RemovedType"))) + .quarantined.single().proposition + .withPinned(true) + + val result = policy.evaluate(diff, listOf(stale)) + + assertEquals(1, result.alreadyQuarantined.size) + assertEquals(0, result.protected.size) + assertEquals(0, result.quarantined.size) + } + } } /** diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt index 62a59637..a4f6ff27 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt @@ -30,6 +30,10 @@ internal class InMemoryMetamodelVersionStore : MetamodelVersionStore { var saveCount: Int = 0 private set + // The reconciled-baseline pointer, tracked apart from `versions`' write order -- see + // MetamodelVersionStore.sweptVersion's doc for why this can't be answered off `latestVersion`. + private val swept = mutableMapOf() + override fun saveVersion(version: MetamodelVersion) { saveCount++ val alreadyStored = versions.any { @@ -45,6 +49,13 @@ internal class InMemoryMetamodelVersionStore : MetamodelVersionStore { override fun versionHistory(schemaName: String): List = versions.filter { it.schemaName == schemaName } + + override fun markSwept(version: MetamodelVersion) { + saveVersion(version) + swept[version.schemaName] = version + } + + override fun sweptVersion(schemaName: String): MetamodelVersion? = swept[schemaName] } /** diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionStoreTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionStoreTest.kt index 15cf86ce..37ccd4fe 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionStoreTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionStoreTest.kt @@ -18,12 +18,15 @@ package com.embabel.dice.metamodel import com.embabel.agent.core.DataDictionary import com.embabel.agent.core.DynamicType import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test /** - * Covers the one piece of behaviour the contract itself ships: the default [findVersion], which a - * backend is free to override with a keyed lookup. A store implementation gets its own tests - * wherever it lives. + * Covers behaviour the contract itself ships, independent of any specific backend: the default + * [findVersion], which a backend is free to override with a keyed lookup, and the + * [MetamodelVersionStore.sweptVersion] / [MetamodelVersionStore.markSwept] pointer, both the + * defaults and [InMemoryMetamodelVersionStore]'s independent tracking of it. A store implementation + * gets its own tests wherever it lives. * * The upsert rules [MetamodelVersionStore.saveVersion] states are checked by * `AbstractMetamodelVersionStoreContractTest`, which runs the same suite against @@ -87,4 +90,118 @@ class MetamodelVersionStoreTest { assertEquals(emptyList(), store.versionHistory("app")) assertNull(store.findVersion("app", "anything")) } + + /** + * [MetamodelVersionStore.sweptVersion] tracks a different fact than [MetamodelVersionStore + * .latestVersion]: which declaration the last completed live sweep actually reconciled against, + * not which stamp arrived most recently in write order. [InMemoryMetamodelVersionStore] tracks + * it independently; the interface default (covered separately below) does not. + */ + @Nested + inner class SweptVersion { + + @Test + fun `nothing has ever been swept for a schema no one has marked`() { + val store = InMemoryMetamodelVersionStore() + store.saveVersion(version("app", "Person")) + + assertNull(store.sweptVersion("app"), "a plain stamp save is not a completed sweep") + } + + @Test + fun `markSwept records the version sweptVersion then answers`() { + val store = InMemoryMetamodelVersionStore() + val v = version("app", "Person") + + store.markSwept(v) + + assertEquals(v, store.sweptVersion("app")) + } + + @Test + fun `markSwept also saves the version into ordinary history`() { + val store = InMemoryMetamodelVersionStore() + val v = version("app", "Person") + + store.markSwept(v) + + assertEquals(v, store.latestVersion("app"), "a swept version is a real stamp too") + assertEquals(listOf(v), store.versionHistory("app")) + } + + @Test + fun `sweptVersion is scoped to its schema name`() { + val store = InMemoryMetamodelVersionStore() + store.markSwept(version("mine", "Person")) + + assertNotNull(store.sweptVersion("mine")) + assertNull(store.sweptVersion("yours")) + } + + @Test + fun `sweptVersion tracks the reconciled pointer, not write order, across a reverted declaration`() { + // A -> B (swept) -> A again (only re-saved, never re-swept) -> A swept for real. + // latestVersion answers B the whole way through, because re-saving A keeps its + // original write-order position; sweptVersion must answer A once markSwept says so. + val store = InMemoryMetamodelVersionStore() + val a = version("app", "Person") + val b = version("app", "Person", "Company") + store.markSwept(a) + store.markSwept(b) + store.saveVersion(a) + + assertEquals(b, store.latestVersion("app"), "sanity: re-saving A doesn't move latestVersion") + assertEquals(b, store.sweptVersion("app"), "sanity: B is still the reconciled baseline") + + store.markSwept(a) + + assertEquals(a, store.sweptVersion("app"), "the pointer moved to what was actually swept") + assertEquals(b, store.latestVersion("app"), "latestVersion is unaffected -- a different question") + } + } + + /** + * The interface defaults exist so a store that doesn't override [MetamodelVersionStore + * .sweptVersion]/[MetamodelVersionStore.markSwept] still compiles and behaves like the runner + * did before those methods existed -- not a placeholder pretending the gap they describe is + * closed. + */ + @Nested + inner class InterfaceDefaults { + + @Test + fun `sweptVersion defaults to latestVersion`() { + val store = MinimalStore() + val v = version("app", "Person") + store.saveVersion(v) + + assertEquals(store.latestVersion("app"), store.sweptVersion("app")) + } + + @Test + fun `markSwept defaults to an ordinary saveVersion`() { + val store = MinimalStore() + val v = version("app", "Person") + + store.markSwept(v) + + assertEquals(v, store.latestVersion("app"), "the default's only effect is the ordinary save") + } + } + + /** Implements only the four original members; everything else comes from the interface. */ + private class MinimalStore : MetamodelVersionStore { + private val versions = mutableListOf() + + override fun saveVersion(version: MetamodelVersion) { + versions.removeIf { it.schemaName == version.schemaName && it.contentHash == version.contentHash } + versions.add(0, version) + } + + override fun latestVersion(schemaName: String): MetamodelVersion? = + versions.firstOrNull { it.schemaName == schemaName } + + override fun versionHistory(schemaName: String): List = + versions.filter { it.schemaName == schemaName } + } } diff --git a/docs/design/metamodel-drift.md b/docs/design/metamodel-drift.md index d1758946..a77d82a4 100644 --- a/docs/design/metamodel-drift.md +++ b/docs/design/metamodel-drift.md @@ -17,28 +17,39 @@ sequenceDiagram participant Versions as MetamodelVersionStore participant Observed as ObservedSchemaSource participant Differ as DeclaredObservedDiffer + participant MetamodelDiffer as MetamodelDiffer participant Reports as DriftReportStore participant Policy as DriftQuarantinePolicy participant Props as PropositionRepository + participant Listener as DiceEventListener Caller->>Runner: run(dryRun, contextId) Runner->>Declared: declare() Declared-->>Runner: DeclaredSchema (stamp + bare rel names) + Runner->>Versions: sweptVersion(schemaName) + Versions-->>Runner: reconciled baseline, or null if no sweep has completed yet Runner->>Versions: saveVersion(stamp) - Note over Runner,Versions: saved before the report, so the
report's hash always resolves later + Note over Runner,Versions: history write, every run, dry or live;
on a store without independent tracking, moves the
reconciled baseline too, but only when the stamp is new Runner->>Observed: observe(contextId) Observed-->>Runner: ObservedSchema (labels + rel types, one instant) Runner->>Differ: diffAgainstObserved(declared, observed) Differ-->>Runner: DeclaredObservedDiff (drifted vs unobserved) + Runner->>MetamodelDiffer: diff(baseline, current) — only when a baseline exists + MetamodelDiffer-->>Runner: declared-vs-previous MetamodelDiff Runner->>Reports: saveDriftReport(report) Note over Runner,Reports: written on every run, including
checks that find nothing - alt live run and entity-type drift + alt live run and (entity-type observed drift or a non-empty declared-vs-previous diff) Runner->>Props: candidates (scoped or all) - Runner->>Policy: evaluate(diff, candidates) - Policy-->>Runner: STALE copies + reasons + Runner->>Policy: evaluate(merged diff, candidates) + Policy-->>Runner: STALE copies + reasons, pinned matches reported protected Runner->>Props: save(each quarantined copy) - else dry run, or no entity-type drift - Note over Runner: nothing is touched + Runner->>Listener: onEvent(PropositionStatusChanged), skipped if status didn't move + else dry run, or relationship-only/no drift from either comparison + Note over Runner: nothing is touched, nothing is emitted + end + alt live run AND unscoped (contextId is null) + Runner->>Versions: markSwept(stamp) + Note over Runner,Versions: on a store with independent tracking, this is
the moment the reconciled baseline advances; a default-forwarding
store's baseline follows write order instead — a new stamp moved it
back at saveVersion, a re-saved stamp never moves it end Runner-->>Caller: DriftCheckResult ``` @@ -82,6 +93,10 @@ an unchanged schema re-saves onto its own key and stores nothing new. Stamping a when the schema changed, would leave the first check after a schema change pointing at a hash nothing recorded. +This is a history write only. It says nothing about which declaration quarantine should diff +against next — that's a separate, deliberately narrower pointer, covered under +[Two sources of drift](#two-sources-of-drift). + ## What counts as drift The comparison itself is [`DeclaredObservedDiffer`](metamodel-diff.md), and it is asymmetric on @@ -118,9 +133,69 @@ to go into the query, so every backend writes all three. ## Quarantine -A live run hands the drifted types to a `DriftQuarantinePolicy`. The shipped one, -`MentionTypeDriftQuarantinePolicy`, quarantines a proposition when one of its entity mentions names a -type a **lossy** change touched: +A live run hands a `DriftQuarantinePolicy` a single merged `MetamodelDiff` built from two independent +comparisons, and quarantines whatever the policy flags in either one. + +### Two sources of drift + +**Declared vs. observed** (`DeclaredObservedDiffer`, described above) catches a type the live graph +holds that this declaration doesn't recognise — the drift a `DriftReport` records. On its own, this +comparison is blind to a change that never shows up in the graph: a property the declaration quietly +narrowed or dropped on a type the graph and the declaration still agree the name of. Nothing about +that change is observed drift, because nothing about the *type* is undeclared — only its shape moved. + +**Declared vs. previous declared** (`MetamodelDiffer`) closes that gap. Before its own history write, +the runner reads `MetamodelVersionStore.sweptVersion` for this schema — the declaration the *last +completed live, unscoped sweep* reconciled against — and diffs it against the current declaration with the same +kind of comparison [metamodel-diff.md](metamodel-diff.md) describes for comparing any two versions. +Whatever moved — a removed property, a narrowed cardinality, a whole type dropped — reaches the +policy exactly like an observed removal does, because it becomes the same `MetamodelChange` entries +the policy already knows how to judge. There is no baseline on a schema's first-ever check, so this +half doesn't run at all. + +The two comparisons are merged into one diff before the policy sees it, evaluated once — never as two +separate sweeps that could each make an independent call about the same proposition. `DriftReport` +itself is unaffected by this merge: it still records only declared-vs-observed drift, which is the +graph-truth signal — "the graph holds something nobody declared" — an operator watching the log +wants; the declared-vs-previous comparison exists to feed quarantine, not to duplicate the report. + +#### The baseline only moves once a sweep finishes + +`sweptVersion` is a pointer to one reconciled declaration per schema, tracked apart from the ordinary +stamp history above, and it advances only when `DefaultDriftCheckRunner.run()` calls +`MetamodelVersionStore.markSwept` — the very last thing it does, and only for a **live, unscoped** +run. The three cases below only hold for a store that overrides `sweptVersion`/`markSwept` with +genuinely independent tracking, such as `InMemoryMetamodelVersionStore`. A store that doesn't +override them inherits the interface default, `sweptVersion` answering `latestVersion` — see that +method's doc on `MetamodelVersionStore` for how much of the runner's care this reopens. + +- A **dry run** never calls `markSwept`. It still reads `sweptVersion` and computes the + declared-vs-previous diff, but throws the result away without acting on it — `DriftReport.hasDrift` + comes only from the observed-vs-declared comparison, so a dry run cannot preview what a live run + would quarantine from the declared-vs-previous side. This is a known limitation, not an oversight: + a dry run can report `hasDrift = false` and `quarantinedCount = 0` while the very next live run, + same declaration, finds and quarantines a lossy declared change. Treating a dry run as having + reconciled the schema would make this worse — a live run right after would compare the declaration + against itself and find nothing at all — so `run()` with no arguments stays a check that reports + and changes nothing, including this pointer, at the cost of not being a reliable preview of + declared-vs-previous quarantine. +- A run **scoped to one context** still computes and acts on the declared-vs-previous diff for that + context's own candidates, but leaves the schema-wide baseline where it was. Advancing it after a + scoped sweep would tell every other context's later check "this declaration is already + reconciled," when only one context's candidates were ever looked at. +- A **crash between the history write and the end of the sweep** leaves `markSwept` uncalled, so the + next check — whenever it runs — sees the same unreconciled baseline and retries the same + comparison. The already-quarantined bucket makes that retry safe: anything the interrupted run did + manage to save comes back as already handled, not re-flagged. + +`sweptVersion` is a different question from `MetamodelVersionStore.latestVersion`, which the store's +own doc covers in detail: `latestVersion` tracks write order and answers wrong once a declaration +cycles back to a stamp it already used before. + +### Lossy changes + +The shipped policy, `MentionTypeDriftQuarantinePolicy`, quarantines a proposition when one of its +entity mentions names a type a **lossy** change touched, wherever that change came from: | Change | Lossy? | | --- | --- | @@ -248,7 +323,7 @@ quietly emptying the list. Swap in a different `DriftQuarantinePolicy` if your storage makes more promotions provably safe. -Two properties make this safe to run as routine maintenance: +Three properties make this safe to run as routine maintenance: - **Non-destructive.** Nothing is deleted and nothing is mutated. An affected proposition comes back as an immutable copy moved to `STALE`, annotated with a human-readable reason under @@ -262,15 +337,48 @@ Two properties make this safe to run as routine maintenance: depend on the diff in front of it: being already quarantined is a fact about the proposition, so an empty or purely additive diff still sorts one into `alreadyQuarantined`. Skipping the check on an empty diff would report quarantined records as conforming on every run that finds no drift. - -The policy decides and doesn't write. The `STALE` copies come back to the caller, and the runner -persists them, which is how a dry run produces the same decisions while changing nothing. +- **Respects pinning.** A pinned proposition a lossy change would otherwise catch is left exactly as + it was and reported in its own `protected` bucket. Pinning is DICE's cross-cutting promise that a + proposition resists reclamation — the decay collector, the sweep policy and contradiction + resolution already honor it — and quarantine is one more reclamation path that has to keep the same + promise. A proposition an earlier sweep already + quarantined before it was pinned is unaffected: idempotency is checked first, so it stays + `alreadyQuarantined`. + +The policy decides and doesn't write. On a live run, the `STALE` copies it returns come back to the +caller, and the runner persists them. A dry run never calls `evaluate` at all, so there is no policy +decision to persist. See "The baseline only moves once a sweep finishes" above for what a dry run +does and doesn't do. The runner reads and writes those propositions through `PropositionStore`, the base persistence port, rather than `PropositionRepository`. A drift check only reads by context or in bulk and saves; requiring vector search, graph traversal and temporal query alongside would shut a plain store-and-retrieve backend out of drift checking over capabilities it never uses. +### Announcing a quarantine + +Each proposition the runner actually quarantines is announced to a `DiceEventListener` as a +`PropositionStatusChanged` (`previousStatus` the status it carried in, `newStatus` `STALE`, `reason` +the same text the metadata carries), right after it is saved. This is what lets something like +`ProjectionLineageStaleCascade` hear that a proposition went stale and mark its projection records +stale in turn. + +A proposition can arrive at the sweep already `STALE` from ordinary decay, with no quarantine reason +yet, and the policy correctly treats that as a fresh candidate — the idempotency rule only skips one +that's *already quarantined*, not one that's merely stale for some other reason. Quarantining it +writes the reason but doesn't move its status, so no event fires for it: the event promises a +transition happened, and here one didn't. + +The runner emits this itself. The injected `PropositionStore` is never asked to notice the +transition and emit it on its own — the way `EventEmittingPropositionRepository` does when an +application chooses to wrap its repository in one — because that would make the signal conditional +on a wiring choice made somewhere else entirely, and silently absent for an application that wires a +plain, undecorated store, which is what auto-configuration hands out by default. Emitting the event +from inside the runner, the same way `DefaultCollectorRunner` already emits its own transitions, +means the signal fires wherever the runner runs, independent of what store backs it. `listener` +defaults to a no-op, so nothing about the rest of this section changes for a caller who isn't +listening. + ## Prior art SHACL is the model for the report half, and it is already described above: validate data that exists, @@ -312,8 +420,8 @@ If it is ever wanted, the shape that would be safe: global switch; - **additive only**, and refused for anything that removes or reshapes; - **capped** per run, so a bad extraction batch can't rewrite a schema wholesale; -- **provenance-recorded**, with `StampProvenance.trigger` naming the check that caused the stamp, so - the history says which stamps a machine wrote. +- **provenance-recorded**, with the stamp itself naming the check that caused it, so the history + says which stamps a machine wrote and which a person did. ## Scope @@ -325,14 +433,17 @@ in that same context. Pass `null` and the check covers the whole graph. ## Using it ```kotlin +val differ = StructuralMetamodelDiffer() // implements both differ interfaces below val runner = DefaultDriftCheckRunner( declaredSchemaSource = { DeclaredSchema.from(dataDictionary, governed) }, versionStore = versionStore, observedSchemaSource = observedSchemaSource, - differ = StructuralMetamodelDiffer(), + differ = differ, + metamodelDiffer = differ, driftReportStore = driftReportStore, quarantinePolicy = MentionTypeDriftQuarantinePolicy(), propositionStore = propositionStore, + listener = SafeDiceEventListener(projectionLineageStaleCascade), // optional; defaults to a no-op ) // The default: dry, whole graph. Reports, changes nothing. From 12bad6e9f185f816fb85c1b394a9e1a8935834a6 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:01:21 -0400 Subject: [PATCH 05/11] Back live drift mode off to a deliberate sweep SPI The runner now evaluates and reports only: run() stamps the declared version, writes the report, and touches no proposition and no swept baseline. Sweeping is a host call through PropositionStoreDriftSweep, whose candidate selection is bounded and ContextId-scoped, closing the findAll path that materialized every tenant. Quarantine records the prior status in metadata and release restores it while clearing the reason, so a quarantine is reversible. sweptVersion and markSwept move to SweptBaselineStore with no default bodies: saving a version never implies a completed sweep, and a store that cannot track the baseline lacks the surface to claim one. Reports carry declaredDiff, so a dry report shows the facts a deliberate sweep would act on. The quarantine policy matches declared types by own label, consistent with the differ. --- CHANGELOG.md | 160 ++-- .../dice/metamodel/DriftCheckRunner.kt | 123 +-- .../dice/metamodel/DriftQuarantinePolicy.kt | 66 +- .../com/embabel/dice/metamodel/DriftReport.kt | 67 +- .../dice/metamodel/DriftSweepCapable.kt | 237 ++++++ .../InMemoryMetamodelVersionStore.kt | 22 +- .../dice/metamodel/MetamodelVersionStore.kt | 76 +- .../support/DefaultDriftCheckRunner.kt | 234 ++---- .../MentionTypeDriftQuarantinePolicy.kt | 222 +++-- .../support/PropositionStoreDriftSweep.kt | 161 ++++ .../dice/metamodel/DriftCheckRunnerTest.kt | 763 ++++++------------ .../metamodel/DriftQuarantinePolicyTest.kt | 230 ++++++ .../embabel/dice/metamodel/DriftReportTest.kt | 117 +++ .../embabel/dice/metamodel/DriftSweepTest.kt | 746 +++++++++++++++++ .../dice/metamodel/InMemoryMetamodelStores.kt | 9 +- .../metamodel/MetamodelVersionStoreTest.kt | 61 +- docs/design/metamodel-drift.md | 312 ++++--- 17 files changed, 2526 insertions(+), 1080 deletions(-) create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftSweepCapable.kt create mode 100644 dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/PropositionStoreDriftSweep.kt create mode 100644 dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftSweepTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c3eb9d..9f0f1c0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,79 +172,103 @@ and the consumer PRs that deliver it). 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. -- Drift checking and quarantine contracts in `dice-metamodel`, plus the default runner. - `DriftCheckRunner` sequences one check: declare, stamp, observe, diff, report, and optionally - quarantine. It is dry-run by default, so `run()` persists a `DriftReport` and touches no - proposition. `DefaultDriftCheckRunner` stamps the declared version into the - `MetamodelVersionStore` on every run, before writing the report, so a report's `versionHash` - always resolves through `findVersion`; the write upserts on `(schemaName, contentHash)`, so an - unchanged schema costs one idempotent write. `DriftReportStore` is the durable log, kept separate - from the version store because stamps and reports have different volumes and lifetimes. Every - read on it is bounded: `driftReports`, `globalDriftReports` and `driftReportsInContext` each take - a `limit` and an optional `since`, and none has a default body, because filtering a limited page - down to one scope in memory applies the limit before the filter and can report zero drift while - plenty sits in the store. - A live run's quarantine candidates come from two independent comparisons merged into one diff: - declared-vs-observed (the `DriftReport` signal) and declared-vs-previous-declared, compared with a - `MetamodelDiffer` against `MetamodelVersionStore.sweptVersion` — a new pointer, tracked apart from - the ordinary stamp history, naming the declaration the *last completed live, unscoped* sweep - reconciled against. The second comparison is what lets a property removed, narrowed, or a whole - type dropped from the declaration itself reach quarantine even when the live graph and the new - declaration still agree on everything the graph currently holds — declared-vs-observed alone is - blind to that case, since nothing about the type is undeclared, only its shape moved. The pointer - only advances via the new `MetamodelVersionStore.markSwept`, called once, last, by a live run with - no `ContextId`: a dry run decides nothing so must not retire it, a scoped run only ever reconciles - one context so retiring it there would strand every other context, and a crash before that final - call leaves it exactly where it was, so the next check retries the same comparison and no - interrupted sweep is ever treated as finished. `sweptVersion`/`markSwept` default to - `latestVersion`/`saveVersion` on the interface, which is the pre-existing (buggy) behavior for a - store that doesn't override them — a real backend should track the pointer independently, the way - the new `InMemoryMetamodelVersionStore` override now does; `latestVersion` alone gets the wrong - answer once a schema's declaration cycles back to a stamp it already used (`A` → `B` → `A` leaves - `B` as the write-order latest, per `saveVersion`'s existing re-save contract, even though `A` is - what's declared again). Quarantine is non-destructive, idempotent, and honors pinning. - `DriftQuarantinePolicy` returns `QuarantineDecision`s (`Conforming` / `Quarantined` / - `AlreadyQuarantined` / `Protected`); only `Quarantined` is an immutable `STALE` copy carrying a - reason under `dice.metamodel.quarantine.reason` for the caller to persist — the other three carry - the proposition back untouched. A pinned proposition a lossy change would otherwise catch comes - back `Protected`, untouched, per DICE's cross-cutting pin promise, with the same reason text so an - operator can still see what it would have caught. The shipped `MentionTypeDriftQuarantinePolicy` - fires on lossy changes only: a removed type, a type that lost labels or properties, or a property - whose signature narrowed (type changed, value ↔ reference, or cardinality shrank along `ONE` ⊂ - `OPTIONAL` ⊂ `SET` ⊂ `LIST`). An inherited label observed in the graph counts as declared, so it - never quarantines. A `ContextId` scopes the observation, the candidate propositions and the - persisted report alike, so a mis-declared schema in one context cannot reach another's data. Each - proposition the sweep actually moves to `STALE` is announced to the runner's `DiceEventListener` as - a `PropositionStatusChanged`, emitted by the runner itself so the signal doesn't depend on whether +- Drift checking and quarantine contracts in `dice-metamodel`, plus the default runner and a + reference sweep. **A drift check reports and changes nothing.** `DriftCheckRunner` has one mode: + `run()` declares, stamps, observes, compares and persists a `DriftReport`, and holds no quarantine + policy and no proposition store, so no path through it can move a proposition or the swept + baseline. `DefaultDriftCheckRunner` stamps the declared version into the `MetamodelVersionStore` on + every run, before writing the report, so a report's `versionHash` always resolves through + `findVersion`; the write upserts on `(schemaName, contentHash)`, so an unchanged schema costs one + idempotent write, and it leaves the swept baseline alone. `DriftReportStore` is the durable log, + kept separate from the version store because stamps and reports have different volumes and + lifetimes. Every read on it is bounded: `driftReports`, `globalDriftReports` and + `driftReportsInContext` each take a `limit` and an optional `since`, and none has a default body, + because filtering a limited page down to one scope in memory applies the limit before the filter + and can report zero drift while plenty sits in the store. + **A report carries both halves of the comparison.** `DriftReport` records declared-vs-observed + drift (`driftedEntityTypes`, `driftedRelationshipTypes`) *and* `declaredDiff`, how the declaration + itself moved since the last completed sweep, compared with a `MetamodelDiffer` against + `SweptBaselineStore.sweptVersion`. The second comparison is what lets a property removed or + narrowed, or a whole type dropped from the declaration itself, show up even when the live graph and + the new declaration still agree on everything the graph currently holds — declared-vs-observed + alone is blind to that case, since nothing about the type is undeclared and only its shape moved. + `DriftReport.quarantineDiff(declaredVersion)` and `DriftCheckResult.quarantineDiff` merge the two + into the exact comparison a sweep evaluates, so a report can no longer read clean while a sweep on + the same state would quarantine. `hasDrift` keeps its narrow graph-truth meaning; the new + `hasAnyChange` answers "would a sweep find anything at all to look at?". + **Sweeping is a documented SPI a host invokes.** The new `DriftSweepCapable` defines + `quarantineCandidates(contextId, mentionTypes, limit, afterId)` — bounded by `limit`, confined to + one *required* `ContextId`, filtered on mention type by the backend, and ordered by proposition id + so `afterId` is a usable cursor, all three stated as contract requirements in its KDoc — + `applyQuarantine(decision)`, and `releaseFromQuarantine(propositionId)`, plus a defaulted `sweep` + that pages through the candidates and persists what the policy flags. A store implements it when + its backend can honour that, the way DICE's other opt-in store capabilities work; there is no + whole-graph read and no whole-graph sweep. The mention types come from the new + `DriftQuarantinePolicy.candidateMentionTypes(diff)`, so the store needs no policy knowledge of its + own, and the policy contract states the invariant that makes bounded selection sound: a proposition + whose mention types are all outside that set must evaluate to conforming. + `PropositionStoreDriftSweep` is the in-memory reference implementation over any `PropositionStore`; + it reads the one context and does the filter, ordering and page bound in the JVM, which is the part + a durable backend pushes down. There is no Drivine implementation, and nothing in DICE calls a + sweep on a timer or from auto-configuration. + **Release is a real operation.** `releaseFromQuarantine` restores the status a proposition carried + before quarantine and clears its quarantine metadata in one write. Clearing + `dice.metamodel.quarantine.reason` by hand left the proposition `STALE`, out of ordinary retrieval + with nothing saying why, and a fresh candidate for the next sweep. The status to restore comes from + the new `DriftQuarantineKeys.PREVIOUS_STATUS` (`dice.metamodel.quarantine.previousStatus`), which + the policy writes onto the `STALE` copy at quarantine time; a proposition with no readable value + there is released to `ACTIVE`. Releasing something that was never quarantined answers `null`, so + releasing twice is safe. + **The swept baseline moves only for a completed sweep.** `sweptVersion` and `markSwept` live on the + new `SweptBaselineStore : MetamodelVersionStore`, with no default bodies, and the host that ran the + sweep is what calls `markSwept` once every context is reconciled. Splitting them off is the fix for + a real hazard: a forwarding default answering `latestVersion` made every store look like it tracked + a baseline while answering with write order, so a check's own stamp, a scoped sweep, or a crashed + one each retired a change nothing had swept for. A store that implements nothing here now reports + `declaredDiff = null` and gets the graph-truth half alone, which is the honest answer; + `InMemoryMetamodelVersionStore` implements the capability with real swept-state semantics, and + `latestVersion` alone still gets the wrong answer once a schema's declaration cycles back to a stamp + it already used (`A` → `B` → `A` leaves `B` as the write-order latest, per `saveVersion`'s existing + re-save contract, even though `A` is what's declared again). + Quarantine itself is non-destructive, idempotent, and honors pinning. `DriftQuarantinePolicy` + returns `QuarantineDecision`s (`Conforming` / `Quarantined` / `AlreadyQuarantined` / `Protected`); + only `Quarantined` is an immutable `STALE` copy carrying a reason and the status it came from, for + the caller to persist — the other three carry the proposition back untouched. A pinned proposition + a lossy change would otherwise catch comes back `Protected`, untouched, per DICE's cross-cutting pin + promise, with the same reason text so an operator can still see what it would have caught. The + shipped `MentionTypeDriftQuarantinePolicy` fires on lossy changes only: a removed type, a type that + lost labels or properties, or a property whose signature narrowed (type changed, value ↔ reference, + or cardinality shrank along `ONE` ⊂ `OPTIONAL` ⊂ `SET` ⊂ `LIST`). An inherited label observed in + the graph counts as declared, so it never quarantines. **Type identity now matches on both + spellings of a declared name** — the name as declared and the label it writes onto a node + (`DeclaredSchema.ownLabelOf`) — so a lossy change on a fully qualified `com.example.Person` reaches + propositions whose mentions say plain `Person`, and a known-but-ungoverned `com.example.Sighting` + is recognised as the declared type it is. This matches what `DeclaredObservedDiffer` already did on + the declared side, so the two halves of a check agree about which type is which; a mention matching + under the other spelling of its own type is ordinary matching and is never reported as a former + name. Each proposition a sweep actually moves to `STALE` is announced to its `DiceEventListener` as + a `PropositionStatusChanged`, emitted by the sweep itself so the signal doesn't depend on whether the injected `PropositionStore` happens to be wrapped in something like `EventEmittingPropositionRepository` — the default auto-configured store isn't. A proposition - already `STALE` from ordinary decay that the sweep quarantines (writing the reason, not moving the - status) emits no event, since none of its status actually changed. This is what lets a listener - such as `ProjectionLineageStaleCascade` mark a quarantined proposition's projection records stale - in turn. There is still no Drivine implementation of the new pointer and no Spring wiring; both - arrive in later slices — `DrivineMetamodelVersionStore` compiles unchanged against the new interface - defaults today, and its own round should override `sweptVersion`/`markSwept` the way the in-memory - store does, or it keeps the `latestVersion`-based gap described above. + already `STALE` from ordinary decay that a sweep quarantines (writing the reason, leaving the + status where it was) emits no event, since nothing about its status actually changed. A release + announces the transition back. This is what lets a listener such as `ProjectionLineageStaleCascade` + mark a quarantined proposition's projection records stale in turn. **Compatibility: additive, with two source-breaking exceptions.** New types in an existing module; no existing API touched except the two below. `DefaultDriftCheckRunner`'s constructor gains a *required* `metamodelDiffer: MetamodelDiffer` parameter (the declared-vs-previous comparison) — - every existing caller must start supplying one — alongside a defaulted `listener: - DiceEventListener = DiceEventListener.DEV_NULL` parameter, which does not force a change on its - own. The constructor carries `@JvmOverloads`, so a Java caller updating for the now-required - `metamodelDiffer` does not also have to start supplying `listener`; a Kotlin caller using named - arguments never had to either — but `@JvmOverloads` does not paper over the required parameter - itself, only the defaulted one. `QuarantineDecision` is a sealed interface gaining a fourth - member, `Protected`, so an external exhaustive `when` over it needs a new branch to keep - compiling — the same shape of change already accepted for `MetamodelChange` in this same - Unreleased block. Everything else here stays additive: `MetamodelVersionStore` gains - `sweptVersion`/`markSwept`, both defaulted on the interface (see above), so every existing - implementation keeps compiling unchanged; `QuarantineResult` gains a `protected: - List` parameter defaulted to empty, so existing callers of its - constructor are unaffected. One dependency-graph change: `dice-metamodel` now depends on `dice` - (core), because quarantine works on the proposition model, so anything depending on - `dice-metamodel` alone now pulls `dice` in transitively. `dice-metamodel` is no longer a leaf - module, and `embabel-agent-rag-core` joins `embabel-agent-api` as a `provided` dependency it - expects the host to supply. + every existing caller must start supplying one. `QuarantineDecision` is a sealed interface gaining + a fourth member, `Protected`, so an external exhaustive `when` over it needs a new branch to keep + compiling — the same shape of change already accepted for `MetamodelChange` in this same Unreleased + block. Everything else here stays additive: `MetamodelVersionStore` is unchanged, so every existing + implementation — `DrivineMetamodelVersionStore` included — keeps compiling untouched, and a backend + opts into baseline tracking by implementing `SweptBaselineStore` when it is ready; + `QuarantineResult` gains a `protected: List` parameter defaulted to + empty, so existing callers of its constructor are unaffected. One dependency-graph change: + `dice-metamodel` now depends on `dice` (core), because quarantine works on the proposition model, so + anything depending on `dice-metamodel` alone now pulls `dice` in transitively. `dice-metamodel` is + no longer a leaf module, and `embabel-agent-rag-core` joins `embabel-agent-api` as a `provided` + dependency it expects the host to supply. - Rename-aware quarantine and a type-widening allow-list in `MentionTypeDriftQuarantinePolicy`, **EXPERIMENTAL** (behavior may change before 1.0). diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt index 050e4b11..de45e4e7 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt @@ -19,28 +19,31 @@ import com.embabel.agent.core.ContextId import java.util.Objects /** - * What one [DriftCheckRunner.run] call found and did. + * What one [DriftCheckRunner.run] call found. * - * The drifted types are read off [report] rather than copied into fields here. Every run persists a + * The drifted types are read off [report], with no second copy kept here. Every run persists a * report, so a copy would be a second version of the same answer, and two versions can disagree: a * caller who logged the result and an operator who read the stored report would then see different * type sets for the same check. * - * @property dryRun Whether this was a preview. On a dry run the report is still persisted; no - * proposition is touched. * @property report The [DriftReport] this run saved. Every run saves one, including a check that * found nothing. - * @property quarantinedCount How many propositions this run newly quarantined. Always 0 on a dry - * run. On a live run it can be non-zero even when [driftedEntityTypes] and - * [driftedRelationshipTypes] are both empty: quarantine reacts to two independent signals, not - * just observed drift — see [DriftCheckRunner.run]'s `dryRun` parameter doc for the second one. + * @property declaredVersion The stamp the check ran against. [DriftReport.versionHash] is this + * stamp's [MetamodelVersion.contentHash], and holding the stamp itself is what lets [quarantineDiff] + * answer without a second trip to the version store. */ class DriftCheckResult( - val dryRun: Boolean, val report: DriftReport, - val quarantinedCount: Int, + val declaredVersion: MetamodelVersion, ) { + init { + require(report.versionHash == declaredVersion.contentHash) { + "report was judged against ${report.versionHash} but was handed the stamp " + + "${declaredVersion.contentHash}" + } + } + /** The declared schema the check ran against. */ val schemaName: String get() = report.schemaName @@ -56,78 +59,76 @@ class DriftCheckResult( /** `true` when the graph contained any type or relationship that was never declared. */ val hasDrift: Boolean get() = report.hasDrift + /** + * How the declaration itself moved since the last completed sweep. `null` when the version store + * tracked no baseline to compare against. See [DriftReport.declaredDiff]. + */ + val declaredDiff: MetamodelDiff? get() = report.declaredDiff + + /** `true` when either half of the check found something. See [DriftReport.hasAnyChange]. */ + val hasAnyChange: Boolean get() = report.hasAnyChange + + /** + * The merged comparison a deliberate sweep would evaluate propositions against, so what this + * check reports and what a sweep would act on are the same facts. See + * [DriftReport.quarantineDiff]. + */ + val quarantineDiff: MetamodelDiff by lazy { report.quarantineDiff(declaredVersion) } + override fun equals(other: Any?): Boolean = other is DriftCheckResult && - dryRun == other.dryRun && report == other.report && - quarantinedCount == other.quarantinedCount + declaredVersion == other.declaredVersion - override fun hashCode(): Int = Objects.hash(dryRun, report, quarantinedCount) + override fun hashCode(): Int = Objects.hash(report, declaredVersion) override fun toString(): String = - "DriftCheckResult(dryRun=$dryRun, quarantinedCount=$quarantinedCount, report=$report)" + "DriftCheckResult(report=$report, declaredVersion=$declaredVersion)" } /** * Runs a drift check end to end: takes the declared schema, stamps it, snapshots what a live graph - * holds, compares the two, writes the result down, and quarantines the propositions the drift - * stranded when asked to. + * holds, compares the two, compares the declaration against the baseline a sweep last reconciled, + * and writes the whole answer down. * - * Dry-run by default. Observing and reporting changes nothing; moving propositions to `STALE` is a - * separate decision a caller opts into. Nothing here schedules itself, so a consuming application - * decides when [run] is called, as it does for the collector. + * ## A check reports; it changes nothing * - * The shorter [run] forms are real overloads with bodies rather than Kotlin default arguments, - * because Java can't see a default argument: `runner.run()` has to exist as a method for a Java - * caller to write it. Implementations override the two-argument form and get the other two free. - * Those two shorter forms are also the whole Java surface, since `ContextId` is a Kotlin value class - * and the two-argument form compiles to a mangled JVM name Java can't call. + * There is one mode. A check reads, compares, and persists a [DriftReport], and no path through it + * moves a proposition or the swept baseline. Acting on what a check found is a separate, deliberate + * step a host takes through [DriftSweepCapable], with its own bounded, context-scoped candidate + * selection. + * + * That split is why [DriftCheckResult.quarantineDiff] exists. A check is the only half DICE runs on + * its own, so its report has to show the *complete* comparison a sweep would act on, including the + * declared-vs-previous half. A report reading clean while a sweep on the very same state would + * quarantine is exactly the surprise this shape removes. + * + * Nothing here schedules itself. A consuming application decides when [run] is called, as it does + * for the collector. + * + * The no-argument [run] is a real overload with a body, because Java can't see a Kotlin default + * argument: `runner.run()` has to exist as a method for a Java caller to write it. It is also the + * whole Java surface, since `ContextId` is a Kotlin value class and the scoped form compiles to a + * mangled JVM name Java can't call. An implementation writes the scoped form and gets the other + * free. */ interface DriftCheckRunner { /** - * Declare, stamp, observe, diff, report, and quarantine when [dryRun] is `false` and either - * source has *any* diff to evaluate: an undeclared *entity* type observed in the graph, or a - * non-empty declared-vs-previous comparison. Either one starts an evaluation sweep, whether or - * not what it found is actually lossy — a purely additive or rename-only declared change still - * runs the policy over the candidate propositions, and the policy is what decides nothing about - * them needs to move. Only the propositions the policy actually judges affected end up - * quarantined. An undeclared *relationship* type alone (observed drift with - * [DriftReport.driftedRelationshipTypes] non-empty but [DriftReport.driftedEntityTypes] empty) - * does not start a sweep at all — only entity mentions are quarantine candidates today, so - * there is nothing for a relationship-only observed drift to catch. - * - * @param dryRun When `true`, the check runs and its [DriftReport] is persisted, but no - * proposition is touched and nothing is swept against. When `false`, quarantine runs against - * two independent signals merged into one sweep: propositions whose mentions reference an - * entity type the graph holds but the declaration doesn't ([DriftReport.driftedEntityTypes]), - * and propositions caught by a lossy change to the declaration itself since it was last swept - * — a property removed or narrowed, a whole type dropped — even when the live graph and the - * new declaration already agree on everything the graph currently holds. Whatever the - * configured [DriftQuarantinePolicy] flags from either signal is persisted. A dry run - * computes neither signal's quarantine effect, so it cannot preview what a live run would - * catch from the second source: running dry, then live, can still find something the dry - * run reported as clean. - * @param contextId `null` means the check covers the whole graph. Non-null scopes everything - * the check touches to that one context: the observed snapshot, the candidate propositions - * read for quarantine, and the persisted [DriftReport]. A mis-declared schema in one context - * can only quarantine propositions in that same context. - * @return What was found, and what was quarantined if this was a live run. - */ - fun run(dryRun: Boolean, contextId: ContextId?): DriftCheckResult - - /** - * Run a dry check over the whole graph. Nothing is quarantined; the report is still persisted. + * Declare, stamp, observe, diff against the graph, diff against the swept baseline, and persist + * the report. * + * @param contextId `null` means the check covers the whole graph. Non-null scopes both the + * observed snapshot and the persisted [DriftReport] to that one context, so a scoped check + * reports what that context alone holds. * @return What was found. */ - fun run(): DriftCheckResult = run(dryRun = true, contextId = null) + fun run(contextId: ContextId?): DriftCheckResult /** - * Run over the whole graph, dry or live. + * Check the whole graph. * - * @param dryRun `true` to preview without touching any proposition. - * @return What was found, and what was quarantined if this was a live run. + * @return What was found. */ - fun run(dryRun: Boolean): DriftCheckResult = run(dryRun, null) + fun run(): DriftCheckResult = run(null) } diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt index 6823f658..c39cfc7f 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt @@ -16,6 +16,30 @@ package com.embabel.dice.metamodel import com.embabel.dice.proposition.Proposition +import com.embabel.dice.proposition.PropositionStatus + +/** + * Metadata keys the metamodel writes onto a proposition, alongside the shared + * [com.embabel.dice.common.DiceMetadataKeys.QUARANTINE_REASON]. + * + * They live here because drift quarantine is the only thing that writes or reads them, and the core + * proposition model has no business knowing about schema versioning. The naming follows the same + * `dice..` convention, so nothing collides with a consumer's own keys. + */ +object DriftQuarantineKeys { + + /** + * The [PropositionStatus] a proposition carried at the moment it was quarantined, stored as its + * `name`. + * + * Quarantine moves a proposition to `STALE`, and `STALE` is a destination several roads lead to + * — ordinary decay reaches it as well. Without this key, releasing a quarantine could only guess + * where to put the proposition back. With it, release is exact: + * [DriftSweepCapable.releaseFromQuarantine] reads the value, restores that status, and clears + * both keys. + */ + const val PREVIOUS_STATUS = "dice.metamodel.quarantine.previousStatus" +} /** * What a policy decided about one [Proposition]. @@ -61,17 +85,22 @@ sealed interface QuarantineDecision { * Schema drift stranded this proposition, and it has been flagged. * * [proposition] is an immutable copy already moved to `STALE` and annotated with the reason - * under `DiceMetadataKeys.QUARANTINE_REASON`. The original is never mutated, and nothing is - * written anywhere; persisting the copy is the caller's job. + * under `DiceMetadataKeys.QUARANTINE_REASON` and the status it came from under + * [DriftQuarantineKeys.PREVIOUS_STATUS]. The original is never mutated, and nothing is written + * anywhere; persisting the copy is the caller's job. * * @property proposition The flagged, `STALE` copy. * @property reason A human-readable explanation of why it was quarantined. * @property affectedMentionTypes The entity type names that triggered it. + * @property previousStatus The status the proposition carried before this decision, which + * [DriftSweepCapable.releaseFromQuarantine] restores. `STALE` when the proposition was already + * stale from ordinary decay, in which case quarantine wrote a reason and moved no status. */ data class Quarantined( val proposition: Proposition, val reason: String, val affectedMentionTypes: Set, + val previousStatus: PropositionStatus, ) : QuarantineDecision /** @@ -134,12 +163,11 @@ data class QuarantineResult @JvmOverloads constructor( * * It takes a [MetamodelDiff], a comparison of two declared versions, which is what says exactly * which types the schema stopped recognising. A drift check compares a declaration against a live - * graph, and synthesizes the equivalent diff rather than deciding quarantine on its own terms. + * graph and synthesizes the equivalent diff, so quarantine is decided on one kind of input. * * ```kotlin - * val diff = differ.diff(previousVersion, currentVersion) - * val result = policy.evaluate(diff, repository.findAll()) - * result.quarantined.forEach { repository.save(it.proposition) } + * val diff = result.quarantineDiff + * val swept = sweepStore.sweep(diff, policy, contextId) * ``` */ interface DriftQuarantinePolicy { @@ -170,4 +198,30 @@ interface DriftQuarantinePolicy { * @return One decision per input proposition. */ fun evaluate(diff: MetamodelDiff, propositions: Iterable): QuarantineResult + + /** + * Every entity type name that, appearing as a mention type, could make a proposition a candidate + * under [diff]. + * + * This is what lets a sweep ask its store for a narrow, bounded set of propositions + * ([DriftSweepCapable.quarantineCandidates]) with no policy knowledge of its own. Which names + * matter is a policy judgement — a removed type's declared former names count, an added type's + * name doesn't — so the policy is the only thing that can answer it. + * + * **The contract that makes bounded selection sound:** if a proposition's mention types are all + * outside this set, [evaluate] must classify it as [QuarantineDecision.Conforming] or + * [QuarantineDecision.AlreadyQuarantined]. A sweep never reads such a proposition, so a policy + * that would have quarantined one anyway silently strands data. + * + * Include every spelling a mention can use. A declared type name can be fully qualified where + * the graph writes the simple label, so a policy matching both must list both here, or a + * bounded sweep asks for a spelling the store has never seen. + * + * An empty result means the diff could strand nothing, and a sweep then reads no propositions at + * all. + * + * @param diff What changed between the old and new schema. + * @return The mention type names worth reading. Empty when nothing in [diff] can strand data. + */ + fun candidateMentionTypes(diff: MetamodelDiff): Set } diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt index e0da011d..54ac0258 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt @@ -32,6 +32,19 @@ import java.util.Objects * resolves through [MetamodelVersionStore.findVersion]. Pull a year-old report and you can still * recover the exact shape that was expected when it was taken. * + * ## A report carries both halves of the comparison + * + * [driftedEntityTypes] and [driftedRelationshipTypes] are the graph-truth half: what the graph holds + * that this declaration doesn't recognise. [declaredDiff] is the other half: how the declaration + * itself moved since the last completed sweep — a property removed, a cardinality narrowed, a whole + * type dropped — which the graph-truth half cannot see, because nothing about such a type is + * undeclared and only its shape moved. + * + * Both are here because a report is what a person reads before deciding to sweep, and a sweep acts + * on both. A report that showed only the first half could read completely clean while a sweep + * against the very same state would quarantine, and the person who checked would have no way to + * know. [quarantineDiff] hands back the exact merged comparison such a sweep evaluates. + * * @property schemaName The declared schema's name at check time. Together with [versionHash] this * is what resolves the report back to a stored [MetamodelVersion]. * @property versionHash The [MetamodelVersion.contentHash] of the declared schema the check ran @@ -40,9 +53,12 @@ import java.util.Objects * declared, sorted the way the diff produced them. * @property driftedRelationshipTypes Relationship type names observed with no matching declaration. * @property capturedAt When the observation was taken: the [ObservedSchema.capturedAt] of the - * snapshot it was computed from, rather than the time of the write. + * snapshot it was computed from, which is a different instant from the write. * @property contextId The context the check was scoped to, or `null` when it covered the whole * graph. + * @property declaredDiff How the declaration itself moved since the last completed sweep, described + * above. `null` when the store tracked no baseline to compare against, which is the state of every + * schema before its first sweep finishes. */ class DriftReport @JvmOverloads constructor( val schemaName: String, @@ -51,6 +67,7 @@ class DriftReport @JvmOverloads constructor( driftedRelationshipTypes: Set, val capturedAt: Instant, val contextId: ContextId? = null, + val declaredDiff: MetamodelDiff? = null, ) { // Both sets are copied into JVM-immutable ones that keep the order they arrived in, and this @@ -67,6 +84,48 @@ class DriftReport @JvmOverloads constructor( val hasDrift: Boolean get() = driftedEntityTypes.isNotEmpty() || driftedRelationshipTypes.isNotEmpty() + /** + * `true` when either half of the check found something: an undeclared type or relationship in + * the graph, or a declared change since the last completed sweep. + * + * [hasDrift] answers the narrower graph-truth question, so read this one when you want to know + * whether a sweep would have anything at all to look at. + */ + val hasAnyChange: Boolean get() = hasDrift || declaredDiff?.isEmpty == false + + /** + * The single comparison a deliberate sweep evaluates propositions against: this report's + * observed drift and its [declaredDiff] merged into one [MetamodelDiff]. + * + * A sweep decides once, off one diff. Running the two comparisons as two sweeps would let each + * make an independent call about the same proposition, and the second call would see a + * proposition the first had already moved. + * + * Every entity type name from either source lands in one [MetamodelChange.EntityTypeRemoved] + * block, sorted by name, which is the global ordering [MetamodelDiff] promises. A removal + * [declaredDiff] already reports is filtered out before the merge, so no name appears twice, and + * [declaredDiff]'s remaining changes keep their relative order behind the block. + * + * [declaredDiff]'s own [MetamodelDiff.fromVersion] carries through as the merged `from` side + * when there is one, so a policy can still resolve the declared former names of a type the + * declaration removed. A type observed in the graph and declared by nobody has no former names + * on either side. + * + * @param declaredVersion The stamp this check ran against — the one [versionHash] resolves to + * through [MetamodelVersionStore.findVersion]. + * @return The merged diff. + */ + fun quarantineDiff(declaredVersion: MetamodelVersion): MetamodelDiff { + val declaredChanges = declaredDiff?.changes.orEmpty() + val removedNames = (declaredDiff?.removedEntityTypes.orEmpty() union driftedEntityTypes).sorted() + return MetamodelDiff( + fromVersion = declaredDiff?.fromVersion ?: declaredVersion, + toVersion = declaredVersion, + changes = removedNames.map { MetamodelChange.EntityTypeRemoved(it) } + + declaredChanges.filterNot { it is MetamodelChange.EntityTypeRemoved }, + ) + } + override fun equals(other: Any?): Boolean = other is DriftReport && schemaName == other.schemaName && @@ -74,7 +133,8 @@ class DriftReport @JvmOverloads constructor( driftedEntityTypes == other.driftedEntityTypes && driftedRelationshipTypes == other.driftedRelationshipTypes && capturedAt == other.capturedAt && - contextId == other.contextId + contextId == other.contextId && + declaredDiff == other.declaredDiff override fun hashCode(): Int = Objects.hash( schemaName, @@ -83,12 +143,13 @@ class DriftReport @JvmOverloads constructor( driftedRelationshipTypes, capturedAt, contextId, + declaredDiff, ) override fun toString(): String = "DriftReport(schemaName=$schemaName, versionHash=$versionHash, " + "driftedEntityTypes=$driftedEntityTypes, driftedRelationshipTypes=$driftedRelationshipTypes, " + - "capturedAt=$capturedAt, contextId=${contextId?.value})" + "capturedAt=$capturedAt, contextId=${contextId?.value}, declaredDiff=$declaredDiff)" private companion object { diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftSweepCapable.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftSweepCapable.kt new file mode 100644 index 00000000..e511d765 --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftSweepCapable.kt @@ -0,0 +1,237 @@ +/* + * 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 +import com.embabel.dice.proposition.Proposition + +/** + * The store-side operations a host needs to act on a drift check: find the propositions a schema + * change could have stranded, quarantine them, and let them back out again. + * + * ## Why this is a separate interface + * + * A `PropositionStore` is a general persistence port, and most of what it offers — read everything, + * read one context, save — is enough to *look* like a sweep while being wrong at any real size. + * Sweeping by reading every proposition materialises every tenant's data in one JVM heap and filters + * mention types afterwards, which works on a laptop and falls over in production. So the sweep asks + * for something narrower and states it as a requirement: a query bounded by a page size, confined to + * one context, and filtered on mention type by the backend. + * + * A store implements this when its backend can honour that, exactly the way DICE's other opt-in + * store capabilities work. A store that can't keeps the plain persistence contract and its host + * sweeps through [com.embabel.dice.metamodel.support.PropositionStoreDriftSweep], the reference + * implementation, which is honest about doing the filtering in the JVM. + * + * ## Nothing calls this on its own + * + * DICE runs drift *checks*. It never sweeps. [DriftCheckRunner] reports what it found and moves + * nothing, and every method here runs because a host called it, at a moment a host chose. There is + * no timer, no scheduler and no autoconfiguration that reaches these methods. + * + * The usual shape is: run a check, read its [DriftCheckResult.quarantineDiff], decide, then call + * [sweep] once per context you meant to reconcile, then — and only after every context is done — + * call [SweptBaselineStore.markSwept] so the next check compares against what you actually swept. + * + * ```kotlin + * val result = runner.run() + * if (result.hasAnyChange) { + * val swept = sweepStore.sweep(result.quarantineDiff, policy, contextId) + * log.info("quarantined {} proposition(s)", swept.quarantined.size) + * } + * ``` + * + * ## Releasing is a real operation + * + * Quarantine is reversible, and [releaseFromQuarantine] is what reverses it. Clearing the reason + * metadata by hand leaves the proposition `STALE`, which keeps it out of ordinary retrieval with + * nothing left to explain why. Release restores the status the proposition carried before quarantine + * and clears both keys in one write. + */ +interface DriftSweepCapable { + + /** + * The propositions in [contextId] that could be affected by a schema change, restricted to those + * mentioning at least one type in [mentionTypes]. + * + * Three requirements, all of them load-bearing: + * + * - **Bounded.** Return at most [limit] propositions. A backend must push the bound into its + * query, so a sweep of a large context costs one page at a time. + * - **Scoped.** Return only propositions whose `contextId` is [contextId]. A schema mis-declared + * in one context must never be able to reach another context's data, so this parameter is + * required and there is no whole-graph form. + * - **Mention-type aware.** Return only propositions carrying at least one entity mention whose + * type is in [mentionTypes]. A backend must push this filter down too. Reading a context whole + * and filtering afterwards gives the same answer at a cost that grows with the size of the + * context, when what it should grow with is the size of the change. + * + * Order by [Proposition.id] ascending, and start after [afterId] when it is given. A stable + * total order is what makes [afterId] a usable cursor: a sweep pages by passing back the last id + * it saw, and quarantining a proposition changes its status without moving it in this order, so + * no page is skipped and none repeats. + * + * An empty [mentionTypes] returns nothing. A change that could affect no mention type has no + * candidates, and reading the context to discover that would be pure waste. + * + * Already-quarantined propositions are included when they match. Skipping them here would hide + * them from [DriftQuarantinePolicy.evaluate], which reports them in their own bucket so a + * sweep's conforming count stays honest. + * + * @param contextId The context to sweep. Required. + * @param mentionTypes Entity type names a candidate must mention at least one of. Ordinarily + * [DriftQuarantinePolicy.candidateMentionTypes] of the diff being swept. + * @param limit The most propositions to return. Must be positive. + * @param afterId Return only propositions whose id sorts after this one. `null` starts at the + * beginning. + * @return At most [limit] candidates, by ascending id. Empty when the page is past the end. + * @throws IllegalArgumentException if [limit] is not positive. + */ + fun quarantineCandidates( + contextId: ContextId, + mentionTypes: Set, + limit: Int, + afterId: String?, + ): List + + /** + * The same read from the beginning. + * + * A real overload with a body, because Java can't see a Kotlin default argument. + * + * @param contextId The context to sweep. + * @param mentionTypes Entity type names a candidate must mention at least one of. + * @param limit The most propositions to return. Must be positive. + * @return At most [limit] candidates, by ascending id. + */ + fun quarantineCandidates( + contextId: ContextId, + mentionTypes: Set, + limit: Int, + ): List = quarantineCandidates(contextId, mentionTypes, limit, null) + + /** + * Persist one quarantine decision. + * + * [QuarantineDecision.Quarantined.proposition] is already the `STALE` copy carrying its reason + * and the status it came from; a policy built it and wrote nothing. This is the write. + * + * An implementation announces the transition to whatever listener it was given, skipping the + * announcement when the status didn't actually move — a proposition that arrived `STALE` from + * ordinary decay gets its reason written without transitioning, and an event claiming otherwise + * would be a lie a listener has no way to catch. + * + * @param decision What the policy decided. + * @return The saved proposition. + */ + fun applyQuarantine(decision: QuarantineDecision.Quarantined): Proposition + + /** + * Let a quarantined proposition back out: restore the status it carried before quarantine and + * clear its quarantine metadata, in one write. + * + * This is the whole reversibility story. A host that clears + * [com.embabel.dice.common.DiceMetadataKeys.QUARANTINE_REASON] by hand leaves the proposition + * `STALE`, so it stays out of ordinary retrieval with nothing on it saying why, and the next + * sweep treats it as a fresh candidate and quarantines it again. + * + * The prior status comes from [DriftQuarantineKeys.PREVIOUS_STATUS], which the policy wrote at + * quarantine time. A proposition carrying no readable value there is restored to + * [com.embabel.dice.proposition.PropositionStatus.ACTIVE], which is the only sensible reading of + * "let it back into use" when the record of where it came from is gone. + * + * Both metadata keys are cleared, so releasing twice is safe: the second call finds nothing + * quarantined and answers `null`. + * + * @param propositionId The proposition to release. + * @return The released proposition, or `null` when no proposition has that id, or when the one + * that does was never quarantined. + */ + fun releaseFromQuarantine(propositionId: String): Proposition? + + /** + * Sweep one context against [diff]: page through the candidates, evaluate them, and persist + * every quarantine [policy] decides on. + * + * The default body is the whole sweep, written once against the three operations above so every + * implementation gets the same bounded, scoped behaviour. It reads a page of at most [batchSize] + * candidates, hands the page to [policy], applies what came back, then asks for the next page + * starting after the last id it saw, until a page comes back short. + * + * Only mention types [policy] says could matter are ever read + * ([DriftQuarantinePolicy.candidateMentionTypes]), so a diff that touches one type reads one + * type's propositions however large the context is. When the diff could affect nothing, no page + * is read at all. + * + * Nothing here advances the swept baseline. A host sweeps every context it means to reconcile + * and then calls [SweptBaselineStore.markSwept] itself, because only the host knows when it is + * finished. See that method for what goes wrong if the baseline moves early. + * + * @param diff The comparison to evaluate against, ordinarily [DriftCheckResult.quarantineDiff]. + * @param policy Decides which propositions the change stranded. + * @param contextId The context to sweep. + * @param batchSize How many candidates to read per page. Must be positive. + * @return Every decision the sweep made, gathered across pages in the order they were read. + * @throws IllegalArgumentException if [batchSize] is not positive. + */ + fun sweep( + diff: MetamodelDiff, + policy: DriftQuarantinePolicy, + contextId: ContextId, + batchSize: Int = DEFAULT_BATCH_SIZE, + ): QuarantineResult { + require(batchSize > 0) { "batchSize must be positive, but was $batchSize" } + val mentionTypes = policy.candidateMentionTypes(diff) + + val conforming = mutableListOf() + val quarantined = mutableListOf() + val alreadyQuarantined = mutableListOf() + val protected = mutableListOf() + + var afterId: String? = null + while (mentionTypes.isNotEmpty()) { + val page = quarantineCandidates(contextId, mentionTypes, batchSize, afterId) + if (page.isEmpty()) break + + val decided = policy.evaluate(diff, page) + decided.quarantined.forEach { applyQuarantine(it) } + + conforming += decided.conforming + quarantined += decided.quarantined + alreadyQuarantined += decided.alreadyQuarantined + protected += decided.protected + + if (page.size < batchSize) break + afterId = page.last().id + } + + return QuarantineResult( + conforming = conforming, + quarantined = quarantined, + alreadyQuarantined = alreadyQuarantined, + protected = protected, + ) + } + + companion object { + + /** + * Candidates read per page when a caller doesn't say. Big enough that an ordinary sweep is a + * handful of round trips, small enough that one page fits comfortably in memory. + */ + const val DEFAULT_BATCH_SIZE: Int = 500 + } +} diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelVersionStore.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelVersionStore.kt index 3ae79884..36c2d660 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelVersionStore.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelVersionStore.kt @@ -16,21 +16,22 @@ package com.embabel.dice.metamodel /** - * Reference [MetamodelVersionStore] that keeps stamps in a list. + * Reference [MetamodelVersionStore] that keeps stamps in a list, and the reference reading of + * [SweptBaselineStore]'s swept-state semantics. * - * It is the executable statement of what the contract means, so a durable backend can be held to + * It is the executable statement of what both contracts mean, so a durable backend can be held to * the same suite of tests. It also lets a host stamp and compare schemas before it has a database, * which is most of what the first tier of versioning is for. * * Nothing here survives the JVM, and two instances know nothing about each other. */ -class InMemoryMetamodelVersionStore : MetamodelVersionStore { +class InMemoryMetamodelVersionStore : SweptBaselineStore { private val saved = mutableListOf() // Tracked apart from `saved`'s write order on purpose: the reconciled baseline a sweep last - // completed against is a *pointer*, one per schema, that moves only on markSwept -- unlike - // versionHistory, which never forgets a stamp's original position. See sweptVersion's doc. + // completed against is a *pointer*, one per schema, that moves only on markSwept, while + // versionHistory never forgets a stamp's original position. See sweptVersion's doc. private val swept = mutableMapOf() /** @@ -57,10 +58,13 @@ class InMemoryMetamodelVersionStore : MetamodelVersionStore { synchronized(saved) { saved.filter { it.schemaName == schemaName }.reversed() } /** - * Also saves [version] into the ordinary history, the way the interface default does, so a - * caller that only ever calls [markSwept] for a brand-new stamp still gets it stored -- but the - * reconciled-baseline pointer itself is kept in [swept], last-write-wins per schema, which is - * what lets it answer correctly after a schema cycles back to an earlier stamp. + * Also saves [version] into the ordinary history, so a caller that only ever calls [markSwept] + * for a brand-new stamp still gets it stored. The reconciled-baseline pointer itself lives in + * [swept], last-write-wins per schema, which is what lets it answer correctly after a schema + * cycles back to an earlier stamp. + * + * Marking is always explicit. [saveVersion] leaves [swept] alone however many times it runs, so + * no amount of stamping ever looks like a finished sweep. */ override fun markSwept(version: MetamodelVersion) { saveVersion(version) diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt index 637d2605..605ea8a7 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt @@ -102,10 +102,29 @@ interface MetamodelVersionStore { */ fun findVersion(schemaName: String, contentHash: String): MetamodelVersion? = versionHistory(schemaName).firstOrNull { it.contentHash == contentHash } +} + +/** + * A [MetamodelVersionStore] that can also remember which declaration a drift sweep last finished + * reconciling against. + * + * Split off from [MetamodelVersionStore] because it is a genuinely harder promise: a store keeps + * this pointer only when it can move it exactly once per completed sweep, and a store that can't do + * that keeps the plain version contract and implements nothing here. `DriftCheckRunner` reads the + * baseline through this interface when its store offers one, the same way DICE's other opt-in + * capabilities work, and reports no declared-vs-previous comparison at all when the store doesn't. + * Silence about a baseline nobody tracks is the honest answer; guessing one from write order is how + * a dry, scoped, or interrupted write gets mistaken for a finished sweep. + * + * Neither method has a default body, and that is the whole point. A forwarding default would make + * every store look like it tracked a baseline while answering with write order, so a host would + * read a confident wrong comparison and never know. + */ +interface SweptBaselineStore : MetamodelVersionStore { /** - * The version the last COMPLETED, unscoped live drift sweep reconciled against — the correct - * baseline for the next declared-vs-previous comparison. + * The version the last COMPLETED drift sweep reconciled against — the baseline for the next + * declared-vs-previous comparison. * * This is a different question from [latestVersion], which answers "what's the newest stamp by * write order" and gets the wrong answer once a declaration cycles back to a stamp that already @@ -113,44 +132,37 @@ interface MetamodelVersionStore { * order, so after a schema goes `A` → `B` → `A` again, [latestVersion] still answers `B`, even * though the schema is back to declaring `A`. A drift check that diffed against [latestVersion] * would compare the reverted `A` against `B` — the wrong pair — and could miss a lossy change - * that came back. [sweptVersion] tracks the actual reconciled baseline instead, moved forward - * only by [markSwept], so it always answers the version a sweep genuinely finished comparing - * against, whatever order the schema's stamps arrived in. - * - * The default answers [latestVersion]. That is a real gap, not a placeholder pretending the gap - * is closed, and it is wider than the `A` → `B` → `A` case above: [saveVersion] runs on every - * check regardless of [DriftCheckRunner]'s `dryRun` or `contextId`, so if this method still - * answers [latestVersion], every path that reading the reconciled baseline separately was meant - * to close reopens for a non-overriding store — a dry run's save moves what the next live run - * treats as "already reconciled," a scoped live run's save does the same for the contexts it - * never touched, and a crash between the save and the sweep finishing leaves the moved pointer - * behind with nothing having actually been swept against it. A store must override this method - * and [markSwept] together to get an independently-tracked baseline; overriding only one leaves - * the other inconsistent. [InMemoryMetamodelVersionStore] overrides both; a durable backend - * should do the same. + * that came back. [sweptVersion] tracks the reconciled baseline itself, moved forward only by + * [markSwept], so it always answers the version a sweep genuinely finished comparing against, + * whatever order the schema's stamps arrived in. + * + * [saveVersion] must never move this pointer. Stamping is a history write that happens on every + * drift check, and a drift check reports without changing a proposition, so treating a stamp as + * a sweep would retire a lossy declared change nobody had acted on yet. * * @param schemaName The schema to look up. - * @return The reconciled baseline, or `null` if no live sweep has ever completed for it. + * @return The reconciled baseline, or `null` if no sweep has ever completed for it. */ - fun sweptVersion(schemaName: String): MetamodelVersion? = latestVersion(schemaName) + fun sweptVersion(schemaName: String): MetamodelVersion? /** - * Record [version] as the new reconciled baseline for its schema, once a live, unscoped drift - * sweep has finished comparing the whole schema against it. See [sweptVersion] for why this is - * tracked apart from [saveVersion]'s write-order history. + * Record [version] as the new reconciled baseline for its schema. + * + * **A completed sweep is the only thing that may move the baseline.** Call this after a + * deliberate sweep has handled every candidate it was going to touch, across the whole schema, + * and nowhere else. Three writes look tempting and are all wrong: * - * Call this only after every candidate the sweep was going to touch has actually been handled — - * calling it earlier (or on a dry run, or a run scoped to one context) would let a later check - * believe a comparison happened that a crash interrupted, or that covered contexts it never - * touched. [DefaultDriftCheckRunner] calls this last, after persisting every proposition its - * sweep quarantined. + * - a **drift check**, which reports and touches no proposition, so the lossy declared change it + * found is still waiting for somebody to act on it; + * - a sweep **scoped to one context**, which reconciled that context alone and would strand + * every other context against a change nothing ever swept them for; + * - an **interrupted** sweep, where marking before the last candidate is handled makes a crash + * look like a finished reconciliation. * - * The default forwards to [saveVersion]. That keeps the stamp itself recorded (harmless, since a - * completed sweep's version is normally already stored by the time this runs) but does not give - * [sweptVersion] independent tracking on its own — see that method's doc for what a store needs - * to override to close the gap. + * Marking after a sweep that found nothing to quarantine is correct: "nothing needed doing" is a + * completed reconciliation against that declaration. * * @param version The version to record as reconciled. */ - fun markSwept(version: MetamodelVersion) = saveVersion(version) + fun markSwept(version: MetamodelVersion) } diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt index 55179491..f3e2c4ac 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/DefaultDriftCheckRunner.kt @@ -16,33 +16,39 @@ package com.embabel.dice.metamodel.support import com.embabel.agent.core.ContextId -import com.embabel.dice.common.DiceEventListener -import com.embabel.dice.common.PropositionStatusChanged import com.embabel.dice.metamodel.DeclaredObservedDiffer import com.embabel.dice.metamodel.DeclaredSchemaSource import com.embabel.dice.metamodel.DriftCheckResult import com.embabel.dice.metamodel.DriftCheckRunner -import com.embabel.dice.metamodel.DriftQuarantinePolicy import com.embabel.dice.metamodel.DriftReport import com.embabel.dice.metamodel.DriftReportStore -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.MetamodelVersionStore import com.embabel.dice.metamodel.ObservedSchemaSource -import com.embabel.dice.proposition.PropositionStore +import com.embabel.dice.metamodel.SweptBaselineStore import org.slf4j.LoggerFactory /** * The shipped [DriftCheckRunner]. It sequences the collaborators and makes no decisions of its own: - * the comparison belongs to the differ and the quarantine call to the policy, and this class puts - * them in an order that leaves a coherent record behind. + * the comparisons belong to the two differs, and this class puts them in an order that leaves a + * coherent record behind. * * Stateless, so calling it repeatedly or for different schemas at once is fine. Two concurrent * checks of the same schema don't corrupt anything, since each captures its own complete snapshot, * but they duplicate work; serialize at the scheduling layer if that matters. * + * ## Nothing here writes to a proposition + * + * This class reads, compares, and writes a [DriftReport]. It holds no quarantine policy and no + * proposition store, so there is no path through it that can move a proposition to `STALE` or move + * the swept baseline. Acting on a check is a deliberate host step through + * `DriftSweepCapable`, and the baseline moves when the host says a sweep finished, through + * [SweptBaselineStore.markSwept]. + * + * Two writes still happen, and both are records of the check itself: the declared version stamp, and + * the report. + * * ## The version is stamped before the report is written * * The declared version is saved to [versionStore] on every run, before the report is written, even @@ -51,119 +57,77 @@ import org.slf4j.LoggerFactory * resolves back to a real stamp through [MetamodelVersionStore.findVersion]. Stamping last, or only * when the schema moved, would leave the first check after a schema change pointing at a hash * nothing has recorded. `saveVersion` upserts on `(schemaName, contentHash)`, so doing it every run - * costs one idempotent write. Every run does this, dry or live — it's a history write, not the diff - * baseline update below, for any store that tracks [MetamodelVersionStore.sweptVersion] - * independently of write order (see that method's doc). A store that doesn't override it inherits - * the interface default, which answers [MetamodelVersionStore.latestVersion], and that method's own - * contract draws the exact line: saving a stamp that isn't already recorded moves `latestVersion` — - * and so the forwarded baseline — to it; re-saving a stamp that's already there keeps its original - * write-order position, leaving `latestVersion`, and the forwarded baseline, unmoved. So on such a - * store, this write moves the baseline precisely when the current run's declaration is a genuinely - * new stamp, dry, scoped, or crashed run alike — see [MetamodelVersionStore.sweptVersion]'s doc for - * what that costs. + * costs one idempotent write. + * + * This is a history write, and it never moves the reconciled baseline. That is a promise + * [SweptBaselineStore] makes on the store's side, and it is what lets a check stamp freely without + * retiring a lossy declared change nobody has acted on yet. * - * ## Two sources of drift + * ## Both comparisons reach the report * - * A live run's quarantine candidates come from two independent comparisons, merged into one diff - * before the policy ever sees them: + * A check runs two comparisons and reports both: * * - **Declared vs. observed** ([differ]): what the live graph holds that this declaration doesn't - * recognise. This is what [DriftReport.driftedEntityTypes] records, and it is blind to a property - * that quietly narrowed or disappeared on a type the graph and the declaration still agree on. + * recognise. [DriftReport.driftedEntityTypes] records it, and it is blind to a property that + * quietly narrowed or disappeared on a type the graph and the declaration still agree on. * - **Declared vs. previous declared** ([metamodelDiffer]): what changed in the declaration itself - * since [MetamodelVersionStore.sweptVersion] — a removed property, a narrowed cardinality, a whole - * type dropped — regardless of what the graph currently holds. `null` when no live sweep has ever - * completed for this schema. + * since [SweptBaselineStore.sweptVersion] — a removed property, a narrowed cardinality, a whole + * type dropped — regardless of what the graph currently holds. [DriftReport.declaredDiff] records + * it, and it is `null` when no sweep has ever completed for this schema, or when the version store + * tracks no baseline at all. * - * Without the second comparison, a schema edit that silently strands previously-extracted data - * (say, a value type narrowing from `long` to `int`) would never reach [quarantinePolicy] at all - * until the graph itself drifted out of step with the *new* declaration — which, if nothing else - * changes, is never. [DriftReport] itself is unaffected: it still reports only declared-vs-observed - * drift, since that is the graph-truth signal an operator watching for undeclared shapes wants; the - * declared-vs-previous comparison feeds quarantine only. + * The second comparison is what catches a schema edit that silently strands previously-extracted + * data (a value type narrowing from `long` to `int`, say) when the graph itself never drifts out of + * step with the *new* declaration. Both go into the report, so + * [DriftCheckResult.quarantineDiff] can hand back the complete comparison a sweep would evaluate — + * a check that reported only the graph-truth half could read clean while a sweep on the same state + * quarantined. * - * ## The diff baseline only advances when a sweep actually finishes + * ## The baseline is read through an optional capability * - * The declared-vs-previous baseline comes from [MetamodelVersionStore.sweptVersion], read before - * [versionStore]'s history write above, and this class only calls - * [MetamodelVersionStore.markSwept] to advance it once — after every candidate a **live, unscoped** - * sweep was going to touch has genuinely been handled. Three things follow, each a real hazard the - * earlier "save on every run" design had: - * - * - A **dry run** decides nothing, so it must not retire a lossy declared change either — the next - * run, live or dry, still needs to see it. `run()` with no arguments stays what the class doc for - * [DriftCheckRunner] promises: reports, changes nothing. - * - A run **scoped to one context** only sweeps that context's candidates. Retiring the schema-wide - * baseline after it would strand every other context's candidates against a change nothing ever - * swept them for. A scoped run still computes and acts on the same diff — that context's - * candidates do get quarantined — it just leaves the baseline where it was, so a later run (scoped - * to another context, or unscoped) still sees the same declared-vs-previous drift and finishes the - * job. The already-quarantined check makes that safe to repeat: nothing already handled gets - * touched twice. - * - A **crash between the history write and the end of the sweep** must not look like a completed - * reconciliation. `markSwept` is the last thing this class does, strictly after every quarantined - * proposition is saved, so an interrupted run leaves the baseline exactly where it was and the next - * run retries the same comparison and finishes the job. - * - * [MetamodelVersionStore.sweptVersion] is a different question from `latestVersion`, which the - * store's own doc covers: `latestVersion` tracks write order and gives the wrong answer once a - * declaration cycles back to an earlier stamp. + * [versionStore] supplies the baseline only when it is a [SweptBaselineStore]. A store that tracks + * no baseline leaves [DriftReport.declaredDiff] `null` and the check reports the graph-truth half + * alone, which is the honest answer: a baseline read off write order would move on ordinary stamping + * and quietly retire changes nothing had swept for. * * @param declaredSchemaSource Supplies the schema as declared. Read first, so everything downstream * is judged against one declaration. * @param versionStore Where the declared stamp is recorded each run, so report hashes always - * resolve, and where the reconciled baseline is read from and advanced. See "The diff baseline - * only advances when a sweep actually finishes" above. + * resolve, and where the reconciled baseline is read from when the store tracks one. * @param observedSchemaSource Snapshots what the live graph actually contains. * @param differ Compares the declaration against the observation. * @param metamodelDiffer Compares the declaration against its reconciled baseline. The same * [StructuralMetamodelDiffer] instance ordinarily implements both this and [differ]. * @param driftReportStore Durable log the report is written to, on every run. - * @param quarantinePolicy Decides which stranded propositions to quarantine. Consulted only on a - * live run that found drift from either source in "Two sources of drift" above. - * @param propositionStore Where candidate propositions are read from and quarantined copies are - * saved back to. The base persistence port rather than `PropositionRepository`: a drift check only - * reads by context or in bulk and saves, so requiring vector search, graph traversal and temporal - * query alongside would shut a plain store-and-retrieve backend out of drift checking for - * capabilities it never uses. - * @param listener Told about each quarantine as a [PropositionStatusChanged], so a consumer like - * `ProjectionLineageStaleCascade` hears about the transition without depending on whichever - * concrete [propositionStore] happens to be wired in. Defaults to a no-op: most of what - * [DefaultDriftCheckRunner] promises holds with nobody listening at all. */ -class DefaultDriftCheckRunner @JvmOverloads constructor( +class DefaultDriftCheckRunner( private val declaredSchemaSource: DeclaredSchemaSource, private val versionStore: MetamodelVersionStore, private val observedSchemaSource: ObservedSchemaSource, private val differ: DeclaredObservedDiffer, private val metamodelDiffer: MetamodelDiffer, private val driftReportStore: DriftReportStore, - private val quarantinePolicy: DriftQuarantinePolicy, - private val propositionStore: PropositionStore, - private val listener: DiceEventListener = DiceEventListener.DEV_NULL, ) : DriftCheckRunner { private val logger = LoggerFactory.getLogger(DefaultDriftCheckRunner::class.java) - override fun run(dryRun: Boolean, contextId: ContextId?): DriftCheckResult { + override fun run(contextId: ContextId?): DriftCheckResult { val declared = declaredSchemaSource.declare() // The reconciled baseline, read before this run's own history write below so it can never - // read back its own stamp. Null when no live sweep has ever completed for this schema. See - // "The diff baseline only advances when a sweep actually finishes" on the class doc. - val previousVersion = versionStore.sweptVersion(declared.version.schemaName) + // read back its own stamp. Null when no sweep has ever completed for this schema, and null + // for the whole life of a store that tracks no baseline. See the class doc. + val previousVersion = (versionStore as? SweptBaselineStore)?.sweptVersion(declared.version.schemaName) - // Every run stamps its declaration into history, dry or live, so a report's hash always - // resolves — see the class doc. On a store that tracks the reconciled baseline - // independently, this alone never moves it; only markSwept does, at the end of a live, - // unscoped run. On a store that doesn't, this can move it too -- see the class doc. + // Every run stamps its declaration into history so a report's hash always resolves -- see + // the class doc. This is a history write and leaves the baseline alone. versionStore.saveVersion(declared.version) val observed = observedSchemaSource.observe(contextId) val diff = differ.diffAgainstObserved(declared = declared, observed = observed) - // What moved in the declaration since the reconciled baseline — property removals, narrowed - // cardinality, a whole type dropped — which diff above never sees, since it only compares + // What moved in the declaration since the reconciled baseline -- property removals, narrowed + // cardinality, a whole type dropped -- which diff above never sees, since it only compares // the current declaration against the graph as it stands right now. val declaredDiff = previousVersion?.let { metamodelDiffer.diff(it, declared.version) } @@ -172,113 +136,25 @@ class DefaultDriftCheckRunner @JvmOverloads constructor( versionHash = declared.version.contentHash, driftedEntityTypes = diff.driftedEntityTypes, driftedRelationshipTypes = diff.driftedRelationshipTypes, - // The instant the graph was looked at, rather than the instant of this write: the - // report is a statement about the snapshot. Declared-vs-previous drift isn't part of - // this report; see the class doc. + // The instant the graph was looked at, which is a different instant from this write: the + // report is a statement about the snapshot. capturedAt = observed.capturedAt, contextId = contextId, + declaredDiff = declaredDiff, ) // Written on every run, including checks that found nothing. driftReportStore.saveDriftReport(report) - val quarantinedCount = if (!dryRun && (diff.driftedEntityTypes.isNotEmpty() || declaredDiff?.isEmpty == false)) { - quarantineAffectedPropositions(declared.version, diff.driftedEntityTypes, declaredDiff, contextId) - } else { - 0 - } - - // This call advances the baseline only for a live, unscoped run: a dry run acted on - // nothing, and a scoped run only ever sweeps one context's candidates against it. - // Unconditional on whether anything was actually quarantined -- "nothing needed doing" is - // still a completed reconciliation against this declaration, and the next check should - // start from here. The saveVersion call above can also move the baseline, on any run, on a - // store that doesn't track it independently -- see the class doc. - if (!dryRun && contextId == null) { - versionStore.markSwept(declared.version) - } - logger.info( - "Drift check for '{}' complete (dryRun={}, contextId={}): {} drifted entity type(s), " + - "{} drifted relationship type(s), {} quarantined", + "Drift check for '{}' complete (contextId={}): {} drifted entity type(s), " + + "{} drifted relationship type(s), {} declared change(s) since the swept baseline", declared.version.schemaName, - dryRun, contextId?.value, diff.driftedEntityTypes.size, diff.driftedRelationshipTypes.size, - quarantinedCount, - ) - - return DriftCheckResult(dryRun = dryRun, report = report, quarantinedCount = quarantinedCount) - } - - /** - * Hand every quarantine-worthy change to [quarantinePolicy] and persist whatever it flags, - * announcing each real transition to [listener] along the way. - * - * [driftedEntityTypes] (observed but undeclared) and [declaredDiff] (declared-vs-previous) are - * merged into one [MetamodelDiff] before evaluation, one [MetamodelChange.EntityTypeRemoved] per - * drifted type standing in for the ones [declaredDiff] didn't already report as removed. The - * policy only ever sees one diff and decides once; this never runs two independent sweeps that - * could each quarantine, or skip, the same proposition for a different reason. - * - * The merge keeps [MetamodelDiff]'s promised global ordering: [MetamodelChange.EntityTypeRemoved] - * is always the differ's first block, sorted by type name, so every removed name — declared or - * drifted — is gathered into that one sorted block. Any [MetamodelChange.EntityTypeRemoved] - * already present in [declaredDiff]'s own changes is explicitly filtered back out before the - * remainder is appended. This filter is what keeps a removal from showing up twice; the merged - * block is built from the two removal sources directly, and [declaredDiff]'s other changes keep - * their original relative order behind it. - * - * [declaredDiff]'s own [MetamodelDiff.fromVersion] carries forward as the merged diff's `from` - * side when it exists, so the policy can still resolve declared former names for a type removal - * that came from the declaration comparison. A drifted-but-undeclared type was never declared by - * either version, so it has no former names to resolve either way. - */ - private fun quarantineAffectedPropositions( - declaredVersion: MetamodelVersion, - driftedEntityTypes: Set, - declaredDiff: MetamodelDiff?, - contextId: ContextId?, - ): Int { - val declaredChanges = declaredDiff?.changes.orEmpty() - val mergedRemovedTypeNames = (declaredDiff?.removedEntityTypes.orEmpty() union driftedEntityTypes).sorted() - val mergedRemovals = mergedRemovedTypeNames.map { MetamodelChange.EntityTypeRemoved(it) } - val mergedDiff = MetamodelDiff( - fromVersion = declaredDiff?.fromVersion ?: declaredVersion, - toVersion = declaredVersion, - changes = mergedRemovals + declaredChanges.filterNot { it is MetamodelChange.EntityTypeRemoved }, + declaredDiff?.changes?.size ?: 0, ) - // A proposition in another context is never a candidate, whatever its mentions say, so a - // scoped run cannot reach outside its context. - val propositions = if (contextId != null) { - propositionStore.findByContextId(contextId) - } else { - propositionStore.findAll() - } - // Captured before evaluation, since QuarantineDecision.Quarantined only carries the copy - // already flipped to STALE — the emitted event needs to say what it moved from. - val statusById = propositions.associate { it.id to it.status } - - val result = quarantinePolicy.evaluate(mergedDiff, propositions) - result.quarantined.forEach { decision -> - val saved = propositionStore.save(decision.proposition) - // A proposition can arrive already STALE from ordinary decay (no quarantine reason yet, - // so the policy still treats it as a fresh candidate) and get quarantined without its - // status actually moving. Announcing a transition then would be a lie the listener has - // no way to catch, so this only fires when something really changed. - val previousStatus = statusById.getValue(decision.proposition.id) - if (previousStatus != decision.proposition.status) { - listener.onEvent( - PropositionStatusChanged( - proposition = saved, - previousStatus = previousStatus, - newStatus = decision.proposition.status, - reason = decision.reason, - ), - ) - } - } - return result.quarantined.size + return DriftCheckResult(report = report, declaredVersion = declared.version) } } diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt index 4ab7e102..f190e15d 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt @@ -17,6 +17,8 @@ package com.embabel.dice.metamodel.support import com.embabel.agent.core.Cardinality import com.embabel.dice.common.DiceMetadataKeys +import com.embabel.dice.metamodel.DeclaredSchema +import com.embabel.dice.metamodel.DriftQuarantineKeys import com.embabel.dice.metamodel.DriftQuarantinePolicy import com.embabel.dice.metamodel.MetamodelChange import com.embabel.dice.metamodel.MetamodelDiff @@ -77,6 +79,24 @@ import org.slf4j.LoggerFactory * new name, a child's inherited label — folds into [MetamodelChange.EntityTypeRenamed] in the diff * and never reaches this policy as loss. * + * ## Two spellings of one type name + * + * A declared name can be fully qualified where the data is simple. A JVM-backed type is declared as + * `com.example.Person`, extraction records the mention as `Person`, and the graph writes `Person` as + * the label. Matching on the declared spelling alone would read a lossy change on + * `com.example.Person` as touching nothing at all, and a schema that dropped the type outright would + * leave every proposition it stranded looking healthy. + * + * So every name this policy matches on — removed types, types that lost shape, declared former names + * — is registered under both spellings: the name as declared, and the label it writes onto a node + * (`DeclaredSchema.ownLabelOf`). This is the same cut the declared-vs-observed comparison makes, so + * the two halves of a drift check agree about which type is which. Two declared types in different + * packages share one label, and a graph can't tell them apart either, so a mention under that label + * is checked against both. + * + * Matching a mention under the other spelling of its own type is ordinary matching, so it never + * shows up in the reason as a former name. + * * ## Value types * * A changed value type counts as lossy in both directions except for the four promotions in @@ -112,32 +132,7 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { private val logger = LoggerFactory.getLogger(MentionTypeDriftQuarantinePolicy::class.java) override fun evaluate(diff: MetamodelDiff, propositions: Iterable): QuarantineResult { - val removedTypes = diff.removedEntityTypes - - // Types whose name survived and which lost labels or whole properties. Also lossy, because - // a mention may have relied on a label or property that is now gone. Keyed by type name. - val lossyModified = diff.modifiedEntityTypes - .filter { it.removedLabels.isNotEmpty() || it.removedProperties.isNotEmpty() } - .associateBy { it.typeName } - - // Types carrying a property that kept its name but narrowed. Grouped by type name, since - // one type can have several such properties and the reason should name them all. - val narrowedProperties = diff.propertySignatureChanges - .filter { isNarrowing(it.before, it.after) } - .groupBy { it.typeName } - - // Types carrying a property that was renamed and narrowed in the same step. The rename is - // harmless; the shape move underneath it is judged by the same rule as any other. - val narrowedRenames = diff.renamedProperties - .filter { isNarrowing(it.before, it.after) } - .groupBy { it.typeName } - - // Every name a surviving type has gone by, pointing at the name its changes are reported - // under, and every name a removed type has gone by, pointing at the removal. Two maps, read - // off opposite sides of the diff, because a removed type is absent from the newer side. - // Extracted once per sweep rather than per proposition. - val currentNamesByFormerName = formerTypeNames(diff) - val formerNamesOfRemovedTypes = formerNamesOfRemovedTypes(diff) + val signals = lossySignalsOf(diff) // There is deliberately no "nothing lossy, so everything conforms" shortcut here. Whether a // proposition is already quarantined is a fact about the proposition and doesn't depend on @@ -153,7 +148,7 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { // with, which is what an operator reading the log is trying to find out. val formerNamesMatched = sortedSetOf() // Propositions left alone because a previous sweep already quarantined them. Their own - // bucket rather than folded into conforming, so conforming.size counts only clean ones. + // bucket, so conforming.size counts only clean ones. val alreadyQuarantined = mutableListOf() for (proposition in propositions) { @@ -184,9 +179,12 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { for (mentionType in mentionTypes) { var affected = false - // Note that this mention type isn't the schema's own name for the type it hit. + // Note that this mention type isn't the schema's own name for the type it hit. Two + // spellings of one name don't count: a mention of `Person` against a declared + // `com.example.Person` is the same type, and calling that a former name would put a + // baffling line in the reason. fun recordFormerName(schemaName: String) { - if (schemaName != mentionType) { + if (DeclaredSchema.ownLabelOf(schemaName) != DeclaredSchema.ownLabelOf(mentionType)) { matchedByFormerName.getOrPut(mentionType) { sortedSetOf() } += schemaName formerNamesMatched += mentionType } @@ -194,21 +192,21 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { // Removals resolve through the OLDER version's aliases. A removed type takes its // former names down with it, and the newer version has no record they were ever - // this type's, so the surviving-type map above can't see them. + // this type's, so the surviving-type map can't see them. val removals = sortedSetOf() - if (mentionType in removedTypes) removals += mentionType - removals += formerNamesOfRemovedTypes[mentionType].orEmpty() + removals += signals.removedTypesBySpelling[mentionType].orEmpty() + removals += signals.formerNamesOfRemovedTypes[mentionType].orEmpty() if (removals.isNotEmpty()) { removedHit += removals affected = true removals.forEach(::recordFormerName) } - for (currentName in setOf(mentionType) + currentNamesByFormerName[mentionType].orEmpty()) { + for (currentName in setOf(mentionType) + signals.currentNamesByFormerName[mentionType].orEmpty()) { var lossyUnderThisName = false - lossyModified[currentName]?.let { lossyHit += it; lossyUnderThisName = true } - narrowedProperties[currentName]?.let { narrowedHit += it; lossyUnderThisName = true } - narrowedRenames[currentName]?.let { renamedHit += it; lossyUnderThisName = true } + signals.lossyModified[currentName]?.let { lossyHit += it; lossyUnderThisName = true } + signals.narrowedProperties[currentName]?.let { narrowedHit += it; lossyUnderThisName = true } + signals.narrowedRenames[currentName]?.let { renamedHit += it; lossyUnderThisName = true } if (lossyUnderThisName) { affected = true recordFormerName(currentName) @@ -249,9 +247,14 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { continue } + // Where the proposition came from, written onto the copy so a release can put it back + // exactly there. `STALE` is a destination several roads lead to -- ordinary decay + // reaches it too -- so a release with nothing recorded here could only guess. + val previousStatus = proposition.status val flagged = proposition .withStatus(PropositionStatus.STALE) .withMetadataValue(DiceMetadataKeys.QUARANTINE_REASON, reason) + .withMetadataValue(DriftQuarantineKeys.PREVIOUS_STATUS, previousStatus.name) logger.debug("Quarantining proposition '{}' (id={}): {}", proposition.text, proposition.id, reason) @@ -259,6 +262,7 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { proposition = flagged, reason = reason, affectedMentionTypes = affectedTypes, + previousStatus = previousStatus, ) } @@ -271,10 +275,10 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { alreadyQuarantined.size, quarantined.size, protected.size, - removedTypes, - lossyModified.keys, - narrowedProperties.keys, - narrowedRenames.keys, + diff.removedEntityTypes, + signals.lossyModified.keys, + signals.narrowedProperties.keys, + signals.narrowedRenames.keys, formerNamesMatched, ) @@ -286,6 +290,71 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { ) } + /** + * Every mention type name this policy could match under [diff], which is what a bounded sweep + * asks its store for. + * + * It is read off exactly the same [lossySignals] the evaluation uses, so the two can't drift + * apart: a name that would quarantine a proposition is a name a sweep asks for. Both spellings + * of every name are here, since a graph writes the simple label for a fully qualified + * declaration and a sweep must ask for what the store actually holds. + */ + override fun candidateMentionTypes(diff: MetamodelDiff): Set { + val signals = lossySignalsOf(diff) + val lossyNames = signals.lossyModified.keys + signals.narrowedProperties.keys + signals.narrowedRenames.keys + val formerNamesOfLossyTypes = signals.currentNamesByFormerName + .filterValues { currentNames -> currentNames.any { it in lossyNames } } + .keys + return java.util.Collections.unmodifiableSet( + sortedSetOf().apply { + addAll(signals.removedTypesBySpelling.keys) + addAll(signals.formerNamesOfRemovedTypes.keys) + addAll(lossyNames) + addAll(formerNamesOfLossyTypes) + }, + ) + } + + /** + * The lossy parts of a diff, gathered once and keyed by every spelling a mention could use. + * + * @property removedTypesBySpelling Each spelling of a removed type name, pointing at the + * declared name (or names) it stands for. + * @property lossyModified Types whose name survived and which lost labels or whole properties. + * Lossy, because a mention may have relied on a label or property that is now gone. + * @property narrowedProperties Types carrying a property that kept its name and narrowed. + * @property narrowedRenames Types carrying a property renamed and narrowed in the same step. The + * rename is harmless; the shape move underneath it is judged by the same rule as any other. + * @property currentNamesByFormerName Every name a surviving type has gone by, pointing at the + * name its changes are reported under. + * @property formerNamesOfRemovedTypes Every name a removed type had gone by, pointing at the + * removal. Two maps, read off opposite sides of the diff, because a removed type is absent + * from the newer side. + */ + private class LossySignals( + val removedTypesBySpelling: Map>, + val lossyModified: Map>, + val narrowedProperties: Map>, + val narrowedRenames: Map>, + val currentNamesByFormerName: Map>, + val formerNamesOfRemovedTypes: Map>, + ) + + private fun lossySignalsOf(diff: MetamodelDiff): LossySignals = LossySignals( + removedTypesBySpelling = bySpelling(diff.removedEntityTypes), + lossyModified = diff.modifiedEntityTypes + .filter { it.removedLabels.isNotEmpty() || it.removedProperties.isNotEmpty() } + .groupBySpelling { it.typeName }, + narrowedProperties = diff.propertySignatureChanges + .filter { isNarrowing(it.before, it.after) } + .groupBySpelling { it.typeName }, + narrowedRenames = diff.renamedProperties + .filter { isNarrowing(it.before, it.after) } + .groupBySpelling { it.typeName }, + currentNamesByFormerName = formerTypeNames(diff), + formerNamesOfRemovedTypes = formerNamesOfRemovedTypes(diff), + ) + /** * Whether a proposition is one an earlier sweep already handled: `STALE` *and* carrying a * quarantine reason. Both halves matter, because a proposition made stale by ordinary decay @@ -296,13 +365,14 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { proposition.metadata.containsKey(DiceMetadataKeys.QUARANTINE_REASON) /** - * Every name an entity type has gone by, mapped to what that type is called now. + * Every name an entity type has gone by, mapped to what that type is called now, under every + * spelling a mention could carry. * - * Read off the **newer version's whole declared alias map**, not just the renames this diff - * happens to contain. A rename and a loss usually land in different releases: stamp 2 renames - * `Person` to `Human`, stamp 3 drops a property, and the stamp-2-to-stamp-3 diff holds no rename - * at all while the graph still holds nodes labelled `Person` and the declaration still says - * `Human` used to be one. Keying off the diff's renames would let that loss pass over every + * Read off the **newer version's whole declared alias map**, and never off only the renames this + * diff happens to contain. A rename and a loss usually land in different releases: stamp 2 + * renames `Person` to `Human`, stamp 3 drops a property, and the stamp-2-to-stamp-3 diff holds + * no rename at all while the graph still holds nodes labelled `Person` and the declaration still + * says `Human` used to be one. Keying off the diff's renames would let that loss pass over every * proposition it stranded, silently, which is the direction this policy exists to avoid. * * Safe to read unconditionally because of the declaration guard: an alias may not name a type @@ -316,20 +386,25 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { private fun formerTypeNames(diff: MetamodelDiff): Map> { val byFormerName = mutableMapOf>() diff.toVersion.entityTypeAliases.forEach { (typeName, formerNames) -> - for (formerName in formerNames - typeName) { - byFormerName.getOrPut(formerName) { sortedSetOf() } += typeName + for (formerName in formerNames) { + for (spelling in spellingsOf(formerName) - spellingsOf(typeName)) { + byFormerName.getOrPut(spelling) { sortedSetOf() } += typeName + } } } - // A diff assembled by hand rather than by the differ can carry a rename whose old name the + // A diff assembled by hand, with no differ involved, can carry a rename whose old name the // stamp's alias map doesn't hold. for (rename in diff.renamedEntityTypes) { - byFormerName.getOrPut(rename.before) { sortedSetOf() } += rename.after + for (spelling in spellingsOf(rename.before)) { + byFormerName.getOrPut(spelling) { sortedSetOf() } += rename.after + } } return byFormerName } /** - * Every name a **removed** type had gone by, mapped to the removed type it belonged to. + * Every name a **removed** type had gone by, mapped to the removed type it belonged to, under + * every spelling a mention could carry. * * Read off the OLDER version, which is the only side that still has the entry. A removed type * takes its former names with it: `C` with former names `{A, B}` disappearing leaves @@ -341,23 +416,60 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { * A former name the newer version declares as a live type of its own is left out. Reusing a * retired name is legal once the type that claimed it is gone, and the removal's rationale is * that nothing describes those mentions any more, which is false when the schema declares a type - * by that exact name. Data under it is judged as that type's, like any other mention. + * by that exact name. Data under it is judged as that type's, like any other mention. The + * exclusion goes by spelling too: a graph writing `Person` can't tell a retired `Person` from a + * live `com.example.Person`. */ private fun formerNamesOfRemovedTypes(diff: MetamodelDiff): Map> { val removed = diff.removedEntityTypes if (removed.isEmpty()) return emptyMap() - val stillDeclared = diff.toVersion.entityTypeNames.toSet() + val stillDeclared = diff.toVersion.entityTypeNames.flatMapTo(mutableSetOf()) { spellingsOf(it) } val byFormerName = mutableMapOf>() for (typeName in removed) { val formerNames = diff.fromVersion.entityTypeAliases[typeName].orEmpty() - for (formerName in formerNames - typeName - stillDeclared) { - byFormerName.getOrPut(formerName) { sortedSetOf() } += typeName + for (formerName in formerNames) { + for (spelling in spellingsOf(formerName) - spellingsOf(typeName) - stillDeclared) { + byFormerName.getOrPut(spelling) { sortedSetOf() } += typeName + } } } return byFormerName } + /** + * The spellings one declared type name can appear under in a graph: the name itself, and the + * label that name writes onto a node. + * + * A stamp holds `com.example.Person` for a JVM-backed type while extraction records the mention + * as `Person`, so matching either spelling alone reads a lossy change as touching nothing. This + * is the same cut `DeclaredObservedDiffer` makes on the declared side of a drift comparison, so + * the two halves of a drift check agree about which type is which. + */ + private fun spellingsOf(typeName: String): Set = + setOf(typeName, DeclaredSchema.ownLabelOf(typeName)) + + /** Each spelling of each name, pointing at the declared name (or names) it stands for. */ + private fun bySpelling(names: Collection): Map> { + val bySpelling = mutableMapOf>() + for (name in names) { + for (spelling in spellingsOf(name)) { + bySpelling.getOrPut(spelling) { sortedSetOf() } += name + } + } + return bySpelling + } + + /** + * Group changes under every spelling of the type name [typeNameOf] reads off them. A list per + * key, since two declared types in different packages share one label and a graph can't tell + * them apart. + */ + private fun List.groupBySpelling(typeNameOf: (T) -> String): Map> = + flatMap { change -> spellingsOf(typeNameOf(change)).map { it to change } } + .groupBy({ it.first }, { it.second }) + + /** * Whether a property's new shape might not hold what its old shape did. * diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/PropositionStoreDriftSweep.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/PropositionStoreDriftSweep.kt new file mode 100644 index 00000000..80de9a2f --- /dev/null +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/PropositionStoreDriftSweep.kt @@ -0,0 +1,161 @@ +/* + * 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.agent.core.ContextId +import com.embabel.dice.common.DiceEventListener +import com.embabel.dice.common.DiceMetadataKeys +import com.embabel.dice.common.PropositionStatusChanged +import com.embabel.dice.metamodel.DriftQuarantineKeys +import com.embabel.dice.metamodel.DriftSweepCapable +import com.embabel.dice.metamodel.QuarantineDecision +import com.embabel.dice.proposition.Proposition +import com.embabel.dice.proposition.PropositionStatus +import com.embabel.dice.proposition.PropositionStore +import org.slf4j.LoggerFactory +import java.time.Instant + +/** + * The reference [DriftSweepCapable]: a working sweep over any [PropositionStore], and the executable + * statement of what the contract means. + * + * It is correct for every backend, so a host can sweep on day one, and a durable store can be held + * to the same suite of tests once it implements [DriftSweepCapable] natively. It is honest about the + * cost: a plain [PropositionStore] can filter by context and nothing else, so this class reads one + * context's propositions and applies the mention-type filter, the ordering and the page bound in the + * JVM. The context bound is real — the read never leaves the context, so no other tenant's data is + * ever materialised — and the rest is the part a backend should push down. + * + * Because it does its own paging over a store read, a context whose propositions change underneath a + * long sweep can shift between pages. That is inherent to paging a live store, and it is safe here: + * a proposition the sweep misses is caught by the next check, and one it sees twice comes back as + * already quarantined. + * + * @param propositions Where candidates are read from and quarantined copies are saved back to. The + * base persistence port: a sweep reads by context and saves, so requiring vector search, graph + * traversal and temporal query alongside would shut a plain store-and-retrieve backend out of + * drift work over capabilities it never uses. + * @param listener Told about each real status transition as a [PropositionStatusChanged], so a + * consumer like `ProjectionLineageStaleCascade` hears about a quarantine, and about a release, + * without depending on whichever concrete [propositions] store happens to be wired in. Defaults to + * a no-op: everything else here holds with nobody listening. + */ +class PropositionStoreDriftSweep @JvmOverloads constructor( + private val propositions: PropositionStore, + private val listener: DiceEventListener = DiceEventListener.DEV_NULL, +) : DriftSweepCapable { + + private val logger = LoggerFactory.getLogger(PropositionStoreDriftSweep::class.java) + + /** + * Reads the one context through [PropositionStore.findByContextId], then filters, sorts and + * pages in the JVM. + * + * The context read is the part that matters for safety: a whole-store read would materialise + * every tenant, and there is no call to one here. A backend implementing [DriftSweepCapable] + * itself turns the three steps after the read into query clauses. + */ + override fun quarantineCandidates( + contextId: ContextId, + mentionTypes: Set, + limit: Int, + afterId: String?, + ): List { + require(limit > 0) { "limit must be positive, but was $limit" } + if (mentionTypes.isEmpty()) return emptyList() + + return propositions.findByContextId(contextId) + .filter { proposition -> proposition.mentions.any { it.type in mentionTypes } } + .sortedBy { it.id } + .filter { afterId == null || it.id > afterId } + .take(limit) + } + + override fun applyQuarantine(decision: QuarantineDecision.Quarantined): Proposition { + val saved = propositions.save(decision.proposition) + logger.debug("Quarantined proposition (id={}): {}", saved.id, decision.reason) + // A proposition can arrive already STALE from ordinary decay (no quarantine reason yet, so + // the policy still treats it as a fresh candidate) and get quarantined without its status + // actually moving. Announcing a transition then would be a lie the listener has no way to + // catch, so this only fires when something really changed. + announce(saved, decision.previousStatus, saved.status, decision.reason) + return saved + } + + /** + * Restores the status recorded under [DriftQuarantineKeys.PREVIOUS_STATUS] and drops both + * quarantine keys in one save. + * + * A proposition with no readable previous status — quarantined by an older policy, or with the + * key edited away — goes back to [PropositionStatus.ACTIVE]. Releasing says "let this back into + * use", and `ACTIVE` is what that means when the record of where it came from is gone. + */ + override fun releaseFromQuarantine(propositionId: String): Proposition? { + val quarantined = propositions.findById(propositionId) ?: return null + if (!quarantined.metadata.containsKey(DiceMetadataKeys.QUARANTINE_REASON)) { + logger.debug("Proposition (id={}) carries no quarantine reason; nothing to release", propositionId) + return null + } + + val restoredStatus = previousStatusOf(quarantined) + val released = propositions.save( + quarantined.copy( + status = restoredStatus, + metadata = quarantined.metadata - + DiceMetadataKeys.QUARANTINE_REASON - + DriftQuarantineKeys.PREVIOUS_STATUS, + metadataRevised = Instant.now(), + ), + ) + logger.debug("Released proposition (id={}) back to {}", released.id, restoredStatus) + announce(released, quarantined.status, restoredStatus, RELEASE_REASON) + return released + } + + /** + * The status this proposition carried before it was quarantined, or [PropositionStatus.ACTIVE] + * when nothing readable was recorded. + */ + private fun previousStatusOf(proposition: Proposition): PropositionStatus { + val recorded = proposition.metadata[DriftQuarantineKeys.PREVIOUS_STATUS] as? String + ?: return PropositionStatus.ACTIVE + return runCatching { PropositionStatus.valueOf(recorded) }.getOrDefault(PropositionStatus.ACTIVE) + } + + /** Tell the listener, and only when the status genuinely moved. */ + private fun announce( + proposition: Proposition, + previousStatus: PropositionStatus, + newStatus: PropositionStatus, + reason: String, + ) { + if (previousStatus == newStatus) return + listener.onEvent( + PropositionStatusChanged( + proposition = proposition, + previousStatus = previousStatus, + newStatus = newStatus, + reason = reason, + ), + ) + } + + private companion object { + + /** What a release event says, since a release clears the reason the quarantine carried. */ + const val RELEASE_REASON = "Released from schema drift quarantine" + } +} diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt index 2ccf4eff..e502647c 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftCheckRunnerTest.kt @@ -17,29 +17,13 @@ package com.embabel.dice.metamodel import com.embabel.agent.core.Cardinality import com.embabel.agent.core.ContextId -import com.embabel.dice.common.DiceEvent -import com.embabel.dice.common.DiceEventListener -import com.embabel.dice.common.DiceMetadataKeys -import com.embabel.dice.common.PropositionStatusChanged -import com.embabel.dice.common.SafeDiceEventListener import com.embabel.dice.metamodel.support.DefaultDriftCheckRunner -import com.embabel.dice.metamodel.support.MentionTypeDriftQuarantinePolicy import com.embabel.dice.metamodel.support.StructuralMetamodelDiffer -import com.embabel.dice.projection.lineage.InMemoryProjectionRecordStore -import com.embabel.dice.projection.lineage.ProjectionLifecycle -import com.embabel.dice.projection.lineage.ProjectionLineageStaleCascade -import com.embabel.dice.projection.lineage.ProjectionRecord -import com.embabel.dice.proposition.EntityMention -import com.embabel.dice.proposition.MentionRole -import com.embabel.dice.proposition.Proposition import com.embabel.dice.proposition.PropositionStore -import com.embabel.dice.proposition.PropositionStatus -import com.embabel.dice.proposition.store.InMemoryPropositionRepository import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -47,19 +31,20 @@ import java.time.Instant /** * [DefaultDriftCheckRunner] against fake sources and stores, with the real - * [StructuralMetamodelDiffer] and [MentionTypeDriftQuarantinePolicy], each covered on its own - * elsewhere. These tests exercise the actual delegation rather than a stand-in for it. + * [StructuralMetamodelDiffer], which is covered on its own elsewhere. These tests exercise the + * actual delegation and never a stand-in for it. + * + * A check reports and changes nothing, so everything here is about what reaches the report and what + * the runner asks its stores for. Sweeping lives in `DriftSweepTest`. */ class DriftCheckRunnerTest { private val contextId = ContextId("test-context") - private val otherContextId = ContextId("other-context") private val schemaName = "test-schema" private val capturedAt = Instant.parse("2026-01-01T00:00:00Z") private lateinit var versionStore: InMemoryMetamodelVersionStore private lateinit var reportStore: OrderRecordingDriftReportStore - private lateinit var propositionStore: InMemoryPropositionRepository /** Declared schema, overridable per test. Defaults to two types and one relationship. */ private var declaredEntityTypes = listOf("Person", "Company") @@ -75,7 +60,6 @@ class DriftCheckRunnerTest { fun setUp() { versionStore = InMemoryMetamodelVersionStore() reportStore = OrderRecordingDriftReportStore(versionStore) - propositionStore = InMemoryPropositionRepository() } private fun declaredVersion(): MetamodelVersion = MetamodelVersion( @@ -86,12 +70,7 @@ class DriftCheckRunnerTest { relationshipNames = declaredRelationshipTypeNames.map { "Person-[$it]->Company" }, ) - // Typed as the base persistence port rather than PropositionRepository, so whatever a test - // passes in, the runner only gets store-and-retrieve out of it. private fun buildRunner( - store: PropositionStore = propositionStore, - listener: DiceEventListener = DiceEventListener.DEV_NULL, - quarantinePolicy: DriftQuarantinePolicy = MentionTypeDriftQuarantinePolicy(), versionStore: MetamodelVersionStore = this.versionStore, ): DriftCheckRunner { val declaredSchema = DeclaredSchema( @@ -119,9 +98,6 @@ class DriftCheckRunnerTest { differ = differ, metamodelDiffer = differ, driftReportStore = reportStore, - quarantinePolicy = quarantinePolicy, - propositionStore = store, - listener = listener, ) } @@ -137,19 +113,6 @@ class DriftCheckRunnerTest { relationshipNames = declaredRelationshipTypeNames.map { "Person-[$it]->Company" }, ) - private fun proposition( - text: String, - vararg mentionTypes: String, - inContext: ContextId = contextId, - ): Proposition = Proposition( - contextId = inContext, - text = text, - mentions = mentionTypes.map { type -> - EntityMention(span = type.lowercase(), type = type, role = MentionRole.SUBJECT) - }, - confidence = 0.9, - ) - private fun savedReports(): List = reportStore.driftReports(schemaName, limit = 100) // ---- Stamping ---- @@ -158,7 +121,7 @@ class DriftCheckRunnerTest { fun `every run stamps the declared version, so the report's hash resolves`() { val runner = buildRunner() - val result = runner.run(dryRun = true) + val result = runner.run() val resolved = versionStore.findVersion(schemaName, result.report.versionHash) assertNotNull(resolved, "a report's versionHash is useless if nothing ever recorded that stamp") @@ -166,13 +129,13 @@ class DriftCheckRunnerTest { } @Test - fun `the stamp is written before the report, not after`() { + fun `the stamp is written before the report`() { // The ordering is why the stamp is written every run. A report written first would name a // version hash nothing had recorded, for the length of that window, and permanently if the // second write failed. val runner = buildRunner() - runner.run(dryRun = true) + runner.run() assertTrue( reportStore.versionWasResolvableWhenReportSaved.single(), @@ -181,31 +144,40 @@ class DriftCheckRunnerTest { } @Test - fun `a repeated run re-stamps idempotently rather than growing history`() { + fun `a repeated run re-stamps idempotently and leaves history one record long`() { val runner = buildRunner() - runner.run(dryRun = true) - runner.run(dryRun = true) - runner.run(dryRun = true) + runner.run() + runner.run() + runner.run() - assertEquals(3, versionStore.saveCount, "the stamp is attempted every run") - assertEquals(1, versionStore.versionHistory(schemaName).size, "but an unchanged schema stores once") + assertEquals(1, versionStore.versionHistory(schemaName).size, "an unchanged schema stores once") assertEquals(3, savedReports().size, "while every check leaves its own report") } + @Test + fun `the baseline is read before this run's own stamp is written`() { + // Reading afterwards would hand back the stamp this very run just wrote, so the + // declared-vs-previous comparison would compare a declaration against itself. + val recording = CallRecordingVersionStore() + val runner = buildRunner(versionStore = recording) + + runner.run() + + assertEquals(listOf("sweptVersion", "saveVersion"), recording.calls) + } + // ---- Reporting ---- @Test fun `zero drift still leaves a retrievable report`() { val runner = buildRunner() - val result = runner.run(dryRun = true) + val result = runner.run() assertFalse(result.hasDrift) assertTrue(result.driftedEntityTypes.isEmpty()) assertTrue(result.driftedRelationshipTypes.isEmpty()) - assertEquals(0, result.quarantinedCount) - assertTrue(result.dryRun) val reports = savedReports() assertEquals(1, reports.size, "even a clean check must leave a record behind") @@ -214,11 +186,13 @@ class DriftCheckRunnerTest { } @Test - fun `the report is stamped with the snapshot's instant, not the write's`() { + fun `the report is stamped with the snapshot's instant`() { + // The report is a statement about the observation, so it carries the observation's instant + // and never the instant of the write. observedEntityTypes = setOf("Person", "Company", "GhostType") val runner = buildRunner() - val result = runner.run(dryRun = true) + val result = runner.run() assertEquals(capturedAt, result.report.capturedAt) } @@ -229,7 +203,7 @@ class DriftCheckRunnerTest { observedRelationshipTypeNames = setOf("WORKS_AT", "UNDECLARED_LINK") val runner = buildRunner() - val result = runner.run(dryRun = true) + val result = runner.run() val saved = savedReports().single() assertEquals(saved.driftedEntityTypes, result.driftedEntityTypes) @@ -238,361 +212,166 @@ class DriftCheckRunnerTest { assertEquals(saved.contextId, result.contextId) } - // ---- Dry run vs. live ---- - @Test - fun `drift with dryRun persists the report but quarantines nothing`() { + fun `the no-argument run covers the whole graph`() { observedEntityTypes = setOf("Person", "Company", "GhostType") - propositionStore.save(proposition("a ghost was mentioned", "GhostType")) - val runner = buildRunner() - - val result = runner.run(dryRun = true) - - assertEquals(setOf("GhostType"), result.driftedEntityTypes) - assertEquals(0, result.quarantinedCount) - assertTrue(result.dryRun) - assertEquals(setOf("GhostType"), savedReports().single().driftedEntityTypes) - - val untouched = propositionStore.findAll().single() - assertEquals(PropositionStatus.ACTIVE, untouched.status) - assertNull(untouched.metadata[DiceMetadataKeys.QUARANTINE_REASON]) - } - - @Test - fun `the default run is a dry whole-graph check`() { - observedEntityTypes = setOf("Person", "Company", "GhostType") - propositionStore.save(proposition("a ghost was mentioned", "GhostType")) val runner = buildRunner() val result = runner.run() - assertTrue(result.dryRun, "the no-argument form must be the safe one") assertNull(result.contextId) - assertEquals(PropositionStatus.ACTIVE, propositionStore.findAll().single().status) - } - - @Test - fun `a live run delegates quarantine to the configured policy and persists what it flags`() { - observedEntityTypes = setOf("Person", "Company", "GhostType") - val affected = propositionStore.save(proposition("a ghost was mentioned", "GhostType")) - val safe = propositionStore.save(proposition("Alice works at Acme", "Person", "Company")) - val runner = buildRunner() - - val result = runner.run(dryRun = false) - assertEquals(setOf("GhostType"), result.driftedEntityTypes) - assertEquals(1, result.quarantinedCount) - assertFalse(result.dryRun) - - val quarantined = propositionStore.findById(affected.id)!! - assertEquals(PropositionStatus.STALE, quarantined.status) - assertNotNull(quarantined.metadata[DiceMetadataKeys.QUARANTINE_REASON]) - - assertEquals(PropositionStatus.ACTIVE, propositionStore.findById(safe.id)!!.status) - assertEquals(1, savedReports().size, "the report is written on a live run just the same") } + // ---- A check changes nothing ---- + @Test - fun `quarantine's status change reaches projection lineage through the listener`() { - // ProjectionLineageStaleCascade is how a proposition going STALE is supposed to mark its - // projection records stale in turn (see that class); it reacts to PropositionStatusChanged. - // Wiring the runner's listener straight to it is what routes a quarantine transition there. - observedEntityTypes = setOf("Person", "Company", "GhostType") - val affected = propositionStore.save(proposition("a ghost was mentioned", "GhostType")) - val recordStore = InMemoryProjectionRecordStore() - recordStore.record( - ProjectionRecord( - propositionId = affected.id, - target = "test-target", - lifecycle = ProjectionLifecycle.PROJECTED, - runId = "run-1", - ), - ) - val cascade = ProjectionLineageStaleCascade(recordStore) - val runner = buildRunner(listener = SafeDiceEventListener(cascade)) + fun `many checks in a row move the swept baseline nowhere`() { + // The baseline moves when a host says a sweep finished. A check reads it, diffs against it, + // and leaves it alone however many times it runs -- otherwise the very next check would + // compare the declaration against itself and the lossy change would vanish unswept. + val baseline = previousVersionWithPersonAge() + versionStore.markSwept(baseline) + val recording = CallRecordingVersionStore(delegate = versionStore) + val runner = buildRunner(versionStore = recording) - val result = runner.run(dryRun = false) + repeat(5) { runner.run() } - assertEquals(1, result.quarantinedCount, "sanity: the quarantine itself did happen") - assertEquals(PropositionStatus.STALE, propositionStore.findById(affected.id)!!.status) + assertEquals(baseline, versionStore.sweptVersion(schemaName), "five checks retired nothing") assertEquals( - ProjectionLifecycle.STALE, - recordStore.findByProposition(affected.id).single().lifecycle, - "the cascade heard about the transition and marked its record stale in turn", + List(5) { listOf("sweptVersion", "saveVersion") }.flatten(), + recording.calls, + "a check reads the baseline and stamps its declaration; markSwept is absent: ${recording.calls}", ) + repeat(5) { run -> + assertNotNull( + savedReports()[run].declaredDiff, + "every one of the five checks still saw the same unreconciled change", + ) + } } @Test - fun `a conforming proposition emits no status-changed event`() { - // The listener should hear about quarantines, not about every proposition the sweep looked - // at; a conforming proposition's status never moved and nothing should say it did. - observedEntityTypes = setOf("Person", "Company", "GhostType") - propositionStore.save(proposition("Alice works at Acme", "Person", "Company")) - val recording = RecordingDiceEventListener() - val runner = buildRunner(listener = recording) - - runner.run(dryRun = false) - - assertTrue(recording.events.isEmpty()) - } - - @Test - fun `a dry run never emits a status-changed event`() { - observedEntityTypes = setOf("Person", "Company", "GhostType") - propositionStore.save(proposition("a ghost was mentioned", "GhostType")) - val recording = RecordingDiceEventListener() - val runner = buildRunner(listener = recording) - - runner.run(dryRun = true) - - assertTrue(recording.events.isEmpty(), "a dry run must not announce a transition it never made") - } - - @Test - fun `the emitted event carries the quarantine reason and the previous status`() { - observedEntityTypes = setOf("Person", "Company", "GhostType") - propositionStore.save(proposition("a ghost was mentioned", "GhostType")) - val recording = RecordingDiceEventListener() - val runner = buildRunner(listener = recording) - - runner.run(dryRun = false) - - val event = recording.events.filterIsInstance().single() - assertEquals(PropositionStatus.ACTIVE, event.previousStatus) - assertEquals(PropositionStatus.STALE, event.newStatus) - assertNotNull(event.reason) - assertTrue(event.reason!!.contains("GhostType")) - } - - @Test - fun `a plain store-and-retrieve backend can drive a live run`() { - // The runner asks for the base persistence port, so a backend with no vector search, graph - // traversal or temporal query can still check for drift. - observedEntityTypes = setOf("Person", "Company", "GhostType") - val affected = propositionStore.save(proposition("a ghost was mentioned", "GhostType")) - val bareStore: PropositionStore = RecordingPropositionStore(propositionStore) - val runner = buildRunner(bareStore) - - val result = runner.run(dryRun = false) - - assertEquals(1, result.quarantinedCount) - assertEquals(PropositionStatus.STALE, propositionStore.findById(affected.id)!!.status) - } - - @Test - fun `a live run with only relationship drift never quarantines`() { - // Nothing a mention's type can match, so a live run must touch nothing. - observedRelationshipTypeNames = setOf("WORKS_AT", "UNDECLARED_LINK") - propositionStore.save(proposition("Alice works at Acme", "Person", "Company")) - val runner = buildRunner() - - val result = runner.run(dryRun = false) - - assertTrue(result.driftedEntityTypes.isEmpty()) - assertEquals(setOf("UNDECLARED_LINK"), result.driftedRelationshipTypes) - assertEquals(0, result.quarantinedCount) - assertEquals(PropositionStatus.ACTIVE, propositionStore.findAll().single().status) - } - - // ---- Declared-vs-previous drift ---- - - @Test - fun `a lossy declared change with no observed drift still reaches quarantine`() { - // Person was stamped with an `age` property on an earlier, completed live check. The - // CURRENT declaration has dropped it, but the observed graph matches the current - // declaration exactly (default observedEntityTypes), so diffAgainstObserved alone — - // declared vs. what the graph holds right now — finds nothing: there is no - // undeclared-but-observed type or label anywhere. Only a declared-vs-previous-declared - // comparison sees the property actually vanished. - val previousVersion = previousVersionWithPersonAge() - versionStore.markSwept(previousVersion) - val mentioning = propositionStore.save(proposition("Alice is 40", "Person")) - val runner = buildRunner() - - val result = runner.run(dryRun = false) + fun `the runner is built with no proposition store at all`() { + // The structural half of "a check changes nothing": there is no collaborator through which + // a check could reach a proposition, so no amount of drift can move one. + val parameterTypes = DefaultDriftCheckRunner::class.java.constructors + .flatMap { it.parameterTypes.asIterable() } - assertTrue(result.driftedEntityTypes.isEmpty(), "sanity: no declared-vs-observed drift at all") - assertEquals(1, result.quarantinedCount, "the declared property removal must still reach quarantine") - val quarantined = propositionStore.findById(mentioning.id)!! - assertEquals(PropositionStatus.STALE, quarantined.status) assertTrue( - (quarantined.metadata[DiceMetadataKeys.QUARANTINE_REASON] as String).contains("age"), - "the reason should name the property the declaration dropped", + parameterTypes.none { PropositionStore::class.java.isAssignableFrom(it) }, + "a drift check must have no way to reach propositions, but found: $parameterTypes", ) } - @Test - fun `the baseline is read before this run's own history write, even on a store with no independent tracking`() { - // InMemoryMetamodelVersionStore (every other test in this class) tracks sweptVersion - // independently of latestVersion, so it can't expose a read-before-save ordering bug: even a - // buggy read-after-save would still see the old baseline through the independent pointer. - // DefaultForwardingVersionStore has no such safety net -- sweptVersion falls through to the - // interface default, latestVersion, which moves the instant saveVersion runs. If the runner - // ever read the baseline after stamping the current declaration into history, this store - // would hand back the declaration that write just made current, the declared-vs-previous - // diff would compare that against itself, and the lossy change below would go uncaught. - val forwardingStore = DefaultForwardingVersionStore() - val previousVersion = previousVersionWithPersonAge() - forwardingStore.saveVersion(previousVersion) - val mentioning = propositionStore.save(proposition("Alice is 40", "Person")) - val runner = buildRunner(versionStore = forwardingStore) - - val result = runner.run(dryRun = false) - - assertEquals( - 1, - result.quarantinedCount, - "the declared property removal must still reach quarantine, proving the baseline was " + - "read before this run's own stamp overwrote what latestVersion answers", - ) - assertEquals(PropositionStatus.STALE, propositionStore.findById(mentioning.id)!!.status) - } + // ---- Declared-vs-previous drift reaches the report ---- @Test - fun `establishing the baseline on the first live check means a later identical declaration finds nothing new`() { - // No prior sweep exists for this schema, so sweptVersion is null and the declared-vs- - // previous comparison doesn't run at all on the first check — it must not throw, and it - // must not just happen to find nothing because it never looked: the second run below - // proves the first run actually established a baseline, not merely that it stayed silent. - propositionStore.save(proposition("Alice is a person", "Person")) + fun `a lossy declared change with no observed drift still reaches the report`() { + // Person was stamped with an `age` property on an earlier, completed sweep. The CURRENT + // declaration has dropped it, but the observed graph matches the current declaration exactly + // (default observedEntityTypes), so diffAgainstObserved alone -- declared against what the + // graph holds right now -- finds nothing: there is no undeclared-but-observed type or label + // anywhere. Only a declared-vs-previous-declared comparison sees the property vanished. + versionStore.markSwept(previousVersionWithPersonAge()) val runner = buildRunner() - val first = runner.run(dryRun = false) - - assertEquals(0, first.quarantinedCount, "nothing to compare the very first check against") - assertEquals( - declaredVersion(), - versionStore.sweptVersion(schemaName), - "completing the first live check must establish the baseline for the next one", - ) - - val second = runner.run(dryRun = false) + val result = runner.run() + assertTrue(result.driftedEntityTypes.isEmpty(), "sanity: no declared-vs-observed drift at all") + assertFalse(result.hasDrift, "the graph-truth half of the report is clean") + assertTrue(result.hasAnyChange, "and the report still says something happened") + assertNotNull(result.declaredDiff, "the declared comparison must reach the report") + val declaredDiff = result.declaredDiff!! assertEquals( - 0, - second.quarantinedCount, - "reading the baseline after it was overwritten, or never establishing it, could each " + - "produce a wrong non-zero result here just as easily as the correct zero", + listOf("age"), + declaredDiff.modifiedEntityTypes.single { it.typeName == "Person" }.removedPropertyNames.toList(), + "the report must name the property the declaration dropped: $declaredDiff", ) } @Test - fun `a purely additive declared change does not quarantine`() { - val previousVersion = MetamodelVersion( - schemaName = schemaName, - entityTypeNames = listOf("Person"), - entityTypeLabels = mapOf("Person" to setOf("Person")), - entityTypeProperties = mapOf("Person" to emptySet()), - relationshipNames = emptyList(), - ) - versionStore.markSwept(previousVersion) - // declaredEntityTypes defaults to Person, Company — an added type versus previousVersion. - propositionStore.save(proposition("Alice is a person", "Person")) + fun `the report a store hands back carries the declared comparison too`() { + // The fix for the dry-check blind spot only holds if declaredDiff survives the round trip + // through DriftReportStore; a result-only field would leave an operator reading a stored + // report exactly as blind as before. + versionStore.markSwept(previousVersionWithPersonAge()) val runner = buildRunner() - val result = runner.run(dryRun = false) + val result = runner.run() - assertEquals(0, result.quarantinedCount, "a purely additive declared change is not lossy") + assertEquals(result.declaredDiff, savedReports().single().declaredDiff) + assertEquals(result.report, savedReports().single()) } @Test - fun `a dry run does not consume a lossy declared change -- the next live run still catches it`() { - val previousVersion = previousVersionWithPersonAge() - versionStore.markSwept(previousVersion) - val mentioning = propositionStore.save(proposition("Alice is 40", "Person")) + fun `the reported comparison is what a sweep would evaluate`() { + // The whole point of carrying declaredDiff: quarantineDiff, built from the report alone, + // must be the same object a deliberate sweep evaluates propositions against. + versionStore.markSwept(previousVersionWithPersonAge()) + observedEntityTypes = setOf("Person", "Company", "GhostType") val runner = buildRunner() - val dry = runner.run(dryRun = true) + val result = runner.run() - assertEquals(0, dry.quarantinedCount, "sanity: a dry run never quarantines") assertEquals( - previousVersion, - versionStore.sweptVersion(schemaName), - "a dry run only read the baseline; it must not retire it", + result.quarantineDiff, + savedReports().single().quarantineDiff(result.declaredVersion), + "a stored report resolves to the same comparison the live result did", + ) + assertEquals( + setOf("GhostType"), + result.quarantineDiff.removedEntityTypes, + "observed drift arrives as a removal a policy can judge", + ) + assertEquals( + listOf("age"), + result.quarantineDiff.modifiedEntityTypes.single().removedPropertyNames.toList(), + "and the declared property removal rides in the same diff", ) - assertEquals(PropositionStatus.ACTIVE, propositionStore.findById(mentioning.id)!!.status) - - val live = runner.run(dryRun = false) - - assertEquals(1, live.quarantinedCount, "the lossy declared change must still reach quarantine") - assertEquals(PropositionStatus.STALE, propositionStore.findById(mentioning.id)!!.status) } @Test - fun `a context-scoped live run does not retire the baseline, so a later run still reaches other contexts`() { - val previousVersion = previousVersionWithPersonAge() - versionStore.markSwept(previousVersion) - val inA = propositionStore.save(proposition("Alice is 40", "Person", inContext = contextId)) - val inB = propositionStore.save(proposition("Bob is 50", "Person", inContext = otherContextId)) + fun `a first check has no baseline, so it reports no declared comparison`() { + // Nothing has ever been swept for this schema, so sweptVersion is null and the + // declared-vs-previous comparison doesn't run. It must not throw, and it must say so. val runner = buildRunner() - val scoped = runner.run(dryRun = false, contextId = contextId) + val first = runner.run() - assertEquals(1, scoped.quarantinedCount, "context A's candidate is reachable straight away") - assertEquals(PropositionStatus.STALE, propositionStore.findById(inA.id)!!.status) - assertEquals( - PropositionStatus.ACTIVE, - propositionStore.findById(inB.id)!!.status, - "sanity: the scoped run never touched context B", - ) - assertEquals( - previousVersion, - versionStore.sweptVersion(schemaName), - "a run scoped to one context must not retire the schema-wide baseline", - ) + assertNull(first.declaredDiff, "there is nothing to compare a schema's very first check against") + assertNull(versionStore.sweptVersion(schemaName), "and the check established no baseline either") - val later = runner.run(dryRun = false, contextId = otherContextId) + // A host sweeps and marks. From then on the comparison runs and finds nothing new. + versionStore.markSwept(declaredVersion()) + val second = runner.run() - assertEquals(1, later.quarantinedCount, "the same declared-vs-previous drift is still there for B") - assertEquals(PropositionStatus.STALE, propositionStore.findById(inB.id)!!.status) + assertNotNull(second.declaredDiff, "a marked baseline is a baseline the next check reads") + val declaredDiff = second.declaredDiff!! + assertTrue(declaredDiff.isEmpty, "the declaration hasn't moved since the sweep: $declaredDiff") } @Test - fun `a crash mid-sweep leaves the baseline unmoved, so the next check retries the same comparison`() { - val previousVersion = previousVersionWithPersonAge() - versionStore.markSwept(previousVersion) - val mentioning = propositionStore.save(proposition("Alice is 40", "Person")) - val crashingStore = object : PropositionStore by propositionStore { - override fun save(proposition: Proposition): Proposition = - throw IllegalStateException("simulated crash mid-sweep") - } - val crashingRunner = buildRunner(store = crashingStore) + fun `a version store that tracks no baseline reports no declared comparison`() { + // A store implements SweptBaselineStore when it can. One that can't gets the graph-truth + // half and an honest null, which beats a baseline guessed from write order: that guess moves + // on every ordinary stamp, so it would quietly retire changes nothing had swept for. + val untracked = BaselineFreeVersionStore() + untracked.saveVersion(previousVersionWithPersonAge()) + val runner = buildRunner(versionStore = untracked) - assertThrows(IllegalStateException::class.java) { crashingRunner.run(dryRun = false) } - - assertEquals( - previousVersion, - versionStore.sweptVersion(schemaName), - "an interrupted sweep must not look like a completed reconciliation", - ) - assertEquals( - PropositionStatus.ACTIVE, - propositionStore.findById(mentioning.id)!!.status, - "sanity: the crashing save never actually landed", - ) - - // The retry: a fresh runner over the same stores, this time able to actually save. Nothing - // about the earlier crash should have consumed or altered the comparison it interrupted. - val retryRunner = buildRunner(store = propositionStore) - - val retried = retryRunner.run(dryRun = false) + val result = runner.run() - assertEquals(1, retried.quarantinedCount, "the retry must still catch the same lossy change") - assertEquals(PropositionStatus.STALE, propositionStore.findById(mentioning.id)!!.status) - assertEquals( - declaredVersion(), - versionStore.sweptVersion(schemaName), - "the retry's own completed sweep is what finally advances the baseline", - ) + assertNull(result.declaredDiff) + assertFalse(result.hasAnyChange) } @Test - fun `a declaration reverted to an earlier stamp is still diffed against what was actually swept`() { - // A (with `age`) -> B (without, swept) -> A again (age restored, saved but never swept) -> - // B declared again. latestVersion answers B the whole way through, because re-saving A - // keeps its original write-order position (MetamodelVersionStore's own saveVersion - // contract), so a runner trusting it would diff B against B at the last step and miss that - // `age` just vanished again. sweptVersion must not make that mistake. + fun `a declaration reverted to an earlier stamp is diffed against what was actually swept`() { + // A (with `age`) -> B (without, swept) -> A again (a plain re-save) -> B declared again. + // latestVersion answers B the whole way through, because re-saving A keeps its original + // write-order position (MetamodelVersionStore.saveVersion's contract), so a runner trusting + // it would diff B against B at the last step and miss that `age` just vanished again. val a = previousVersionWithPersonAge() val b = MetamodelVersion( schemaName = schemaName, @@ -603,73 +382,60 @@ class DriftCheckRunnerTest { ) versionStore.markSwept(a) versionStore.markSwept(b) - versionStore.saveVersion(a) // re-save only -- not a sweep + versionStore.saveVersion(a) // a re-save only, and never a sweep assertEquals(b, versionStore.latestVersion(schemaName), "sanity: latestVersion still answers B") - assertEquals(b, versionStore.sweptVersion(schemaName), "sanity: B is still the reconciled baseline") - versionStore.markSwept(a) - assertEquals( - a, - versionStore.sweptVersion(schemaName), - "sweptVersion tracks the pointer, not write order -- unlike latestVersion above", - ) - // The schema drops `age` again (declares B's shape again). Diffing against sweptVersion - // (A) catches the reversion; diffing against latestVersion (B, unchanged since the last - // markSwept(b) two lines up) would compare B against B and find nothing. + // The schema drops `age` again (declares B's shape again). Diffing against sweptVersion (A) + // catches the reversion; diffing against latestVersion (B) would compare B with B. declaredEntityTypeProperties = mapOf("Person" to emptySet(), "Company" to emptySet()) - val mentioning = propositionStore.save(proposition("Alice is 40", "Person")) val runner = buildRunner() - val result = runner.run(dryRun = false) + val result = runner.run() - assertEquals(1, result.quarantinedCount, "the reverted removal of `age` must be caught") - assertEquals(PropositionStatus.STALE, propositionStore.findById(mentioning.id)!!.status) + assertNotNull(result.declaredDiff, "sweptVersion answers A, so the comparison runs") + val declaredDiff = result.declaredDiff!! + assertEquals( + listOf("age"), + declaredDiff.modifiedEntityTypes.single { it.typeName == "Person" }.removedPropertyNames.toList(), + "the reverted removal of `age` must be caught: $declaredDiff", + ) } @Test - fun `a proposition already STALE from decay emits no status-changed event when quarantined`() { - // The idempotency check only skips a proposition that's already quarantined (STALE with a - // reason); one that's STALE from ordinary decay, with no reason yet, is still a fresh - // candidate and does get quarantined -- but its status doesn't move, so no event should say - // it did. - val previousVersion = previousVersionWithPersonAge() - versionStore.markSwept(previousVersion) - val decayed = propositionStore.save( - proposition("Alice is 40", "Person").withStatus(PropositionStatus.STALE), + fun `a purely additive declared change leaves an empty comparison`() { + val previousVersion = MetamodelVersion( + schemaName = schemaName, + entityTypeNames = listOf("Person"), + entityTypeLabels = mapOf("Person" to setOf("Person")), + entityTypeProperties = mapOf("Person" to emptySet()), + relationshipNames = emptyList(), ) - val recording = RecordingDiceEventListener() - val runner = buildRunner(listener = recording) + versionStore.markSwept(previousVersion) + // declaredEntityTypes defaults to Person, Company -- an added type versus previousVersion. + val runner = buildRunner() - val result = runner.run(dryRun = false) + val result = runner.run() - assertEquals(1, result.quarantinedCount, "sanity: it was quarantined") - val quarantined = propositionStore.findById(decayed.id)!! - assertEquals(PropositionStatus.STALE, quarantined.status) - assertNotNull( - quarantined.metadata[DiceMetadataKeys.QUARANTINE_REASON], - "sanity: the reason was written even though the status didn't move", - ) + assertNotNull(result.declaredDiff, "a baseline exists, so the comparison ran") + val declaredDiff = result.declaredDiff!! + assertTrue(declaredDiff.removedEntityTypes.isEmpty(), "adding a type removes nothing") assertTrue( - recording.events.isEmpty(), - "previousStatus and newStatus are both STALE -- nothing actually transitioned", + result.quarantineDiff.changes.none { it is MetamodelChange.EntityTypeRemoved }, + "so the merged comparison carries no removal either: ${result.quarantineDiff.changes}", ) } @Test - fun `the merged diff keeps every removed type in one sorted block, ahead of other declared changes`() { + fun `the merged comparison keeps every removed type in one sorted block, ahead of other changes`() { // Two removals from EACH source, interleaved alphabetically, so a merge that only // concatenates per-source contributions -- synthetic removals, then declared ones, each in // whatever order they arrived, without sorting the union as a whole -- would produce A, N, - // M, B or some other source-grouped order that happens to look plausible but isn't the one - // sorted run MetamodelDiff promises. "M" and "B" are declared-vs-previous (dropped from the - // declaration outright, filed as declared EntityTypeRemoved); "A" and "N" are - // observed-vs-declared (synthetic EntityTypeRemoved, never declared by either version). Z - // survives with a lost property (a declared EntityTypeModified, filed under "Z"). The only - // block ordering that survives both a true union-sort AND a naive per-source concatenation - // for two of these four names is indistinguishable from a bug; asserting the complete, - // alphabetically interleaved list -- A, B, M, N, then Z's modification -- is what makes the - // two indistinguishable orderings actually distinguishable. + // M, B or some other source-grouped order that happens to look plausible while being wrong. + // "M" and "B" are declared-vs-previous (dropped from the declaration outright); "A" and "N" + // are observed-vs-declared (never declared by either version). Z survives with a lost + // property. Asserting the complete, alphabetically interleaved list -- A, B, M, N, then Z's + // modification -- is what makes the two orderings distinguishable. declaredEntityTypes = listOf("Person", "Company", "Z") val previousVersion = MetamodelVersion( schemaName = schemaName, @@ -685,16 +451,13 @@ class DriftCheckRunnerTest { relationshipNames = declaredRelationshipTypeNames.map { "Person-[$it]->Company" }, ) versionStore.markSwept(previousVersion) - // Current declaration: Z loses "p"; "M" and "B" are dropped outright (declared-vs-previous - // removals). + // Current declaration: Z loses "p"; "M" and "B" are dropped outright. declaredEntityTypeProperties = declaredEntityTypes.associateWith { emptySet() } observedEntityTypes = setOf("Person", "Company", "Z", "A", "N") // "A", "N" are undeclared drift - val recording = RecordingDriftQuarantinePolicy() - val runner = buildRunner(quarantinePolicy = recording) + val runner = buildRunner() - runner.run(dryRun = false) + val changes = runner.run().quarantineDiff.changes - val changes = recording.lastDiff!!.changes val zModification = changes.single { it !is MetamodelChange.EntityTypeRemoved } assertEquals( listOf( @@ -706,46 +469,35 @@ class DriftCheckRunnerTest { ), changes, "the removed-type block must merge both sources into one alphabetically sorted run, " + - "ahead of Z's modification, not a per-source grouping that happens to look sorted: $changes", + "ahead of Z's modification, and never a per-source grouping that looks sorted: $changes", ) } // ---- Label closure ---- @Test - fun `an inherited label observed in the graph is not drift and never quarantines`() { + fun `an inherited label observed in the graph counts as declared`() { // Declaring Person with parent Agent puts both labels on every Person node, so the graph // reports Agent too. Comparing observed labels against type names alone would report Agent - // as undeclared and quarantine sound propositions on a schema nobody had touched. + // as undeclared and a sweep would then flag sound propositions on a schema nobody touched. declaredEntityTypes = listOf("Person") declaredEntityTypeLabels = mapOf("Person" to setOf("Person", "Agent")) observedEntityTypes = setOf("Person", "Agent", "GhostType") - val agentMention = propositionStore.save(proposition("Alice acts", "Agent")) - val ghostMention = propositionStore.save(proposition("a ghost was mentioned", "GhostType")) val runner = buildRunner() - val result = runner.run(dryRun = false) + val result = runner.run() assertEquals(setOf("GhostType"), result.driftedEntityTypes, "the inherited label is declared") - assertEquals(1, result.quarantinedCount) - assertEquals( - PropositionStatus.ACTIVE, - propositionStore.findById(agentMention.id)!!.status, - "a proposition mentioning an inherited label must survive a live run", - ) - assertEquals(PropositionStatus.STALE, propositionStore.findById(ghostMention.id)!!.status) } @Test fun `a declared type with no data is reported as unobserved, never as drift`() { observedEntityTypes = setOf("Person") - propositionStore.save(proposition("Alice is a person", "Person")) val runner = buildRunner() - val result = runner.run(dryRun = false) + val result = runner.run() assertTrue(result.driftedEntityTypes.isEmpty(), "declared-but-empty is an ordinary state") - assertEquals(0, result.quarantinedCount) } // ---- Names that look like delimiters ---- @@ -757,7 +509,7 @@ class DriftCheckRunnerTest { observedRelationshipTypeNames = delimiterLaden + "UNDECLARED|ALSO\tDELIMITED" val runner = buildRunner() - val result = runner.run(dryRun = true) + val result = runner.run() // The declared names must reach the differ exactly as supplied, with no splitting, trimming // or delimiter parsing, so only the undeclared name shows up as drift. @@ -765,69 +517,13 @@ class DriftCheckRunnerTest { assertEquals(setOf("UNDECLARED|ALSO\tDELIMITED"), savedReports().single().driftedRelationshipTypes) } - // ---- Context scoping ---- + // ---- Scope ---- @Test - fun `a scoped run reads candidates via findByContextId, not findAll`() { - observedEntityTypes = setOf("Person", "Company", "GhostType") - propositionStore.save(proposition("a ghost was mentioned", "GhostType")) - val recording = RecordingPropositionStore(propositionStore) - val runner = buildRunner(recording) - - val result = runner.run(dryRun = false, contextId = contextId) - - assertEquals(contextId, recording.findByContextIdCall) - assertNull(recording.findAllCall) - assertEquals(contextId, result.contextId) - assertEquals(contextId, result.report.contextId) - } - - @Test - fun `an unscoped run reads candidates via findAll, not findByContextId`() { - observedEntityTypes = setOf("Person", "Company", "GhostType") - propositionStore.save(proposition("a ghost was mentioned", "GhostType")) - val recording = RecordingPropositionStore(propositionStore) - val runner = buildRunner(recording) - - val result = runner.run(dryRun = false) - - assertEquals(true, recording.findAllCall) - assertNull(recording.findByContextIdCall) - assertNull(result.contextId) - assertNull(result.report.contextId) - } - - @Test - fun `a scoped live run leaves another context's propositions completely alone`() { - // Both propositions mention the drifted type, and a global run would quarantine both. - // Scoping to one context must reach exactly one of them. - observedEntityTypes = setOf("Person", "Company", "GhostType") - val inScope = propositionStore.save(proposition("a ghost in context A", "GhostType")) - val outOfScope = propositionStore.save( - proposition("a ghost in context B", "GhostType", inContext = otherContextId), - ) + fun `a scoped check stamps the report with its context`() { val runner = buildRunner() - val result = runner.run(dryRun = false, contextId = contextId) - - assertEquals(setOf("GhostType"), result.driftedEntityTypes) - assertEquals(1, result.quarantinedCount, "only context A's proposition is a candidate") - assertEquals(PropositionStatus.STALE, propositionStore.findById(inScope.id)!!.status) - - val untouched = propositionStore.findById(outOfScope.id)!! - assertEquals( - PropositionStatus.ACTIVE, - untouched.status, - "a check scoped to one context must not be able to reach another's propositions", - ) - assertNull(untouched.metadata[DiceMetadataKeys.QUARANTINE_REASON]) - } - - @Test - fun `a scoped dry check still stamps the report with its context`() { - val runner = buildRunner() - - val result = runner.run(dryRun = true, contextId = contextId) + val result = runner.run(contextId) assertEquals(contextId, result.contextId) assertEquals(contextId, result.report.contextId) @@ -838,86 +534,79 @@ class DriftCheckRunnerTest { ) assertTrue( reportStore.globalDriftReports(schemaName, limit = 10).isEmpty(), - "a scoped check is not a global one", + "a scoped check is a different thing from a global one", ) } - /** - * Wraps a real [DriftQuarantinePolicy] and remembers the last [MetamodelDiff] it was asked to - * evaluate, so a test can inspect the diff the runner actually built and merged directly, - * instead of inferring its shape from quarantine outcomes alone. - */ - private class RecordingDriftQuarantinePolicy( - private val delegate: DriftQuarantinePolicy = MentionTypeDriftQuarantinePolicy(), - ) : DriftQuarantinePolicy { - var lastDiff: MetamodelDiff? = null - private set - - override fun evaluate(diff: MetamodelDiff, propositions: Iterable): QuarantineResult { - lastDiff = diff - return delegate.evaluate(diff, propositions) + @Test + fun `a result refuses a stamp its report was never judged against`() { + // The two halves have to agree, or quarantineDiff would merge one check's observed drift + // into another declaration's changes. + val report = DriftReport( + schemaName = schemaName, + versionHash = "some-other-hash", + driftedEntityTypes = emptySet(), + driftedRelationshipTypes = emptySet(), + capturedAt = capturedAt, + ) + + val failure = org.junit.jupiter.api.assertThrows { + DriftCheckResult(report = report, declaredVersion = declaredVersion()) } + assertTrue(failure.message!!.contains("some-other-hash"), failure.message) } /** - * Implements only the three original [MetamodelVersionStore] members, so `sweptVersion` and - * `markSwept` fall through to the interface defaults -- `sweptVersion` answering `latestVersion`, - * which moves on every [saveVersion]. Unlike [InMemoryMetamodelVersionStore] (which every other - * test in this class uses, and which tracks the reconciled baseline independently), a store built - * this way is exactly what exposes a read-before-save ordering bug: reading the baseline after the - * current run's own history write would read back the stamp that write just made current. + * Records the version-store calls a run makes, in order, so a test can assert what a check does + * and doesn't ask its store for. Reads and writes go through to [delegate]. */ - private class DefaultForwardingVersionStore : MetamodelVersionStore { - private val versions = mutableListOf() + private class CallRecordingVersionStore( + private val delegate: InMemoryMetamodelVersionStore = InMemoryMetamodelVersionStore(), + ) : SweptBaselineStore { + + val calls = mutableListOf() override fun saveVersion(version: MetamodelVersion) { - versions.removeIf { it.schemaName == version.schemaName && it.contentHash == version.contentHash } - versions.add(0, version) + calls += "saveVersion" + delegate.saveVersion(version) } - override fun latestVersion(schemaName: String): MetamodelVersion? = - versions.firstOrNull { it.schemaName == schemaName } + override fun latestVersion(schemaName: String): MetamodelVersion? { + calls += "latestVersion" + return delegate.latestVersion(schemaName) + } override fun versionHistory(schemaName: String): List = - versions.filter { it.schemaName == schemaName } - } + delegate.versionHistory(schemaName) + + override fun sweptVersion(schemaName: String): MetamodelVersion? { + calls += "sweptVersion" + return delegate.sweptVersion(schemaName) + } - /** Captures every event handed to it, in order, so a test can assert on what the runner emits. */ - private class RecordingDiceEventListener : DiceEventListener { - val events = mutableListOf() - override fun onEvent(event: DiceEvent) { - events += event + override fun markSwept(version: MetamodelVersion) { + calls += "markSwept" + delegate.markSwept(version) } } /** - * Records which candidate-read the runner called, so a test can assert the scoped or global read - * path directly rather than inferring it from a side effect. Everything else is delegated - * unchanged. - * - * A bare [PropositionStore] rather than a `PropositionRepository`: passing one of these to the - * runner is what shows a plain store-and-retrieve backend, with no vector search or graph - * traversal, can drive a live drift check. + * A version store that keeps stamps and tracks no reconciled baseline, which is what a backend + * looks like before it implements [SweptBaselineStore]. */ - private class RecordingPropositionStore( - private val delegate: PropositionStore, - ) : PropositionStore by delegate { - - var findAllCall: Boolean? = null - private set - - var findByContextIdCall: ContextId? = null - private set + private class BaselineFreeVersionStore : MetamodelVersionStore { + private val versions = mutableListOf() - override fun findAll(): List { - findAllCall = true - return delegate.findAll() + override fun saveVersion(version: MetamodelVersion) { + versions.removeIf { it.schemaName == version.schemaName && it.contentHash == version.contentHash } + versions.add(0, version) } - override fun findByContextId(contextId: ContextId): List { - findByContextIdCall = contextId - return delegate.findByContextId(contextId) - } + override fun latestVersion(schemaName: String): MetamodelVersion? = + versions.firstOrNull { it.schemaName == schemaName } + + override fun versionHistory(schemaName: String): List = + versions.filter { it.schemaName == schemaName } } /** diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt index bf37086f..c43031ca 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt @@ -1141,6 +1141,236 @@ class DriftQuarantinePolicyTest { assertEquals(0, result.quarantined.size) } } + + /** + * A declared name can be fully qualified where the graph writes the simple label. Both spellings + * have to match the same type, or a lossy change on `com.example.Person` sails past every + * proposition whose mentions say `Person`, and an ungoverned `com.example.Sighting` reads as a + * type nobody declared. + */ + @Nested + inner class QualifiedAndSimpleSpellings { + + private fun qualified( + types: List, + properties: Map> = emptyMap(), + aliases: Map> = emptyMap(), + ): MetamodelVersion = MetamodelVersion( + schemaName = "test", + entityTypeNames = types, + entityTypeLabels = types.associateWith { setOf(DeclaredSchema.ownLabelOf(it)) }, + entityTypeProperties = types.associateWith { properties[it].orEmpty() }, + relationshipNames = emptyList(), + entityTypeAliases = aliases, + ) + + @Test + fun `a simple mention matches a qualified declaration that lost a property`() { + val diff = differ.diff( + qualified( + listOf("com.example.Person"), + mapOf("com.example.Person" to setOf(valueProperty("age"))), + ), + qualified(listOf("com.example.Person")), + ) + + val result = policy.evaluate(diff, listOf(proposition("Alice is 40", "Person"))) + + assertEquals(1, result.quarantined.size, "the graph writes `Person` for `com.example.Person`") + assertTrue(reasonOf(result.quarantined.single()).contains("age")) + } + + @Test + fun `a simple mention matches a qualified type the declaration removed`() { + val diff = differ.diff( + qualified(listOf("com.example.Person", "com.example.Sighting")), + qualified(listOf("com.example.Person")), + ) + + val result = policy.evaluate(diff, listOf(proposition("a sighting", "Sighting"))) + + assertEquals(1, result.quarantined.size) + assertTrue( + reasonOf(result.quarantined.single()).contains("com.example.Sighting"), + "the reason names the type as the schema declares it", + ) + } + + @Test + fun `a spelling difference is ordinary matching and never reported as a former name`() { + // Without the guard, an operator reads "mention type 'Person' is a declared former name + // of [com.example.Person]", which is a baffling thing to say about one type. + val diff = differ.diff( + qualified( + listOf("com.example.Person"), + mapOf("com.example.Person" to setOf(valueProperty("age"))), + ), + qualified(listOf("com.example.Person")), + ) + + val reason = reasonOf( + policy.evaluate(diff, listOf(proposition("Alice is 40", "Person"))).quarantined.single(), + ) + + assertTrue( + !reason.contains("former name"), + "two spellings of one type are the same type: $reason", + ) + } + + @Test + fun `a qualified declaration that changed nothing lossy quarantines nothing`() { + // The carried case, at the policy's own level: a graph holding `Sighting` and `Person` + // against a declaration that lost nothing must leave both alone. + val unchanged = qualified(listOf("com.example.Person")) + val diff = differ.diff(unchanged, unchanged) + + val result = policy.evaluate( + diff, + listOf(proposition("a sighting", "Sighting"), proposition("Alice", "Person")), + ) + + assertEquals(2, result.conforming.size) + assertEquals(0, result.quarantined.size) + } + + @Test + fun `a declared former name matches under either spelling`() { + val diff = differ.diff( + qualified( + listOf("com.example.Person"), + mapOf("com.example.Person" to setOf(valueProperty("age"))), + aliases = mapOf("com.example.Person" to setOf("com.example.Human")), + ), + qualified( + listOf("com.example.Person"), + aliases = mapOf("com.example.Person" to setOf("com.example.Human")), + ), + ) + + val result = policy.evaluate(diff, listOf(proposition("Alice is 40", "Human"))) + + assertEquals(1, result.quarantined.size, "the graph writes `Human` for `com.example.Human`") + assertTrue(reasonOf(result.quarantined.single()).contains("former name")) + } + } + + /** + * [DriftQuarantinePolicy.candidateMentionTypes] is what lets a sweep read a narrow, bounded set + * of propositions. It has to be a superset of everything [DriftQuarantinePolicy.evaluate] would + * match, or a bounded sweep never reads a proposition it would have quarantined. + */ + @Nested + inner class CandidateMentionTypes { + + @Test + fun `a diff that strands nothing has no candidates`() { + val diff = differ.diff(schemaWith("Person"), schemaWith("Person", "Company")) + + assertEquals(emptySet(), policy.candidateMentionTypes(diff)) + } + + @Test + fun `a removed type is a candidate`() { + val diff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) + + assertEquals(setOf("RemovedType"), policy.candidateMentionTypes(diff)) + } + + @Test + fun `a type that narrowed a property is a candidate, and its untouched neighbours are left out`() { + val diff = differ.diff( + versionOf("Person" to setOf(valueProperty("age")), "Company" to emptySet()), + versionOf( + "Person" to setOf(valueProperty("age", type = "integer")), + "Company" to emptySet(), + ), + ) + + assertEquals(setOf("Person"), policy.candidateMentionTypes(diff)) + } + + @Test + fun `both spellings of a qualified name are offered to the store`() { + // A sweep asks a store for names, and the store holds what the graph wrote. Offering the + // qualified spelling alone would ask for a label no graph has ever held. + val before = MetamodelVersion( + schemaName = "test", + entityTypeNames = listOf("com.example.Person", "com.example.Sighting"), + entityTypeLabels = mapOf( + "com.example.Person" to setOf("Person"), + "com.example.Sighting" to setOf("Sighting"), + ), + entityTypeProperties = mapOf( + "com.example.Person" to emptySet(), + "com.example.Sighting" to emptySet(), + ), + relationshipNames = emptyList(), + ) + val after = MetamodelVersion( + schemaName = "test", + entityTypeNames = listOf("com.example.Person"), + entityTypeLabels = mapOf("com.example.Person" to setOf("Person")), + entityTypeProperties = mapOf("com.example.Person" to emptySet()), + relationshipNames = emptyList(), + ) + + assertEquals( + setOf("Sighting", "com.example.Sighting"), + policy.candidateMentionTypes(differ.diff(before, after)), + ) + } + + @Test + fun `a declared former name of a lossy type is a candidate`() { + val aliases = mapOf("Human" to setOf("Person")) + val diff = differ.diff( + versionOf(listOf("Human"), mapOf("Human" to setOf(valueProperty("age"))), aliases), + versionOf(listOf("Human"), emptyMap(), aliases), + ) + + assertTrue( + "Person" in policy.candidateMentionTypes(diff), + "the graph still holds the old label: ${policy.candidateMentionTypes(diff)}", + ) + } + + @Test + fun `every proposition evaluate would flag carries a candidate mention type`() { + // The contract that makes bounded selection sound, checked directly across the shapes + // this policy matches on. + val aliases = mapOf("Human" to setOf("Person")) + val diffs = listOf( + differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")), + differ.diff( + versionOf("Person" to setOf(valueProperty("age"))), + versionOf("Person" to emptySet()), + ), + differ.diff( + versionOf(listOf("Human"), mapOf("Human" to setOf(valueProperty("age"))), aliases), + versionOf(listOf("Human"), emptyMap(), aliases), + ), + ) + val candidates = listOf( + proposition("removed", "RemovedType"), + proposition("narrowed", "Person"), + proposition("renamed", "Human"), + proposition("untouched", "Company"), + ) + + diffs.forEach { diff -> + val offered = policy.candidateMentionTypes(diff) + policy.evaluate(diff, candidates).quarantined.forEach { flagged -> + assertTrue( + flagged.proposition.mentions.any { it.type in offered }, + "evaluate flagged '${flagged.proposition.text}' on types " + + "${flagged.proposition.mentions.map { it.type }}, which a bounded sweep " + + "asking for $offered would never have read", + ) + } + } + } + } } /** diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportTest.kt index 151162af..ab80f9ab 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftReportTest.kt @@ -15,7 +15,9 @@ */ package com.embabel.dice.metamodel +import com.embabel.agent.core.Cardinality import com.embabel.agent.core.ContextId +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.assertNotEquals @@ -36,6 +38,7 @@ class DriftReportTest { driftedRelationshipTypes: Set = emptySet(), capturedAt: Instant = this.capturedAt, contextId: ContextId? = null, + declaredDiff: MetamodelDiff? = null, ) = DriftReport( schemaName = schemaName, versionHash = versionHash, @@ -43,6 +46,7 @@ class DriftReportTest { driftedRelationshipTypes = driftedRelationshipTypes, capturedAt = capturedAt, contextId = contextId, + declaredDiff = declaredDiff, ) @Test @@ -110,4 +114,117 @@ class DriftReportTest { ) assertNotEquals(report(contextId = ContextId("ctx-1")), report()) } + + private fun version( + types: List, + properties: Map> = emptyMap(), + ): MetamodelVersion = MetamodelVersion( + schemaName = "test-schema", + entityTypeNames = types, + entityTypeLabels = types.associateWith { setOf(it) }, + entityTypeProperties = types.associateWith { properties[it].orEmpty() }, + relationshipNames = emptyList(), + ) + + /** `Person` loses its `age` property between two stamps. */ + private fun personLostAge(): MetamodelDiff = StructuralMetamodelDiffer().diff( + version( + listOf("Person"), + mapOf("Person" to setOf(PropertySignature("age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE))), + ), + version(listOf("Person")), + ) + + @Test + fun `a report carries the declared comparison it was given`() { + val declaredDiff = personLostAge() + + val report = report(declaredDiff = declaredDiff) + + assertEquals(declaredDiff, report.declaredDiff) + assertFalse(report.hasDrift, "the graph-truth half is clean") + assertTrue(report.hasAnyChange, "and the report still says something happened") + } + + @Test + fun `a report with neither kind of change reports nothing happened`() { + val clean = report() + + assertNull(clean.declaredDiff) + assertFalse(clean.hasAnyChange) + } + + @Test + fun `an empty declared comparison is not a change`() { + val unchanged = version(listOf("Person")) + val empty = StructuralMetamodelDiffer().diff(unchanged, unchanged) + + val report = report(declaredDiff = empty) + + assertFalse(report.hasAnyChange, "a declaration that hasn't moved strands nothing") + } + + @Test + fun `two reports differing only in the declared comparison are different reports`() { + assertNotEquals(report(declaredDiff = personLostAge()), report()) + assertNotEquals( + report(declaredDiff = personLostAge()).hashCode(), + report().hashCode(), + ) + } + + @Test + fun `the quarantine comparison merges observed drift into the declared changes`() { + val declaredVersion = version(listOf("Person")) + + val merged = report( + versionHash = declaredVersion.contentHash, + driftedEntityTypes = setOf("GhostType"), + declaredDiff = personLostAge(), + ).quarantineDiff(declaredVersion) + + assertEquals(setOf("GhostType"), merged.removedEntityTypes, "observed drift becomes a removal") + assertEquals( + listOf("age"), + merged.modifiedEntityTypes.single().removedPropertyNames.toList(), + "and the declared property removal rides along", + ) + assertEquals( + personLostAge().fromVersion, + merged.fromVersion, + "the declared comparison's own from-side carries through, so former names still resolve", + ) + } + + @Test + fun `a removal reported by both halves appears once`() { + val declaredVersion = version(emptyList()) + val removal = StructuralMetamodelDiffer().diff(version(listOf("Ghost")), declaredVersion) + + val merged = report( + versionHash = declaredVersion.contentHash, + driftedEntityTypes = setOf("Ghost"), + declaredDiff = removal, + ).quarantineDiff(declaredVersion) + + assertEquals( + listOf(MetamodelChange.EntityTypeRemoved("Ghost")), + merged.changes, + "the same type removed on both sides is one removal: ${merged.changes}", + ) + } + + @Test + fun `a report with no declared comparison still yields a usable quarantine comparison`() { + val declaredVersion = version(listOf("Person")) + + val merged = report( + versionHash = declaredVersion.contentHash, + driftedEntityTypes = setOf("GhostType"), + ).quarantineDiff(declaredVersion) + + assertEquals(setOf("GhostType"), merged.removedEntityTypes) + assertEquals(declaredVersion, merged.fromVersion) + assertEquals(declaredVersion, merged.toVersion) + } } diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftSweepTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftSweepTest.kt new file mode 100644 index 00000000..9c9c8396 --- /dev/null +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftSweepTest.kt @@ -0,0 +1,746 @@ +/* + * 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.ContextId +import com.embabel.agent.core.DataDictionary +import com.embabel.agent.core.JvmType +import com.embabel.dice.common.DiceEvent +import com.embabel.dice.common.DiceEventListener +import com.embabel.dice.common.DiceMetadataKeys +import com.embabel.dice.common.PropositionStatusChanged +import com.embabel.dice.common.SafeDiceEventListener +import com.embabel.dice.metamodel.support.DefaultDriftCheckRunner +import com.embabel.dice.metamodel.support.MentionTypeDriftQuarantinePolicy +import com.embabel.dice.metamodel.support.PropositionStoreDriftSweep +import com.embabel.dice.metamodel.support.StructuralMetamodelDiffer +import com.embabel.dice.projection.lineage.InMemoryProjectionRecordStore +import com.embabel.dice.projection.lineage.ProjectionLifecycle +import com.embabel.dice.projection.lineage.ProjectionLineageStaleCascade +import com.embabel.dice.projection.lineage.ProjectionRecord +import com.embabel.dice.proposition.EntityMention +import com.embabel.dice.proposition.MentionRole +import com.embabel.dice.proposition.Proposition +import com.embabel.dice.proposition.PropositionStatus +import com.embabel.dice.proposition.PropositionStore +import com.embabel.dice.proposition.store.InMemoryPropositionRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +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 java.time.Instant + +/** + * The deliberate half: [PropositionStoreDriftSweep], the reference [DriftSweepCapable], driven the + * way a host drives it — check first, decide, then sweep one context at a time, then mark the + * baseline. + * + * The real [MentionTypeDriftQuarantinePolicy] and [StructuralMetamodelDiffer] are used throughout, + * so these exercise the actual collaboration. + */ +class DriftSweepTest { + + private val contextId = ContextId("context-a") + private val otherContextId = ContextId("context-b") + private val schemaName = "test-schema" + + private lateinit var propositions: InMemoryPropositionRepository + private lateinit var policy: MentionTypeDriftQuarantinePolicy + + @BeforeEach + fun setUp() { + propositions = InMemoryPropositionRepository() + policy = MentionTypeDriftQuarantinePolicy() + } + + private fun proposition( + text: String, + vararg mentionTypes: String, + inContext: ContextId = contextId, + id: String? = null, + status: PropositionStatus = PropositionStatus.ACTIVE, + pinned: Boolean = false, + ): Proposition { + val base = Proposition( + contextId = inContext, + text = text, + mentions = mentionTypes.map { type -> + EntityMention(span = type.lowercase(), type = type, role = MentionRole.SUBJECT) + }, + confidence = 0.9, + ).withStatus(status).withPinned(pinned) + return if (id == null) base else base.copy(id = id) + } + + private fun versionOf( + types: List, + properties: Map> = emptyMap(), + ): MetamodelVersion = MetamodelVersion( + schemaName = schemaName, + entityTypeNames = types, + entityTypeLabels = types.associateWith { setOf(it) }, + entityTypeProperties = types.associateWith { properties[it].orEmpty() }, + relationshipNames = emptyList(), + ) + + private fun diffOf(from: MetamodelVersion, to: MetamodelVersion): MetamodelDiff = + StructuralMetamodelDiffer().diff(from, to) + + /** A canned snapshot of what a graph reports: labels, simple, with no package on them. */ + private fun observing(entityTypeNames: Set): ObservedSchemaSource = + object : ObservedSchemaSource { + override fun observe(contextId: ContextId?): ObservedSchema = ObservedSchema( + entityTypeNames = entityTypeNames, + relationshipTypeNames = emptySet(), + capturedAt = Instant.parse("2026-01-01T00:00:00Z"), + ) + } + + /** `Person` loses its `age` property: the plainest lossy change there is. */ + private fun personLostAge(): MetamodelDiff = diffOf( + versionOf( + listOf("Person", "Company"), + mapOf("Person" to setOf(PropertySignature("age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE))), + ), + versionOf(listOf("Person", "Company")), + ) + + @Nested + inner class Scoping { + + @Test + fun `a sweep scoped to one context leaves every other context untouched`() { + // Both propositions mention the affected type, so a sweep that reached across contexts + // would flag both. Confining it to one must reach exactly one. + val inScope = propositions.save(proposition("Alice is 40", "Person", inContext = contextId)) + val outOfScope = propositions.save(proposition("Bob is 50", "Person", inContext = otherContextId)) + val sweep = PropositionStoreDriftSweep(propositions) + + val result = sweep.sweep(personLostAge(), policy, contextId) + + assertEquals(1, result.quarantined.size, "only context A holds a candidate") + assertEquals(PropositionStatus.STALE, propositions.findById(inScope.id)!!.status) + + val untouched = propositions.findById(outOfScope.id)!! + assertEquals( + PropositionStatus.ACTIVE, + untouched.status, + "a sweep scoped to one context must have no way to reach another's propositions", + ) + assertNull(untouched.metadata[DiceMetadataKeys.QUARANTINE_REASON]) + } + + @Test + fun `the candidate read never leaves the context it was given`() { + propositions.save(proposition("Alice is 40", "Person", inContext = contextId)) + propositions.save(proposition("Bob is 50", "Person", inContext = otherContextId)) + val recording = RecordingPropositionStore(propositions) + val sweep = PropositionStoreDriftSweep(recording) + + val candidates = sweep.quarantineCandidates(contextId, setOf("Person"), limit = 10) + + assertEquals(listOf(contextId), recording.contextReads) + assertFalse(recording.readEverything, "a whole-store read would materialise every tenant") + assertEquals(listOf(contextId), candidates.map { it.contextId }) + } + + @Test + fun `sweeping each context in turn reaches all of them`() { + val inA = propositions.save(proposition("Alice is 40", "Person", inContext = contextId)) + val inB = propositions.save(proposition("Bob is 50", "Person", inContext = otherContextId)) + val sweep = PropositionStoreDriftSweep(propositions) + val diff = personLostAge() + + sweep.sweep(diff, policy, contextId) + sweep.sweep(diff, policy, otherContextId) + + assertEquals(PropositionStatus.STALE, propositions.findById(inA.id)!!.status) + assertEquals(PropositionStatus.STALE, propositions.findById(inB.id)!!.status) + } + } + + @Nested + inner class BoundedSelection { + + @Test + fun `a page never exceeds the limit and the cursor walks the rest`() { + (1..5).forEach { propositions.save(proposition("p$it", "Person", id = "id-$it")) } + val sweep = PropositionStoreDriftSweep(propositions) + + val first = sweep.quarantineCandidates(contextId, setOf("Person"), limit = 2) + val second = sweep.quarantineCandidates(contextId, setOf("Person"), limit = 2, afterId = first.last().id) + val third = sweep.quarantineCandidates(contextId, setOf("Person"), limit = 2, afterId = second.last().id) + + assertEquals(listOf("id-1", "id-2"), first.map { it.id }) + assertEquals(listOf("id-3", "id-4"), second.map { it.id }) + assertEquals(listOf("id-5"), third.map { it.id }) + } + + @Test + fun `a sweep pages through every candidate when the batch is smaller than the context`() { + (1..5).forEach { propositions.save(proposition("p$it", "Person", id = "id-$it")) } + val sweep = PropositionStoreDriftSweep(propositions) + + val result = sweep.sweep(personLostAge(), policy, contextId, batchSize = 2) + + assertEquals(5, result.quarantined.size, "paging must reach the whole context: $result") + assertTrue( + propositions.findAll().all { it.status == PropositionStatus.STALE }, + "every candidate was quarantined", + ) + } + + @Test + fun `only the mention types the policy asks for are ever read`() { + propositions.save(proposition("Alice is 40", "Person")) + propositions.save(proposition("Acme is a company", "Company")) + val recording = RecordingSweep(PropositionStoreDriftSweep(propositions)) + + recording.sweep(personLostAge(), policy, contextId) + + assertEquals( + listOf(setOf("Person")), + recording.requestedMentionTypes, + "a change touching Person must never ask the store for Company", + ) + assertEquals(PropositionStatus.ACTIVE, propositions.findAll().single { it.text.startsWith("Acme") }.status) + } + + @Test + fun `a change that can strand nothing reads no propositions at all`() { + propositions.save(proposition("Alice is 40", "Person")) + val recording = RecordingSweep(PropositionStoreDriftSweep(propositions)) + val additive = diffOf(versionOf(listOf("Person")), versionOf(listOf("Person", "Company"))) + + val result = recording.sweep(additive, policy, contextId) + + assertTrue(recording.requestedMentionTypes.isEmpty(), "no page should have been requested") + assertEquals(0, result.total, "and nothing was decided") + assertEquals(PropositionStatus.ACTIVE, propositions.findAll().single().status) + } + + @Test + fun `an empty mention-type set returns nothing`() { + propositions.save(proposition("Alice is 40", "Person")) + val sweep = PropositionStoreDriftSweep(propositions) + + assertEquals(emptyList(), sweep.quarantineCandidates(contextId, emptySet(), limit = 10)) + } + + @Test + fun `a non-positive limit is refused`() { + val sweep = PropositionStoreDriftSweep(propositions) + + assertThrows(IllegalArgumentException::class.java) { + sweep.quarantineCandidates(contextId, setOf("Person"), limit = 0) + } + assertThrows(IllegalArgumentException::class.java) { + sweep.sweep(personLostAge(), policy, contextId, batchSize = 0) + } + } + } + + @Nested + inner class Quarantining { + + @Test + fun `a sweep persists what the policy flags and leaves the rest alone`() { + val affected = propositions.save(proposition("Alice is 40", "Person")) + val safe = propositions.save(proposition("Acme is a company", "Company")) + val sweep = PropositionStoreDriftSweep(propositions) + + val result = sweep.sweep(personLostAge(), policy, contextId) + + assertEquals(1, result.quarantined.size) + val quarantined = propositions.findById(affected.id)!! + assertEquals(PropositionStatus.STALE, quarantined.status) + assertNotNull(quarantined.metadata[DiceMetadataKeys.QUARANTINE_REASON]) + assertEquals(PropositionStatus.ACTIVE, propositions.findById(safe.id)!!.status) + } + + @Test + fun `a pinned proposition is reported and never moved`() { + val pinned = propositions.save(proposition("Alice is 40", "Person", pinned = true)) + val sweep = PropositionStoreDriftSweep(propositions) + + val result = sweep.sweep(personLostAge(), policy, contextId) + + assertEquals(1, result.protected.size) + assertEquals(0, result.quarantined.size) + assertEquals(PropositionStatus.ACTIVE, propositions.findById(pinned.id)!!.status) + } + + @Test + fun `a second sweep leaves an already-quarantined proposition exactly as it was`() { + val affected = propositions.save(proposition("Alice is 40", "Person")) + val sweep = PropositionStoreDriftSweep(propositions) + val diff = personLostAge() + sweep.sweep(diff, policy, contextId) + val afterFirst = propositions.findById(affected.id)!! + + val second = sweep.sweep(diff, policy, contextId) + + assertEquals(1, second.alreadyQuarantined.size) + assertEquals(0, second.quarantined.size) + assertEquals(afterFirst, propositions.findById(affected.id)!!, "the record must be untouched") + } + + @Test + fun `a quarantine's status change reaches projection lineage through the listener`() { + // ProjectionLineageStaleCascade is how a proposition going STALE marks its projection + // records stale in turn; it reacts to PropositionStatusChanged. + val affected = propositions.save(proposition("Alice is 40", "Person")) + val recordStore = InMemoryProjectionRecordStore() + recordStore.record( + ProjectionRecord( + propositionId = affected.id, + target = "test-target", + lifecycle = ProjectionLifecycle.PROJECTED, + runId = "run-1", + ), + ) + val cascade = ProjectionLineageStaleCascade(recordStore) + val sweep = PropositionStoreDriftSweep(propositions, SafeDiceEventListener(cascade)) + + sweep.sweep(personLostAge(), policy, contextId) + + assertEquals(PropositionStatus.STALE, propositions.findById(affected.id)!!.status) + assertEquals( + ProjectionLifecycle.STALE, + recordStore.findByProposition(affected.id).single().lifecycle, + "the cascade heard about the transition and marked its record stale in turn", + ) + } + + @Test + fun `the emitted event carries the reason and the status it came from`() { + propositions.save(proposition("Alice is 40", "Person")) + val recording = RecordingDiceEventListener() + val sweep = PropositionStoreDriftSweep(propositions, recording) + + sweep.sweep(personLostAge(), policy, contextId) + + val event = recording.events.filterIsInstance().single() + assertEquals(PropositionStatus.ACTIVE, event.previousStatus) + assertEquals(PropositionStatus.STALE, event.newStatus) + assertTrue(event.reason!!.contains("age"), event.reason) + } + + @Test + fun `a conforming proposition emits nothing`() { + propositions.save(proposition("Acme is a company", "Company")) + val recording = RecordingDiceEventListener() + val sweep = PropositionStoreDriftSweep(propositions, recording) + + sweep.sweep(personLostAge(), policy, contextId) + + assertTrue(recording.events.isEmpty()) + } + + @Test + fun `a proposition already STALE from decay is quarantined without announcing a transition`() { + // The idempotency check only skips one that is already quarantined (STALE with a + // reason); one STALE from ordinary decay carries no reason and is a fresh candidate. It + // gets its reason written while its status stays put, so no event should claim a move. + val decayed = propositions.save( + proposition("Alice is 40", "Person", status = PropositionStatus.STALE), + ) + val recording = RecordingDiceEventListener() + val sweep = PropositionStoreDriftSweep(propositions, recording) + + val result = sweep.sweep(personLostAge(), policy, contextId) + + assertEquals(1, result.quarantined.size, "sanity: it was quarantined") + assertNotNull(propositions.findById(decayed.id)!!.metadata[DiceMetadataKeys.QUARANTINE_REASON]) + assertTrue( + recording.events.isEmpty(), + "previousStatus and newStatus are both STALE, so nothing actually transitioned", + ) + } + + @Test + fun `a failing write stops the sweep and leaves the baseline alone`() { + // markSwept belongs to the host, and the host never reaches it when the sweep throws, + // so an interrupted sweep can never look like a finished reconciliation. + val versionStore = InMemoryMetamodelVersionStore() + val baseline = versionOf(listOf("Person")) + versionStore.markSwept(baseline) + propositions.save(proposition("Alice is 40", "Person")) + val crashing = object : PropositionStore by propositions { + override fun save(proposition: Proposition): Proposition = + throw IllegalStateException("simulated crash mid-sweep") + } + val sweep = PropositionStoreDriftSweep(crashing) + + assertThrows(IllegalStateException::class.java) { + sweep.sweep(personLostAge(), policy, contextId) + versionStore.markSwept(versionOf(listOf("Person", "Company"))) + } + + assertEquals(baseline, versionStore.sweptVersion(schemaName)) + assertEquals(PropositionStatus.ACTIVE, propositions.findAll().single().status) + } + } + + @Nested + inner class Releasing { + + @Test + fun `release restores the status the proposition came from and clears the reason`() { + val original = propositions.save(proposition("Alice is 40", "Person")) + val sweep = PropositionStoreDriftSweep(propositions) + sweep.sweep(personLostAge(), policy, contextId) + assertEquals(PropositionStatus.STALE, propositions.findById(original.id)!!.status, "sanity") + + val released = sweep.releaseFromQuarantine(original.id) + + assertNotNull(released) + assertEquals( + PropositionStatus.ACTIVE, + released!!.status, + "clearing the reason alone would leave it STALE and out of ordinary retrieval", + ) + assertNull(released.metadata[DiceMetadataKeys.QUARANTINE_REASON]) + assertNull(released.metadata[DriftQuarantineKeys.PREVIOUS_STATUS]) + assertEquals(released, propositions.findById(original.id), "and the release was persisted") + } + + @Test + fun `release puts a proposition back where it was, even when that was not ACTIVE`() { + val promoted = propositions.save( + proposition("Alice is 40", "Person", status = PropositionStatus.PROMOTED), + ) + val sweep = PropositionStoreDriftSweep(propositions) + sweep.sweep(personLostAge(), policy, contextId) + + val released = sweep.releaseFromQuarantine(promoted.id)!! + + assertEquals(PropositionStatus.PROMOTED, released.status) + } + + @Test + fun `a released proposition is a fresh candidate again`() { + val original = propositions.save(proposition("Alice is 40", "Person")) + val sweep = PropositionStoreDriftSweep(propositions) + val diff = personLostAge() + sweep.sweep(diff, policy, contextId) + sweep.releaseFromQuarantine(original.id) + + val second = sweep.sweep(diff, policy, contextId) + + assertEquals(1, second.quarantined.size, "the reason is gone, so the policy judges it afresh") + assertEquals(0, second.alreadyQuarantined.size) + } + + @Test + fun `release announces the transition it made`() { + val original = propositions.save(proposition("Alice is 40", "Person")) + val recording = RecordingDiceEventListener() + val sweep = PropositionStoreDriftSweep(propositions, recording) + sweep.sweep(personLostAge(), policy, contextId) + recording.events.clear() + + sweep.releaseFromQuarantine(original.id) + + val event = recording.events.filterIsInstance().single() + assertEquals(PropositionStatus.STALE, event.previousStatus) + assertEquals(PropositionStatus.ACTIVE, event.newStatus) + } + + @Test + fun `releasing something that was never quarantined answers null and changes nothing`() { + val untouched = propositions.save(proposition("Alice is 40", "Person")) + val sweep = PropositionStoreDriftSweep(propositions) + + assertNull(sweep.releaseFromQuarantine(untouched.id)) + assertNull(sweep.releaseFromQuarantine("no-such-id")) + assertEquals(untouched, propositions.findById(untouched.id)) + } + + @Test + fun `releasing twice is safe`() { + val original = propositions.save(proposition("Alice is 40", "Person")) + val sweep = PropositionStoreDriftSweep(propositions) + sweep.sweep(personLostAge(), policy, contextId) + + assertNotNull(sweep.releaseFromQuarantine(original.id)) + assertNull(sweep.releaseFromQuarantine(original.id), "the second call finds nothing quarantined") + } + + @Test + fun `a quarantine with no recorded previous status is released to ACTIVE`() { + // What an older quarantine looks like, or one whose metadata a person edited. + val legacy = propositions.save( + proposition("Alice is 40", "Person", status = PropositionStatus.STALE) + .withMetadataValue(DiceMetadataKeys.QUARANTINE_REASON, "quarantined by an earlier build"), + ) + val sweep = PropositionStoreDriftSweep(propositions) + + val released = sweep.releaseFromQuarantine(legacy.id)!! + + assertEquals(PropositionStatus.ACTIVE, released.status) + assertNull(released.metadata[DiceMetadataKeys.QUARANTINE_REASON]) + } + } + + /** + * The case carried over from the review of the diff slice: a dictionary declaring a governed + * `Person` and an ungoverned `Sighting`, a graph holding both, and a full pass — check, then + * deliberate sweep — that has to leave the `Sighting` proposition alone. + * + * The types are real JVM classes, so the declaration holds fully qualified names while the graph + * and the extraction hold simple ones. That is the spelling mismatch this whole path has to + * survive, on both halves at once. + */ + @Nested + inner class GovernedPersonAndUngovernedSighting { + + private val personName = Person::class.java.name + private val sightingName = Sighting::class.java.name + + private fun dictionary(): DataDictionary = DataDictionary.fromDomainTypes( + schemaName, + listOf(JvmType(Person::class.java), JvmType(Sighting::class.java)), + ) + + private fun declaredSchema(): DeclaredSchema = + DeclaredSchema.from(dictionary(), GovernedTypeSelector { it.name == personName }) + + private fun runCheck(versionStore: MetamodelVersionStore): DriftCheckResult { + val differ = StructuralMetamodelDiffer() + val declared = declaredSchema() + return DefaultDriftCheckRunner( + declaredSchemaSource = { declared }, + versionStore = versionStore, + observedSchemaSource = observing(setOf("Person", "Sighting")), + differ = differ, + metamodelDiffer = differ, + driftReportStore = InMemoryDriftReportStore(), + ).run() + } + + @Test + fun `an ungoverned type observed in the graph survives a full check-and-sweep pass`() { + assertTrue(sightingName.contains('.'), "sanity: the declaration is fully qualified") + assertEquals(setOf("Sighting"), declaredSchema().ungovernedEntityTypeNames.map { + DeclaredSchema.ownLabelOf(it) + }.toSet()) + + val sighting = propositions.save(proposition("a sighting was reported", "Sighting")) + val person = propositions.save(proposition("Alice is a person", "Person")) + val versionStore = InMemoryMetamodelVersionStore() + + val result = runCheck(versionStore) + + assertTrue( + result.driftedEntityTypes.isEmpty(), + "an ungoverned type is a known type, so it is no drift: ${result.driftedEntityTypes}", + ) + assertFalse(result.hasAnyChange) + + // Now the deliberate half, exactly as a host would run it. + val sweep = PropositionStoreDriftSweep(propositions) + val swept = sweep.sweep(result.quarantineDiff, policy, contextId) + versionStore.markSwept(result.declaredVersion) + + assertEquals(0, swept.quarantined.size, "the sweep had nothing to act on: $swept") + assertEquals( + PropositionStatus.ACTIVE, + propositions.findById(sighting.id)!!.status, + "a proposition mentioning a known-but-ungoverned type must survive a real sweep", + ) + assertNull(propositions.findById(sighting.id)!!.metadata[DiceMetadataKeys.QUARANTINE_REASON]) + assertEquals(PropositionStatus.ACTIVE, propositions.findById(person.id)!!.status) + assertEquals( + result.declaredVersion, + versionStore.sweptVersion(schemaName), + "and the host's completed sweep is what moved the baseline", + ) + } + + @Test + fun `the governed type is still reachable when its declaration really does lose something`() { + // The other side of the same spelling problem: a lossy change on the fully qualified + // `Person` has to reach a proposition whose mention says plain `Person`. Without that, + // the case above would pass for the wrong reason -- nothing would ever match. + val mentioning = propositions.save(proposition("Alice is 40", "Person")) + val before = MetamodelVersion( + schemaName = schemaName, + entityTypeNames = listOf(personName), + entityTypeLabels = mapOf(personName to setOf("Person")), + entityTypeProperties = mapOf( + personName to setOf( + PropertySignature("age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE), + ), + ), + relationshipNames = emptyList(), + ) + val after = MetamodelVersion( + schemaName = schemaName, + entityTypeNames = listOf(personName), + entityTypeLabels = mapOf(personName to setOf("Person")), + entityTypeProperties = mapOf(personName to emptySet()), + relationshipNames = emptyList(), + ) + val sweep = PropositionStoreDriftSweep(propositions) + + val result = sweep.sweep(diffOf(before, after), policy, contextId) + + assertEquals(1, result.quarantined.size, "the simple mention must match the qualified declaration") + assertEquals(PropositionStatus.STALE, propositions.findById(mentioning.id)!!.status) + } + } + + @Nested + inner class WhatAReportPromises { + + @Test + fun `the comparison a report carries is the one the sweep evaluates`() { + // The report's whole purpose after the live path went away: a person reads it, decides, + // and the sweep then acts on exactly those facts. + val versionStore = InMemoryMetamodelVersionStore() + versionStore.markSwept( + versionOf( + listOf("Person", "Company"), + mapOf( + "Person" to setOf( + PropertySignature("age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE), + ), + ), + ), + ) + val declared = DeclaredSchema( + version = versionOf(listOf("Person", "Company")), + relationshipTypeNames = emptySet(), + ) + val differ = StructuralMetamodelDiffer() + val result = DefaultDriftCheckRunner( + declaredSchemaSource = { declared }, + versionStore = versionStore, + observedSchemaSource = observing(setOf("Person", "Company", "GhostType")), + differ = differ, + metamodelDiffer = differ, + driftReportStore = InMemoryDriftReportStore(), + ).run() + + propositions.save(proposition("Alice is 40", "Person")) + propositions.save(proposition("a ghost was mentioned", "GhostType")) + val recordingPolicy = RecordingPolicy() + + val swept = PropositionStoreDriftSweep(propositions) + .sweep(result.quarantineDiff, recordingPolicy, contextId) + + assertNotNull(result.declaredDiff, "the report saw the declared change") + assertEquals( + result.report.quarantineDiff(result.declaredVersion), + recordingPolicy.evaluatedDiff, + "the sweep evaluated exactly the comparison the report carries", + ) + assertEquals(2, swept.quarantined.size, "both halves of that comparison found something") + } + } + + /** Records which reads a sweep made of the underlying store. */ + private class RecordingPropositionStore( + private val delegate: PropositionStore, + ) : PropositionStore by delegate { + + val contextReads = mutableListOf() + var readEverything: Boolean = false + private set + + override fun findAll(): List { + readEverything = true + return delegate.findAll() + } + + override fun findByContextId(contextId: ContextId): List { + contextReads += contextId + return delegate.findByContextId(contextId) + } + } + + /** + * Wraps a real [DriftSweepCapable] and remembers what the interface's own `sweep` asked its + * store for, so a test can assert on the bound and the mention types directly. + * + * Written out member by member, with no Kotlin interface delegation, on purpose: a delegating + * wrapper would forward `sweep` to the delegate, whose `this` is the delegate, and the recording + * overrides below would never be reached. + */ + private class RecordingSweep( + private val delegate: DriftSweepCapable, + ) : DriftSweepCapable { + + val requestedMentionTypes = mutableListOf>() + + override fun quarantineCandidates( + contextId: ContextId, + mentionTypes: Set, + limit: Int, + afterId: String?, + ): List { + requestedMentionTypes += mentionTypes + return delegate.quarantineCandidates(contextId, mentionTypes, limit, afterId) + } + + override fun applyQuarantine(decision: QuarantineDecision.Quarantined): Proposition = + delegate.applyQuarantine(decision) + + override fun releaseFromQuarantine(propositionId: String): Proposition? = + delegate.releaseFromQuarantine(propositionId) + } + + /** Remembers the comparison a sweep handed the policy. */ + private class RecordingPolicy( + private val delegate: DriftQuarantinePolicy = MentionTypeDriftQuarantinePolicy(), + ) : DriftQuarantinePolicy { + + var evaluatedDiff: MetamodelDiff? = null + private set + + override fun evaluate(diff: MetamodelDiff, propositions: Iterable): QuarantineResult { + evaluatedDiff = diff + return delegate.evaluate(diff, propositions) + } + + override fun candidateMentionTypes(diff: MetamodelDiff): Set = + delegate.candidateMentionTypes(diff) + } + + /** Captures every event handed to it, in order. */ + private class RecordingDiceEventListener : DiceEventListener { + val events = mutableListOf() + override fun onEvent(event: DiceEvent) { + events += event + } + } +} + +/** + * A governed domain type. Declared through `JvmType`, so its declared name is the fully qualified + * class name while a graph writes the simple label `Person`. + */ +private data class Person(val name: String, val age: Int) + +/** The ungoverned counterpart: the host's dictionary names it and the selector leaves it out. */ +private data class Sighting(val where: String, val about: Person) diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt index a4f6ff27..833b08a8 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/InMemoryMetamodelStores.kt @@ -19,10 +19,11 @@ import com.embabel.agent.core.ContextId import java.time.Instant /** - * In-memory [MetamodelVersionStore] for tests: upserts on `(schemaName, contentHash)` and keeps - * history newest first, the same way the real contract describes. + * In-memory [SweptBaselineStore] for tests: upserts on `(schemaName, contentHash)`, keeps history + * newest first, and tracks the reconciled baseline as its own pointer, the same way the real + * contracts describe. */ -internal class InMemoryMetamodelVersionStore : MetamodelVersionStore { +internal class InMemoryMetamodelVersionStore : SweptBaselineStore { private val versions = mutableListOf() @@ -31,7 +32,7 @@ internal class InMemoryMetamodelVersionStore : MetamodelVersionStore { private set // The reconciled-baseline pointer, tracked apart from `versions`' write order -- see - // MetamodelVersionStore.sweptVersion's doc for why this can't be answered off `latestVersion`. + // SweptBaselineStore.sweptVersion's doc for why this can't be answered off `latestVersion`. private val swept = mutableMapOf() override fun saveVersion(version: MetamodelVersion) { diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionStoreTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionStoreTest.kt index 37ccd4fe..faa45a9f 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionStoreTest.kt +++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionStoreTest.kt @@ -23,10 +23,10 @@ import org.junit.jupiter.api.Test /** * Covers behaviour the contract itself ships, independent of any specific backend: the default - * [findVersion], which a backend is free to override with a keyed lookup, and the - * [MetamodelVersionStore.sweptVersion] / [MetamodelVersionStore.markSwept] pointer, both the - * defaults and [InMemoryMetamodelVersionStore]'s independent tracking of it. A store implementation - * gets its own tests wherever it lives. + * [findVersion], which a backend is free to override with a keyed lookup, and + * [InMemoryMetamodelVersionStore]'s reading of the [SweptBaselineStore.sweptVersion] / + * [SweptBaselineStore.markSwept] pointer. A store implementation gets its own tests wherever it + * lives. * * The upsert rules [MetamodelVersionStore.saveVersion] states are checked by * `AbstractMetamodelVersionStoreContractTest`, which runs the same suite against @@ -92,10 +92,10 @@ class MetamodelVersionStoreTest { } /** - * [MetamodelVersionStore.sweptVersion] tracks a different fact than [MetamodelVersionStore - * .latestVersion]: which declaration the last completed live sweep actually reconciled against, - * not which stamp arrived most recently in write order. [InMemoryMetamodelVersionStore] tracks - * it independently; the interface default (covered separately below) does not. + * [SweptBaselineStore.sweptVersion] tracks a different fact than + * [MetamodelVersionStore.latestVersion]: which declaration the last completed sweep actually + * reconciled against, where `latestVersion` answers which stamp arrived most recently in write + * order. */ @Nested inner class SweptVersion { @@ -161,31 +161,46 @@ class MetamodelVersionStoreTest { } /** - * The interface defaults exist so a store that doesn't override [MetamodelVersionStore - * .sweptVersion]/[MetamodelVersionStore.markSwept] still compiles and behaves like the runner - * did before those methods existed -- not a placeholder pretending the gap they describe is - * closed. + * A plain [MetamodelVersionStore] has no swept baseline at all, and there is no forwarding + * default that would give it one. That absence is the fix for a real hazard: a default answering + * [MetamodelVersionStore.latestVersion] moved on every ordinary stamp, so a store that never + * asked for baseline tracking still handed one out, and a dry, scoped or interrupted write + * looked to the next check like a finished sweep. */ @Nested - inner class InterfaceDefaults { + inner class NoBaselineWithoutTheCapability { @Test - fun `sweptVersion defaults to latestVersion`() { - val store = MinimalStore() - val v = version("app", "Person") - store.saveVersion(v) + fun `a plain version store is no SweptBaselineStore`() { + val store: MetamodelVersionStore = MinimalStore() + store.saveVersion(version("app", "Person")) - assertEquals(store.latestVersion("app"), store.sweptVersion("app")) + assertFalse( + store is SweptBaselineStore, + "stamping must never be enough to make a store look like it tracks a baseline", + ) } @Test - fun `markSwept defaults to an ordinary saveVersion`() { - val store = MinimalStore() - val v = version("app", "Person") + fun `the in-memory store does declare the capability`() { + val store: MetamodelVersionStore = InMemoryMetamodelVersionStore() - store.markSwept(v) + assertTrue(store is SweptBaselineStore) + } + + @Test + fun `saving a stamp many times leaves the baseline where it was`() { + val store = InMemoryMetamodelVersionStore() + val swept = version("app", "Person") + store.markSwept(swept) + + repeat(5) { store.saveVersion(version("app", "Person", "Company")) } - assertEquals(v, store.latestVersion("app"), "the default's only effect is the ordinary save") + assertEquals( + swept, + store.sweptVersion("app"), + "a completed sweep is the only thing that may move the baseline", + ) } } diff --git a/docs/design/metamodel-drift.md b/docs/design/metamodel-drift.md index a77d82a4..25fd8696 100644 --- a/docs/design/metamodel-drift.md +++ b/docs/design/metamodel-drift.md @@ -1,12 +1,28 @@ # Metamodel drift: checking a live graph against a declared schema -A drift check takes the schema an application declares, observes what a live graph is holding, -records where the two disagree, and, when asked to, pulls the propositions that disagreement -stranded out of normal use. +A drift check takes the schema an application declares, observes what a live graph is holding, and +records where the two disagree. Pulling the propositions that disagreement stranded out of normal use +is a separate, deliberate step a host takes afterwards. Stamping and diffing both work on declarations. A drift check is the step that reads the graph. -The shape of a run is fixed, and every step leaves something behind: +## Two halves, and only one of them runs itself + +**A check reports.** `DriftCheckRunner` has one mode. It reads, compares, writes a `DriftReport`, and +no path through it moves a proposition or the swept baseline. It holds no quarantine policy and no +proposition store, so there is nothing for it to move them with. + +**A sweep acts.** `DriftSweepCapable` is the store-side SPI a host calls once it has read a report and +decided. Its candidate selection is bounded, confined to one `ContextId`, and filtered on mention +type. Nothing in DICE calls it on a timer, from a scheduler, or out of auto-configuration. + +The split is what makes a report trustworthy. While a check could also act, a report was a preview of +one half of what a run would do, and the honest thing to say about it was that a clean report and a +quarantining run could describe the same state. Now a check reports the *whole* comparison a sweep +would evaluate — see [What a report carries](#what-a-report-carries) — so reading one and deciding +from it is sound. + +The shape of a check is fixed, and every step leaves something behind: ```mermaid sequenceDiagram @@ -19,41 +35,51 @@ sequenceDiagram participant Differ as DeclaredObservedDiffer participant MetamodelDiffer as MetamodelDiffer participant Reports as DriftReportStore - participant Policy as DriftQuarantinePolicy - participant Props as PropositionRepository - participant Listener as DiceEventListener - Caller->>Runner: run(dryRun, contextId) + Caller->>Runner: run(contextId) Runner->>Declared: declare() Declared-->>Runner: DeclaredSchema (stamp + bare rel names) - Runner->>Versions: sweptVersion(schemaName) + Runner->>Versions: sweptVersion(schemaName) — when the store is a SweptBaselineStore Versions-->>Runner: reconciled baseline, or null if no sweep has completed yet Runner->>Versions: saveVersion(stamp) - Note over Runner,Versions: history write, every run, dry or live;
on a store without independent tracking, moves the
reconciled baseline too, but only when the stamp is new + Note over Runner,Versions: history write, every run;
it leaves the reconciled baseline alone Runner->>Observed: observe(contextId) Observed-->>Runner: ObservedSchema (labels + rel types, one instant) Runner->>Differ: diffAgainstObserved(declared, observed) Differ-->>Runner: DeclaredObservedDiff (drifted vs unobserved) Runner->>MetamodelDiffer: diff(baseline, current) — only when a baseline exists MetamodelDiffer-->>Runner: declared-vs-previous MetamodelDiff - Runner->>Reports: saveDriftReport(report) + Runner->>Reports: saveDriftReport(report, including the declared comparison) Note over Runner,Reports: written on every run, including
checks that find nothing - alt live run and (entity-type observed drift or a non-empty declared-vs-previous diff) - Runner->>Props: candidates (scoped or all) - Runner->>Policy: evaluate(merged diff, candidates) - Policy-->>Runner: STALE copies + reasons, pinned matches reported protected - Runner->>Props: save(each quarantined copy) - Runner->>Listener: onEvent(PropositionStatusChanged), skipped if status didn't move - else dry run, or relationship-only/no drift from either comparison - Note over Runner: nothing is touched, nothing is emitted - end - alt live run AND unscoped (contextId is null) - Runner->>Versions: markSwept(stamp) - Note over Runner,Versions: on a store with independent tracking, this is
the moment the reconciled baseline advances; a default-forwarding
store's baseline follows write order instead — a new stamp moved it
back at saveVersion, a re-saved stamp never moves it - end Runner-->>Caller: DriftCheckResult ``` +A sweep is the second diagram, and a person starts it: + +```mermaid +sequenceDiagram + autonumber + participant Host + participant Sweep as DriftSweepCapable + participant Policy as DriftQuarantinePolicy + participant Props as PropositionStore + participant Listener as DiceEventListener + participant Versions as SweptBaselineStore + + Host->>Sweep: sweep(result.quarantineDiff, policy, contextId) + Sweep->>Policy: candidateMentionTypes(diff) + Policy-->>Sweep: the mention types this change could strand + loop one bounded page at a time, until a page comes back short + Sweep->>Props: quarantineCandidates(contextId, mentionTypes, limit, afterId) + Sweep->>Policy: evaluate(diff, page) + Policy-->>Sweep: STALE copies + reasons, pinned matches reported protected + Sweep->>Props: applyQuarantine(each flagged copy) + Sweep->>Listener: onEvent(PropositionStatusChanged), skipped if status didn't move + end + Sweep-->>Host: QuarantineResult + Host->>Versions: markSwept(stamp) — once every context is done +``` + ## The three tiers Schema governance in DICE has three tiers, shipped in that order. @@ -62,12 +88,13 @@ Schema governance in DICE has three tiers, shipped in that order. [metamodel-versioning.md](metamodel-versioning.md). **Detect and report.** Compare two declarations against each other, or a declaration against a live -graph, and record what you find. The drift check sits here, and it is the default: `run()` with no -arguments is a dry, whole-graph check that persists a report and changes nothing. +graph, and record what you find. The drift check sits here, and it is all `DriftCheckRunner` does: +`run()` is a whole-graph check that persists a report and changes nothing. -**Quarantine.** Act on a lossy change by marking the affected propositions stale rather than -deleting them. Off by default; `run(dryRun = false)` turns it on. This slice adds the contracts, and -the wiring that schedules a live run arrives with the autoconfigure slice. +**Quarantine.** Act on a lossy change by marking the affected propositions stale, which is a +different thing from deleting them. A host does this through `DriftSweepCapable`, at a moment it +chooses. This slice adds the contracts and a reference implementation; a durable store's own +implementation, and any wiring, come later. Each tier builds on the one below it, and each is useful on its own. You can stamp for a year without detecting, and detect for a year without quarantining. Rejecting undeclared types at write @@ -133,7 +160,7 @@ to go into the query, so every backend writes all three. ## Quarantine -A live run hands a `DriftQuarantinePolicy` a single merged `MetamodelDiff` built from two independent +A sweep hands a `DriftQuarantinePolicy` a single merged `MetamodelDiff` built from two independent comparisons, and quarantines whatever the policy flags in either one. ### Two sources of drift @@ -145,8 +172,8 @@ narrowed or dropped on a type the graph and the declaration still agree the name that change is observed drift, because nothing about the *type* is undeclared — only its shape moved. **Declared vs. previous declared** (`MetamodelDiffer`) closes that gap. Before its own history write, -the runner reads `MetamodelVersionStore.sweptVersion` for this schema — the declaration the *last -completed live, unscoped sweep* reconciled against — and diffs it against the current declaration with the same +the runner reads `SweptBaselineStore.sweptVersion` for this schema — the declaration the *last +completed sweep* reconciled against — and diffs it against the current declaration with the same kind of comparison [metamodel-diff.md](metamodel-diff.md) describes for comparing any two versions. Whatever moved — a removed property, a narrowed cardinality, a whole type dropped — reaches the policy exactly like an observed removal does, because it becomes the same `MetamodelChange` entries @@ -154,43 +181,99 @@ the policy already knows how to judge. There is no baseline on a schema's first- half doesn't run at all. The two comparisons are merged into one diff before the policy sees it, evaluated once — never as two -separate sweeps that could each make an independent call about the same proposition. `DriftReport` -itself is unaffected by this merge: it still records only declared-vs-observed drift, which is the -graph-truth signal — "the graph holds something nobody declared" — an operator watching the log -wants; the declared-vs-previous comparison exists to feed quarantine, not to duplicate the report. +separate sweeps that could each make an independent call about the same proposition. + +### What a report carries + +A `DriftReport` records both halves. `driftedEntityTypes` and `driftedRelationshipTypes` are the +graph-truth signal — "the graph holds something nobody declared" — an operator watching the log wants. +`declaredDiff` is how the declaration itself moved since the last completed sweep, and it is `null` +when there was no baseline to compare against. `DriftReport.quarantineDiff(declaredVersion)` merges +the two into the exact comparison a sweep evaluates, and `DriftCheckResult.quarantineDiff` is the same +thing off a live result. + +Carrying both is what makes a report a sound basis for deciding. A report holding only the graph-truth +half could read completely clean while a sweep on the very same state quarantined, because a property +that quietly narrowed shows up in neither drifted set. The person who checked would have had no way to +know. `hasDrift` still answers the narrow graph-truth question; `hasAnyChange` answers "would a sweep +have anything at all to look at?". + +The declared comparison is part of the record, so a `DriftReportStore` backend persists it alongside +the drifted type sets. A report read back out of the store a year later resolves to the same +`quarantineDiff` the live result did. + +### The sweep SPI + +`DriftSweepCapable` is three store operations plus one `sweep` that composes them: + +- `quarantineCandidates(contextId, mentionTypes, limit, afterId)` — **bounded** by `limit`, + **scoped** to one required `ContextId`, and **filtered** on mention type by the backend, ordered by + proposition id so `afterId` is a usable cursor. All three are contract requirements, spelled out in + the KDoc. Reading every proposition and filtering afterwards materialises every tenant in one heap, + and costs the size of the store on a change that touches one type. +- `applyQuarantine(decision)` — persist one `STALE` copy the policy built, and announce the + transition when the status genuinely moved. +- `releaseFromQuarantine(propositionId)` — see [Release](#release) below. + +The mention types come from `DriftQuarantinePolicy.candidateMentionTypes(diff)`, so the store needs no +policy knowledge of its own. The contract that makes that sound: a proposition whose mention types are +all outside that set must evaluate to conforming, or a bounded sweep would skip something it should +have caught. Both spellings of every name are offered, since a declaration can be fully qualified +where a graph writes the simple label. + +A store implements `DriftSweepCapable` when its backend can honour that. One that can't sweeps through +`PropositionStoreDriftSweep`, the reference implementation, which works over any `PropositionStore` and +is honest about the cost: it reads the one context and applies the mention-type filter, the ordering +and the page bound in the JVM. The context bound is real there — the read never leaves the context — +and the rest is the part a backend should push down. + +### Release + +Quarantine is reversible, and `releaseFromQuarantine` is what reverses it: it restores the status the +proposition carried before quarantine and clears both quarantine keys in one write. + +Clearing `dice.metamodel.quarantine.reason` by hand does half the job and leaves the proposition +`STALE`, so it stays out of ordinary retrieval with nothing on it saying why, and the next sweep +treats it as a fresh candidate and quarantines it again. The status to restore comes from +`dice.metamodel.quarantine.previousStatus`, which the policy writes onto the `STALE` copy at +quarantine time — `STALE` is a destination several roads lead to, ordinary decay included, so a +release with nothing recorded could only guess. A proposition carrying no readable value there goes +back to `ACTIVE`, which is what "let this back into use" means once the record is gone. + +Releasing something that was never quarantined answers `null` and changes nothing, so releasing twice +is safe. #### The baseline only moves once a sweep finishes `sweptVersion` is a pointer to one reconciled declaration per schema, tracked apart from the ordinary -stamp history above, and it advances only when `DefaultDriftCheckRunner.run()` calls -`MetamodelVersionStore.markSwept` — the very last thing it does, and only for a **live, unscoped** -run. The three cases below only hold for a store that overrides `sweptVersion`/`markSwept` with -genuinely independent tracking, such as `InMemoryMetamodelVersionStore`. A store that doesn't -override them inherits the interface default, `sweptVersion` answering `latestVersion` — see that -method's doc on `MetamodelVersionStore` for how much of the runner's care this reopens. - -- A **dry run** never calls `markSwept`. It still reads `sweptVersion` and computes the - declared-vs-previous diff, but throws the result away without acting on it — `DriftReport.hasDrift` - comes only from the observed-vs-declared comparison, so a dry run cannot preview what a live run - would quarantine from the declared-vs-previous side. This is a known limitation, not an oversight: - a dry run can report `hasDrift = false` and `quarantinedCount = 0` while the very next live run, - same declaration, finds and quarantines a lossy declared change. Treating a dry run as having - reconciled the schema would make this worse — a live run right after would compare the declaration - against itself and find nothing at all — so `run()` with no arguments stays a check that reports - and changes nothing, including this pointer, at the cost of not being a reliable preview of - declared-vs-previous quarantine. -- A run **scoped to one context** still computes and acts on the declared-vs-previous diff for that - context's own candidates, but leaves the schema-wide baseline where it was. Advancing it after a - scoped sweep would tell every other context's later check "this declaration is already - reconciled," when only one context's candidates were ever looked at. -- A **crash between the history write and the end of the sweep** leaves `markSwept` uncalled, so the - next check — whenever it runs — sees the same unreconciled baseline and retries the same - comparison. The already-quarantined bucket makes that retry safe: anything the interrupted run did - manage to save comes back as already handled, not re-flagged. - -`sweptVersion` is a different question from `MetamodelVersionStore.latestVersion`, which the store's -own doc covers in detail: `latestVersion` tracks write order and answers wrong once a declaration -cycles back to a stamp it already used before. +stamp history above. **A completed sweep is the only thing that may move it**, and the host that ran +the sweep is what calls `SweptBaselineStore.markSwept`, once every context it meant to reconcile is +done. Three writes look tempting and are all wrong: + +- A **drift check** never marks. It reads `sweptVersion`, computes the declared-vs-previous diff, and + reports it. The lossy change it found is still waiting for somebody to act on, so retiring it would + mean the next check compared the declaration against itself and found nothing at all. +- A sweep **scoped to one context** reconciled that context alone. Marking after it would tell every + other context's later check "this declaration is already reconciled," when only one context's + candidates were ever looked at. +- An **interrupted** sweep leaves `markSwept` uncalled, because the host never reaches it, so the + next check sees the same unreconciled baseline and the next sweep retries the same comparison. The + already-quarantined bucket makes that retry safe: anything the interrupted sweep did manage to save + comes back as already handled, and never re-flagged. + +Marking after a sweep that found nothing to quarantine is correct. "Nothing needed doing" is a +completed reconciliation against that declaration. + +`sweptVersion` and `markSwept` live on `SweptBaselineStore`, a separate interface a version store +implements when it can keep the pointer honestly, with **no default bodies**. That absence is the +point. A forwarding default answering `MetamodelVersionStore.latestVersion` made every store look like +it tracked a baseline while answering with write order, so a check's own stamp moved what the next +sweep treated as already reconciled. A store implementing nothing here reports `declaredDiff = null` +and gets the graph-truth half alone, which is the honest answer. + +`sweptVersion` is also a different question from `latestVersion`, which the store's own doc covers in +detail: `latestVersion` tracks write order and answers wrong once a declaration cycles back to a stamp +it already used before. ### Lossy changes @@ -345,23 +428,22 @@ Three properties make this safe to run as routine maintenance: quarantined before it was pinned is unaffected: idempotency is checked first, so it stays `alreadyQuarantined`. -The policy decides and doesn't write. On a live run, the `STALE` copies it returns come back to the -caller, and the runner persists them. A dry run never calls `evaluate` at all, so there is no policy -decision to persist. See "The baseline only moves once a sweep finishes" above for what a dry run -does and doesn't do. +The policy decides and doesn't write. The `STALE` copies it returns come back to the caller, and the +sweep persists them through `applyQuarantine`. A drift check never calls `evaluate` at all, so there +is no policy decision for it to persist. -The runner reads and writes those propositions through `PropositionStore`, the base persistence port, -rather than `PropositionRepository`. A drift check only reads by context or in bulk and saves; -requiring vector search, graph traversal and temporal query alongside would shut a plain -store-and-retrieve backend out of drift checking over capabilities it never uses. +`PropositionStoreDriftSweep` reads and writes those propositions through `PropositionStore`, the base +persistence port, and never `PropositionRepository`. A sweep reads by context and saves; requiring +vector search, graph traversal and temporal query alongside would shut a plain store-and-retrieve +backend out of drift work over capabilities it never uses. ### Announcing a quarantine -Each proposition the runner actually quarantines is announced to a `DiceEventListener` as a +Each proposition a sweep actually quarantines is announced to a `DiceEventListener` as a `PropositionStatusChanged` (`previousStatus` the status it carried in, `newStatus` `STALE`, `reason` -the same text the metadata carries), right after it is saved. This is what lets something like -`ProjectionLineageStaleCascade` hear that a proposition went stale and mark its projection records -stale in turn. +the same text the metadata carries), right after it is saved. A release announces the transition back. +This is what lets something like `ProjectionLineageStaleCascade` hear that a proposition went stale +and mark its projection records stale in turn. A proposition can arrive at the sweep already `STALE` from ordinary decay, with no quarantine reason yet, and the policy correctly treats that as a fresh candidate — the idempotency rule only skips one @@ -369,13 +451,13 @@ that's *already quarantined*, not one that's merely stale for some other reason. writes the reason but doesn't move its status, so no event fires for it: the event promises a transition happened, and here one didn't. -The runner emits this itself. The injected `PropositionStore` is never asked to notice the +The sweep emits this itself. The injected `PropositionStore` is never asked to notice the transition and emit it on its own — the way `EventEmittingPropositionRepository` does when an application chooses to wrap its repository in one — because that would make the signal conditional on a wiring choice made somewhere else entirely, and silently absent for an application that wires a plain, undecorated store, which is what auto-configuration hands out by default. Emitting the event -from inside the runner, the same way `DefaultCollectorRunner` already emits its own transitions, -means the signal fires wherever the runner runs, independent of what store backs it. `listener` +from inside the sweep, the same way `DefaultCollectorRunner` already emits its own transitions, +means the signal fires wherever the sweep runs, independent of what store backs it. `listener` defaults to a no-op, so nothing about the rest of this section changes for a caller who isn't listening. @@ -389,15 +471,15 @@ column whose value doesn't fit the declared schema, the value is captured into a column rather than dropped, and the row still lands. The stance is that data an extraction already produced is evidence: a schema that no longer describes it sets that data aside for a person to look at rather than deleting it. Quarantine is the same move on a proposition: `STALE`, annotated -with a reason, still in the store, still readable, and reversible by clearing one metadata key. +with a reason, still in the store, still readable, and reversible through `releaseFromQuarantine`. **Enforcement and evolution are separate settings**, which is how Delta and the Snowflake-style lakehouses organize this. Enforcement asks whether an incoming write matches; evolution asks whether the schema should move to accommodate it. DICE splits them the same way, and both halves are opt-in: the declared schema — which types a `GovernedTypeSelector` governs and what `SchemaAliases` says they -used to be called — is the enforcement side, and the drift mode (`run()` dry versus -`run(dryRun = false)`) is what a check is allowed to do about a mismatch. A schema that governs -nothing enforces nothing, and a dry check changes nothing whatever it finds. +used to be called — is the enforcement side, and calling `DriftSweepCapable.sweep` is what a host +does about a mismatch. A schema that governs nothing enforces nothing, and a check changes nothing +whatever it finds. ### Not adopted: auto-adopting additive drift @@ -425,10 +507,12 @@ If it is ever wanted, the shape that would be safe: ## Scope -Every part of a run takes the same optional `ContextId`, and it means the same thing throughout: the -observed snapshot, the candidate propositions read for quarantine, and the persisted report are all -confined to that one context. A mis-declared schema in one context can only quarantine propositions -in that same context. Pass `null` and the check covers the whole graph. +A check takes an optional `ContextId`: the observed snapshot and the persisted report are both +confined to that one context, and `null` covers the whole graph. + +A sweep takes a **required** one. There is no whole-graph sweep, so a mis-declared schema in one +context has no way to reach another context's propositions. A host that means to reconcile several +contexts sweeps each in turn and marks the baseline once they are all done. ## Using it @@ -436,32 +520,47 @@ in that same context. Pass `null` and the check covers the whole graph. val differ = StructuralMetamodelDiffer() // implements both differ interfaces below val runner = DefaultDriftCheckRunner( declaredSchemaSource = { DeclaredSchema.from(dataDictionary, governed) }, - versionStore = versionStore, + versionStore = versionStore, // a SweptBaselineStore, to get the declared comparison too observedSchemaSource = observedSchemaSource, differ = differ, metamodelDiffer = differ, driftReportStore = driftReportStore, - quarantinePolicy = MentionTypeDriftQuarantinePolicy(), - propositionStore = propositionStore, - listener = SafeDiceEventListener(projectionLineageStaleCascade), // optional; defaults to a no-op ) -// The default: dry, whole graph. Reports, changes nothing. +// A check. Reports, changes nothing. val result = runner.run() if (result.hasDrift) { log.warn("undeclared in the graph: {} {}", result.driftedEntityTypes, result.driftedRelationshipTypes) } - -// Opt in to acting on it. -val live = runner.run(dryRun = false) -log.info("quarantined {} proposition(s)", live.quarantinedCount) +if (result.hasAnyChange) { + log.warn("a sweep would evaluate: {}", result.quarantineDiff.changes) +} // What did the last week look like? driftReportStore.globalDriftReports(schemaName, limit = 50, since = Instant.now().minus(7, ChronoUnit.DAYS)) ``` -`DriftCheckResult` reads its drifted types off the `report` it saved rather than keeping a second -copy, so what you log and what an operator later reads out of the store can't disagree. +Acting on it is a separate call a person decides to make: + +```kotlin +val sweep = PropositionStoreDriftSweep( + propositionStore, + SafeDiceEventListener(projectionLineageStaleCascade), // optional; defaults to a no-op +) + +for (contextId in contextsToReconcile) { + val swept = sweep.sweep(result.quarantineDiff, MentionTypeDriftQuarantinePolicy(), contextId) + log.info("quarantined {} proposition(s) in {}", swept.quarantined.size, contextId) +} +// Only now, with every context done, has anything actually been reconciled. +versionStore.markSwept(result.declaredVersion) + +// Changed your mind about one of them? +sweep.releaseFromQuarantine(propositionId) +``` + +`DriftCheckResult` reads its drifted types off the `report` it saved and keeps no second copy, so what +you log and what an operator later reads out of the store can't disagree. The runner is stateless and schedules nothing. Running it repeatedly, or for different schemas at once, is fine. Two concurrent checks of the same schema don't corrupt anything, since each captures @@ -471,10 +570,17 @@ matters. ## What comes next `DriftReportStore` and `ObservedSchemaSource` are contracts here with no implementation yet. They -need a Drivine-backed report store, and an observer that asks Neo4j for its distinct labels and -relationship types. There is no Spring configuration in `dice-metamodel` either, so a runner is an -ordinary constructor call until the autoconfigure slice assembles one, with quarantine off unless a -host turns it on. +need a Drivine-backed report store — one that persists a report's `declaredDiff` alongside its drifted +type sets — and an observer that asks Neo4j for its distinct labels and relationship types. + +`DriftSweepCapable` and `SweptBaselineStore` are the same: contracts with an in-memory reference +implementation and no durable one. Until the graph-backed store implements `SweptBaselineStore`, a +Drivine-backed host gets the graph-truth half of a report and a `null` declared comparison. Until it +implements `DriftSweepCapable`, a host sweeps through `PropositionStoreDriftSweep`, which is correct +and does its filtering in the JVM. + +There is no Spring configuration in `dice-metamodel` either, so a runner is an ordinary constructor +call, and nothing sweeps unless a host calls it. **Registration-time compatibility evaluation** is deferred design, tracked under the metamodel epic (`embabel/dice#45`) until it gets its own issue. A registry-style compatibility check would grade a From c730c9219be810a83877bb5d8a7740fa642c31cf Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:53:06 -0400 Subject: [PATCH 06/11] Give quarantine its own status and move it beside the lifecycle policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quarantine was a STALE plus a reason key, and the default decay policy revives any STALE proposition whose utility clears the recovery threshold with no look at the reason — so a quarantined proposition with healthy confidence came back on the next decay sweep and the two sweeps alternated. PropositionStatus.QUARANTINED ends that: reads that filter on ACTIVE exclude it structurally, the lifecycle policies leave it alone entirely, and release is an explicit transition that restores the recorded prior status, clears the reason, and announces the change. The quarantine machinery moves into dice beside StatusTransitionPolicy, where the conflict it resolves lives, and dice-metamodel returns to a leaf over DataDictionary with no dice dependency. Declared renames and aliases change how the diff and the drift check read old data; nothing rewrites a stored mention type. --- CHANGELOG.md | 95 +++++--- dice-metamodel/pom.xml | 11 - .../dice/metamodel/DriftCheckRunner.kt | 4 +- .../support/DefaultDriftCheckRunner.kt | 8 +- .../dice/metamodel/DriftCheckRunnerTest.kt | 8 +- dice/pom.xml | 12 + .../lineage/ProjectionLineageStaleCascade.kt | 10 +- .../memory/DefaultDreamLoopOrchestrator.kt | 14 +- .../embabel/dice/proposition/Proposition.kt | 18 +- .../dice/spi}/DriftQuarantinePolicy.kt | 75 +++--- .../embabel/dice/spi}/DriftSweepCapable.kt | 49 ++-- .../spi}/MentionTypeDriftQuarantinePolicy.kt | 34 +-- .../dice/spi}/PropositionStoreDriftSweep.kt | 30 ++- .../dice/spi/StatusTransitionPolicy.kt | 8 + .../dice/spi}/DriftQuarantinePolicyTest.kt | 36 +-- .../com/embabel/dice/spi}/DriftSweepTest.kt | 111 +++++++-- .../spi/QuarantineDecayInteractionTest.kt | 225 ++++++++++++++++++ docs/design/metamodel-drift.md | 103 +++++--- 18 files changed, 637 insertions(+), 214 deletions(-) rename {dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel => dice/src/main/kotlin/com/embabel/dice/spi}/DriftQuarantinePolicy.kt (74%) rename {dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel => dice/src/main/kotlin/com/embabel/dice/spi}/DriftSweepCapable.kt (83%) rename {dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support => dice/src/main/kotlin/com/embabel/dice/spi}/MentionTypeDriftQuarantinePolicy.kt (96%) rename {dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support => dice/src/main/kotlin/com/embabel/dice/spi}/PropositionStoreDriftSweep.kt (86%) rename {dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel => dice/src/test/kotlin/com/embabel/dice/spi}/DriftQuarantinePolicyTest.kt (97%) rename {dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel => dice/src/test/kotlin/com/embabel/dice/spi}/DriftSweepTest.kt (87%) create mode 100644 dice/src/test/kotlin/com/embabel/dice/spi/QuarantineDecayInteractionTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f0f1c0b..f73c80c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -211,14 +211,26 @@ and the consumer PRs that deliver it). it reads the one context and does the filter, ordering and page bound in the JVM, which is the part a durable backend pushes down. There is no Drivine implementation, and nothing in DICE calls a sweep on a timer or from auto-configuration. - **Release is a real operation.** `releaseFromQuarantine` restores the status a proposition carried - before quarantine and clears its quarantine metadata in one write. Clearing - `dice.metamodel.quarantine.reason` by hand left the proposition `STALE`, out of ordinary retrieval - with nothing saying why, and a fresh candidate for the next sweep. The status to restore comes from - the new `DriftQuarantineKeys.PREVIOUS_STATUS` (`dice.metamodel.quarantine.previousStatus`), which - the policy writes onto the `STALE` copy at quarantine time; a proposition with no readable value - there is released to `ACTIVE`. Releasing something that was never quarantined answers `null`, so - releasing twice is safe. + **Quarantine is its own lifecycle status, so the hold has an owner.** `PropositionStatus` gains + `QUARANTINED`, and a drift sweep moves a stranded proposition there. A quarantine expressed as + `STALE` plus a metadata note sat directly in the decay path: `DecayStatusPolicy` moves any `STALE` + proposition back to `ACTIVE` once utility clears the recovery threshold, with no reason check, and + `DecayManager` persists that. A confident proposition held for schema drift was therefore revived by + the next decay sweep and held again by the next drift sweep, indefinitely. Recovery from quarantine + was a side effect of that overlap, with no operation behind it. `QUARANTINED` closes it structurally: reads + that filter on `ACTIVE` exclude it, `DecayStatusPolicy` never sees a `STALE` to revive *and* returns + `null` for `QUARANTINED` outright (so a host that widens `DecaySweepConfig.targetStatuses` to every + status still can't lift a hold), `pruneStale` leaves it alone, contradiction resolution and the + abstraction pass read `ACTIVE` and never reach it, and the policy's already-quarantined check is the + status alone — editing the reason metadata by hand releases nothing. + `ProjectionLineageStaleCascade` treats `QUARANTINED` as terminal alongside `SUPERSEDED`, + `CONTRADICTED` and `STALE`, so a quarantine still marks derived projection records stale. + **Release is a real operation, and now the only way out.** `releaseFromQuarantine` restores the + status a proposition carried before quarantine, clears its quarantine metadata, and announces the + transition, in one write. The status to restore comes from the new + `DriftQuarantineKeys.PREVIOUS_STATUS` (`dice.metamodel.quarantine.previousStatus`), which the policy + writes onto the quarantined copy; a proposition with no usable value there is released to `ACTIVE`. + Releasing a proposition that isn't quarantined answers `null`, so releasing twice is safe. **The swept baseline moves only for a completed sweep.** `sweptVersion` and `markSwept` live on the new `SweptBaselineStore : MetamodelVersionStore`, with no default bodies, and the host that ran the sweep is what calls `markSwept` once every context is reconciled. Splitting them off is the fix for @@ -232,7 +244,7 @@ and the consumer PRs that deliver it). re-save contract, even though `A` is what's declared again). Quarantine itself is non-destructive, idempotent, and honors pinning. `DriftQuarantinePolicy` returns `QuarantineDecision`s (`Conforming` / `Quarantined` / `AlreadyQuarantined` / `Protected`); - only `Quarantined` is an immutable `STALE` copy carrying a reason and the status it came from, for + only `Quarantined` is an immutable `QUARANTINED` copy carrying a reason and the status it came from, for the caller to persist — the other three carry the proposition back untouched. A pinned proposition a lossy change would otherwise catch comes back `Protected`, untouched, per DICE's cross-cutting pin promise, with the same reason text so an operator can still see what it would have caught. The @@ -246,29 +258,42 @@ and the consumer PRs that deliver it). is recognised as the declared type it is. This matches what `DeclaredObservedDiffer` already did on the declared side, so the two halves of a check agree about which type is which; a mention matching under the other spelling of its own type is ordinary matching and is never reported as a former - name. Each proposition a sweep actually moves to `STALE` is announced to its `DiceEventListener` as - a `PropositionStatusChanged`, emitted by the sweep itself so the signal doesn't depend on whether - the injected `PropositionStore` happens to be wrapped in something like + name. Each proposition a sweep quarantines is announced to its `DiceEventListener` as a + `PropositionStatusChanged`, emitted by the sweep itself so the signal doesn't depend on whether the + injected `PropositionStore` happens to be wrapped in something like `EventEmittingPropositionRepository` — the default auto-configured store isn't. A proposition - already `STALE` from ordinary decay that a sweep quarantines (writing the reason, leaving the - status where it was) emits no event, since nothing about its status actually changed. A release - announces the transition back. This is what lets a listener such as `ProjectionLineageStaleCascade` - mark a quarantined proposition's projection records stale in turn. - **Compatibility: additive, with two source-breaking exceptions.** New types in an existing module; - no existing API touched except the two below. `DefaultDriftCheckRunner`'s constructor gains a - *required* `metamodelDiffer: MetamodelDiffer` parameter (the declared-vs-previous comparison) — - every existing caller must start supplying one. `QuarantineDecision` is a sealed interface gaining - a fourth member, `Protected`, so an external exhaustive `when` over it needs a new branch to keep - compiling — the same shape of change already accepted for `MetamodelChange` in this same Unreleased - block. Everything else here stays additive: `MetamodelVersionStore` is unchanged, so every existing - implementation — `DrivineMetamodelVersionStore` included — keeps compiling untouched, and a backend - opts into baseline tracking by implementing `SweptBaselineStore` when it is ready; - `QuarantineResult` gains a `protected: List` parameter defaulted to - empty, so existing callers of its constructor are unaffected. One dependency-graph change: - `dice-metamodel` now depends on `dice` (core), because quarantine works on the proposition model, so - anything depending on `dice-metamodel` alone now pulls `dice` in transitively. `dice-metamodel` is - no longer a leaf module, and `embabel-agent-rag-core` joins `embabel-agent-api` as a `provided` - dependency it expects the host to supply. + already `STALE` from ordinary decay is a fresh candidate, moves `STALE` → `QUARANTINED`, and a + later release puts it back to `STALE`. A release announces the transition back. This is what lets a + listener such as `ProjectionLineageStaleCascade` mark a quarantined proposition's projection records + stale in turn. + **The quarantine machinery lives in `dice` core, in `com.embabel.dice.spi`.** + `DriftQuarantinePolicy`, `DriftQuarantineKeys`, `QuarantineDecision`, `QuarantineResult`, + `DriftSweepCapable`, `MentionTypeDriftQuarantinePolicy` and `PropositionStoreDriftSweep` sit beside + `StatusTransitionPolicy` and `SweepPolicy`, because moving a proposition between lifecycle statuses + is what that package is for. The module dependency now points one way: `dice` depends on + `dice-metamodel` to read a `MetamodelDiff`, and `dice-metamodel` is a leaf over the agent + `DataDictionary` again with no view of the proposition model at all. A drift check therefore has no + type through which it could reach a proposition, which is the structural half of "a check changes + nothing". `embabel-agent-rag-core` remains a `provided` dependency of `dice-metamodel`. + **Compatibility: additive on the released surface, with three source-breaking exceptions.** + `DefaultDriftCheckRunner`'s constructor gains a *required* `metamodelDiffer: MetamodelDiffer` + parameter (the declared-vs-previous comparison) — every existing caller must start supplying one. + `QuarantineDecision` is a sealed interface gaining a fourth member, `Protected`, so an external + exhaustive `when` over it needs a new branch to keep compiling — the same shape of change already + accepted for `MetamodelChange` in this same Unreleased block. `PropositionStatus` gains + `QUARANTINED`, so an exhaustive `when` over the enum needs a new branch too; inside DICE there was + exactly one (`DefaultDreamLoopOrchestrator.statusStrength`, where `QUARANTINED` now ranks above + every automatic retirement, since letting one overwrite a governance hold would drop the reason and + the recorded prior status with it), and `me`'s status matching is the known external consumer, which + recompiles. Persistence is by enum *name* throughout (`PropositionGraphMapper`, + `CollectorTraceRowMappers`, `LineageRowMappers`), so no stored value changes meaning. The + quarantine types keep their names and move package, from `com.embabel.dice.metamodel` and + `com.embabel.dice.metamodel.support` to `com.embabel.dice.spi`; they were added in this same + Unreleased block and have never shipped. Everything else stays additive: `MetamodelVersionStore` is + unchanged, so every existing implementation — `DrivineMetamodelVersionStore` included — keeps + compiling untouched, and a backend opts into baseline tracking by implementing `SweptBaselineStore` + when it is ready; `QuarantineResult` gains a `protected: List` + parameter defaulted to empty, so existing callers of its constructor are unaffected. - Rename-aware quarantine and a type-widening allow-list in `MentionTypeDriftQuarantinePolicy`, **EXPERIMENTAL** (behavior may change before 1.0). @@ -310,10 +335,10 @@ and the consumer PRs that deliver it). aliases. For a schema declaring none, matching is exactly what it was and the only move is permissive: a property whose value type went along one of the four allow-listed pairs no longer quarantines. Propositions an earlier sweep quarantined for one of those widenings stay - quarantined — the already-quarantined check runs before any matching and nothing clears the - reason key on its own, so no stored proposition changes state without an operator. To release - them, clear `dice.metamodel.quarantine.reason` on those propositions and re-run the check; under - the new rule they come back conforming. For a schema that declares aliases, matching now reaches + quarantined — the already-quarantined check runs before any matching and nothing lifts a hold on + its own, so no stored proposition changes state without an operator. To release them, call + `releaseFromQuarantine` on those propositions and re-run the check; under the new rule they come + back conforming. For a schema that declares aliases, matching now reaches data under a type's former names, so a proposition mentioning an old type name can newly quarantine when the renamed type lost something — which is the point: the old name is what the graph stores. Aliases arrive in this same Unreleased block, so no consumer can be in that state diff --git a/dice-metamodel/pom.xml b/dice-metamodel/pom.xml index 41d7c653..6f3e3c86 100644 --- a/dice-metamodel/pom.xml +++ b/dice-metamodel/pom.xml @@ -13,17 +13,6 @@ Schema governance for DICE knowledge graphs: content-hash stamping, the declared-schema contract, diffing, drift checking, and non-destructive quarantine - - - com.embabel.dice - dice - - + + com.embabel.dice + dice-metamodel + + com.embabel.agent embabel-agent-rag-core diff --git a/dice/src/main/kotlin/com/embabel/dice/projection/lineage/ProjectionLineageStaleCascade.kt b/dice/src/main/kotlin/com/embabel/dice/projection/lineage/ProjectionLineageStaleCascade.kt index a522aa0c..c9d15823 100644 --- a/dice/src/main/kotlin/com/embabel/dice/projection/lineage/ProjectionLineageStaleCascade.kt +++ b/dice/src/main/kotlin/com/embabel/dice/projection/lineage/ProjectionLineageStaleCascade.kt @@ -24,11 +24,16 @@ import org.slf4j.LoggerFactory /** * Listens for proposition status changes and marks the corresponding projection records stale. * - * When a proposition moves to a terminal status (SUPERSEDED, CONTRADICTED, or STALE), every - * [ProjectionRecord] derived from it is flipped to [ProjectionLifecycle.STALE] in the + * When a proposition moves to a terminal status (SUPERSEDED, CONTRADICTED, STALE, or QUARANTINED), + * every [ProjectionRecord] derived from it is flipped to [ProjectionLifecycle.STALE] in the * [recordStore]. Non-terminal transitions (ACTIVE, PROMOTED) are ignored, as is any event * type other than [PropositionStatusChanged]. * + * QUARANTINED belongs in that list for the same reason the other three do: the proposition has left + * ordinary use, so anything projected from it is no longer backed by a live belief. A release moves + * it back to a non-terminal status and fires its own event, which this cascade ignores; re-deriving + * the projection is the projector's job either way. + * * Wire this up alongside your collector — either directly or as part of a composite listener. * Wrapping it in a safe listener is a good idea so a fault here can't abort the sweep that * fired the event. @@ -56,6 +61,7 @@ class ProjectionLineageStaleCascade( PropositionStatus.SUPERSEDED, PropositionStatus.CONTRADICTED, PropositionStatus.STALE, + PropositionStatus.QUARANTINED, ) } } diff --git a/dice/src/main/kotlin/com/embabel/dice/projection/memory/DefaultDreamLoopOrchestrator.kt b/dice/src/main/kotlin/com/embabel/dice/projection/memory/DefaultDreamLoopOrchestrator.kt index 921e7bf8..cd77c4ce 100644 --- a/dice/src/main/kotlin/com/embabel/dice/projection/memory/DefaultDreamLoopOrchestrator.kt +++ b/dice/src/main/kotlin/com/embabel/dice/projection/memory/DefaultDreamLoopOrchestrator.kt @@ -205,10 +205,15 @@ data class DefaultDreamLoopOrchestrator( * * When two passes each hand back a copy of the same proposition with a different target status, * the order they happen to appear in the flat-mapped list must not decide what gets persisted. - * We pick a deterministic winner by status strength: a contradiction (the belief is now wrong) - * outranks a supersession (still true, just rolled up into an abstraction), which outranks a - * decay-to-STALE, and any retirement outranks leaving it ACTIVE. Freshly created propositions - * (new ids, e.g. abstractions) never collide, so they pass through untouched. + * We pick a deterministic winner by status strength: a quarantine (schema governance is holding + * this one, and only an explicit release lifts it) outranks a contradiction (the belief is now + * wrong), which outranks a supersession (still true, just rolled up into an abstraction), which + * outranks a decay-to-STALE, and any retirement outranks leaving it ACTIVE. Freshly created + * propositions (new ids, e.g. abstractions) never collide, so they pass through untouched. + * + * No dream-loop pass quarantines anything, so QUARANTINED reaches this ranking only if a + * consumer pass produces one. It ranks top because letting an automatic retirement overwrite a + * governance hold would drop the quarantine reason and the recorded prior status with it. */ private fun reconcileSaves(toSave: List): List = toSave @@ -216,6 +221,7 @@ data class DefaultDreamLoopOrchestrator( .map { (_, copies) -> copies.maxByOrNull { statusStrength(it.status) }!! } private fun statusStrength(status: PropositionStatus): Int = when (status) { + PropositionStatus.QUARANTINED -> 5 PropositionStatus.CONTRADICTED -> 4 PropositionStatus.SUPERSEDED -> 3 PropositionStatus.STALE -> 2 diff --git a/dice/src/main/kotlin/com/embabel/dice/proposition/Proposition.kt b/dice/src/main/kotlin/com/embabel/dice/proposition/Proposition.kt index ec4a6926..5aa98366 100644 --- a/dice/src/main/kotlin/com/embabel/dice/proposition/Proposition.kt +++ b/dice/src/main/kotlin/com/embabel/dice/proposition/Proposition.kt @@ -50,7 +50,23 @@ enum class PropositionStatus { * re-reinforcement can lift a STALE proposition back to [ACTIVE]. */ @ApiStatus.Experimental - STALE + STALE, + + /** + * Held out of use by schema governance until a person looks. + * + * A drift sweep puts a proposition here when a schema change stranded the entity types it + * mentions. It reads like [STALE] to anything filtering on [ACTIVE], and the difference is who + * owns it: decay reaches [STALE] on its own and can lift a proposition back out on its own, + * while nothing automatic touches a quarantined proposition. Lifecycle policies leave it alone + * — no decay transition, no revival, no contradiction-resolution move — and the one way out is + * an explicit release, which restores the status the proposition carried before quarantine. + * + * The reason it was held is on the proposition, under + * `DiceMetadataKeys.QUARANTINE_REASON`. + */ + @ApiStatus.Experimental + QUARANTINED } /** diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt b/dice/src/main/kotlin/com/embabel/dice/spi/DriftQuarantinePolicy.kt similarity index 74% rename from dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt rename to dice/src/main/kotlin/com/embabel/dice/spi/DriftQuarantinePolicy.kt index c39cfc7f..e4a3454c 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicy.kt +++ b/dice/src/main/kotlin/com/embabel/dice/spi/DriftQuarantinePolicy.kt @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.embabel.dice.metamodel +package com.embabel.dice.spi +import com.embabel.dice.metamodel.MetamodelDiff import com.embabel.dice.proposition.Proposition import com.embabel.dice.proposition.PropositionStatus /** - * Metadata keys the metamodel writes onto a proposition, alongside the shared + * Metadata keys drift quarantine writes onto a proposition, alongside the shared * [com.embabel.dice.common.DiceMetadataKeys.QUARANTINE_REASON]. * * They live here because drift quarantine is the only thing that writes or reads them, and the core @@ -32,11 +33,10 @@ object DriftQuarantineKeys { * The [PropositionStatus] a proposition carried at the moment it was quarantined, stored as its * `name`. * - * Quarantine moves a proposition to `STALE`, and `STALE` is a destination several roads lead to - * — ordinary decay reaches it as well. Without this key, releasing a quarantine could only guess - * where to put the proposition back. With it, release is exact: - * [DriftSweepCapable.releaseFromQuarantine] reads the value, restores that status, and clears - * both keys. + * A proposition can be quarantined from any status — active, promoted, already stale from + * ordinary decay — so without this key, releasing one could only guess where to put it back. + * With it, release is exact: [DriftSweepCapable.releaseFromQuarantine] reads the value, restores + * that status, and clears both keys. */ const val PREVIOUS_STATUS = "dice.metamodel.quarantine.previousStatus" } @@ -64,12 +64,13 @@ sealed interface QuarantineDecision { data class Conforming(val proposition: Proposition) : QuarantineDecision /** - * An earlier sweep already quarantined this one: it is `STALE` and carries a - * `DiceMetadataKeys.QUARANTINE_REASON`, so this sweep left it as it found it. Nothing needs + * An earlier sweep already quarantined this one: its status is + * [PropositionStatus.QUARANTINED], so this sweep left it as it found it. Nothing needs * persisting for these. * - * To force one back through evaluation, clear its `QUARANTINE_REASON` metadata and pass it in - * again. + * To force one back through evaluation, release it + * ([DriftSweepCapable.releaseFromQuarantine]) and pass it in again. Editing the metadata by hand + * does nothing, because the hold is the status. * * @property proposition The proposition, unchanged. * @property originalReason The reason the earlier sweep recorded, when it is still readable as @@ -84,17 +85,17 @@ sealed interface QuarantineDecision { /** * Schema drift stranded this proposition, and it has been flagged. * - * [proposition] is an immutable copy already moved to `STALE` and annotated with the reason - * under `DiceMetadataKeys.QUARANTINE_REASON` and the status it came from under - * [DriftQuarantineKeys.PREVIOUS_STATUS]. The original is never mutated, and nothing is written - * anywhere; persisting the copy is the caller's job. + * [proposition] is an immutable copy already moved to [PropositionStatus.QUARANTINED] and + * annotated with the reason under `DiceMetadataKeys.QUARANTINE_REASON` and the status it came + * from under [DriftQuarantineKeys.PREVIOUS_STATUS]. The original is never mutated, and nothing + * is written anywhere; persisting the copy is the caller's job. * - * @property proposition The flagged, `STALE` copy. + * @property proposition The flagged, `QUARANTINED` copy. * @property reason A human-readable explanation of why it was quarantined. * @property affectedMentionTypes The entity type names that triggered it. * @property previousStatus The status the proposition carried before this decision, which - * [DriftSweepCapable.releaseFromQuarantine] restores. `STALE` when the proposition was already - * stale from ordinary decay, in which case quarantine wrote a reason and moved no status. + * [DriftSweepCapable.releaseFromQuarantine] restores. Any status but `QUARANTINED` itself: a + * proposition already quarantined never reaches this decision. */ data class Quarantined( val proposition: Proposition, @@ -106,9 +107,9 @@ sealed interface QuarantineDecision { /** * A pinned proposition that a lossy schema change would otherwise have quarantined. Pinning * promises cross-cutting immunity from reclamation (see `PropositionStore.pin`), so this sweep - * leaves it exactly as it was — an unpinned match on the same change gets flipped to `STALE`, - * this one doesn't — and reports it here so an operator reading the sweep can still see it was - * affected. + * leaves it exactly as it was — an unpinned match on the same change gets flipped to + * `QUARANTINED`, this one doesn't — and reports it here so an operator reading the sweep can + * still see it was affected. * * [proposition] is the original, completely untouched: no status change, no metadata written. * Persisting it is never necessary, unlike [Quarantined]'s copy. @@ -129,7 +130,8 @@ sealed interface QuarantineDecision { * What a whole sweep decided, with one decision per proposition it was given. * * @property conforming Propositions the change doesn't touch. - * @property quarantined Propositions this sweep flagged, as `STALE` copies waiting to be persisted. + * @property quarantined Propositions this sweep flagged, as `QUARANTINED` copies waiting to be + * persisted. * @property alreadyQuarantined Propositions an earlier sweep had already flagged, left untouched by * this one. Empty unless the input contained some. * @property protected Pinned propositions a lossy change would otherwise have caught, left @@ -157,9 +159,14 @@ data class QuarantineResult @JvmOverloads constructor( * Decides which propositions a schema change has stranded, and flags them. * * Quarantining is non-destructive. An affected proposition comes back as an immutable copy moved - * to [com.embabel.dice.proposition.PropositionStatus.STALE] with a metadata note explaining why; - * the original is untouched and nothing is written to any store. Persisting the copies is the - * caller's job, which is what lets a drift check preview a sweep without changing anything. + * to [PropositionStatus.QUARANTINED] with a metadata note explaining why; the original is untouched + * and nothing is written to any store. Persisting the copies is the caller's job, which is what lets + * a drift check preview a sweep without changing anything. + * + * A quarantined proposition has an owner. It sits in a status of its own, which every lifecycle + * policy in DICE leaves alone, so nothing automatic can lift the hold and nothing automatic can + * mistake it for ordinary staleness. Only [DriftSweepCapable.releaseFromQuarantine] lets one back + * into use. * * It takes a [MetamodelDiff], a comparison of two declared versions, which is what says exactly * which types the schema stopped recognising. A drift check compares a declaration against a live @@ -175,10 +182,10 @@ interface DriftQuarantinePolicy { /** * Evaluate every proposition against [diff]. * - * Implementations must be idempotent: a proposition already quarantined by a prior sweep - * (`STALE` with a `QUARANTINE_REASON`) must keep its original reason. Those come back unchanged - * as [QuarantineDecision.AlreadyQuarantined], not as conforming, which would report them as - * clean. Clear the metadata key to force one back through evaluation. + * Implementations must be idempotent: a proposition already quarantined by a prior sweep (one + * whose status is [PropositionStatus.QUARANTINED]) must keep its original reason. Those come + * back unchanged as [QuarantineDecision.AlreadyQuarantined]. Calling them conforming would + * report them as clean. Release one to force it back through evaluation. * * That classification does not depend on [diff]. Being already quarantined is a fact about the * proposition, so an empty or purely additive diff must still sort those into @@ -186,11 +193,11 @@ interface DriftQuarantinePolicy { * [QuarantineResult.conforming]. Drift checks run on a schedule and most runs find nothing, so * short-circuiting would report quarantined records as conforming on those runs. * - * A pinned proposition a lossy change would otherwise catch must never be flipped to `STALE`: - * implementations report it as [QuarantineResult.protected] instead, leaving the proposition - * itself untouched. This holds even for one an earlier sweep already quarantined before it was - * pinned; that one is [QuarantineResult.alreadyQuarantined], since idempotency (above) takes - * priority over the pin. + * A pinned proposition a lossy change would otherwise catch must never be flipped to + * `QUARANTINED`. Implementations report it as [QuarantineResult.protected] and leave the + * proposition itself untouched. This holds even for one an earlier sweep already quarantined + * before it was pinned; that one is [QuarantineResult.alreadyQuarantined], since idempotency + * (above) takes priority over the pin. * * @param diff What changed between the old and new schema. * @param propositions The propositions to evaluate. Any [Iterable] will do: a list, a diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftSweepCapable.kt b/dice/src/main/kotlin/com/embabel/dice/spi/DriftSweepCapable.kt similarity index 83% rename from dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftSweepCapable.kt rename to dice/src/main/kotlin/com/embabel/dice/spi/DriftSweepCapable.kt index e511d765..3f9d088f 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftSweepCapable.kt +++ b/dice/src/main/kotlin/com/embabel/dice/spi/DriftSweepCapable.kt @@ -13,10 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.embabel.dice.metamodel +package com.embabel.dice.spi import com.embabel.agent.core.ContextId +import com.embabel.dice.metamodel.DriftCheckResult +import com.embabel.dice.metamodel.DriftCheckRunner +import com.embabel.dice.metamodel.MetamodelDiff +import com.embabel.dice.metamodel.SweptBaselineStore import com.embabel.dice.proposition.Proposition +import com.embabel.dice.proposition.PropositionStatus /** * The store-side operations a host needs to act on a drift check: find the propositions a schema @@ -33,7 +38,7 @@ import com.embabel.dice.proposition.Proposition * * A store implements this when its backend can honour that, exactly the way DICE's other opt-in * store capabilities work. A store that can't keeps the plain persistence contract and its host - * sweeps through [com.embabel.dice.metamodel.support.PropositionStoreDriftSweep], the reference + * sweeps through [PropositionStoreDriftSweep], the reference * implementation, which is honest about doing the filtering in the JVM. * * ## Nothing calls this on its own @@ -56,10 +61,12 @@ import com.embabel.dice.proposition.Proposition * * ## Releasing is a real operation * - * Quarantine is reversible, and [releaseFromQuarantine] is what reverses it. Clearing the reason - * metadata by hand leaves the proposition `STALE`, which keeps it out of ordinary retrieval with - * nothing left to explain why. Release restores the status the proposition carried before quarantine - * and clears both keys in one write. + * Quarantine is reversible, and [releaseFromQuarantine] is the only thing that reverses it. + * [PropositionStatus.QUARANTINED] is a status of its own that every lifecycle policy in DICE leaves + * alone, so a quarantined proposition stays put until a host says otherwise: no decay sweep revives + * it, no consolidation pass moves it, and editing its metadata by hand changes nothing. Release + * restores the status the proposition carried before quarantine and clears both quarantine keys in + * one write. */ interface DriftSweepCapable { @@ -126,13 +133,12 @@ interface DriftSweepCapable { /** * Persist one quarantine decision. * - * [QuarantineDecision.Quarantined.proposition] is already the `STALE` copy carrying its reason - * and the status it came from; a policy built it and wrote nothing. This is the write. + * [QuarantineDecision.Quarantined.proposition] is already the `QUARANTINED` copy carrying its + * reason and the status it came from; a policy built it and wrote nothing. This is the write. * - * An implementation announces the transition to whatever listener it was given, skipping the - * announcement when the status didn't actually move — a proposition that arrived `STALE` from - * ordinary decay gets its reason written without transitioning, and an event claiming otherwise - * would be a lie a listener has no way to catch. + * An implementation announces the transition to whatever listener it was given as a + * [com.embabel.dice.common.PropositionStatusChanged], so a consumer watching the proposition + * lifecycle hears about a quarantine the way it hears about any other status move. * * @param decision What the policy decided. * @return The saved proposition. @@ -143,22 +149,25 @@ interface DriftSweepCapable { * Let a quarantined proposition back out: restore the status it carried before quarantine and * clear its quarantine metadata, in one write. * - * This is the whole reversibility story. A host that clears + * This is the whole reversibility story, and it is the only way out. A host that clears * [com.embabel.dice.common.DiceMetadataKeys.QUARANTINE_REASON] by hand leaves the proposition - * `STALE`, so it stays out of ordinary retrieval with nothing on it saying why, and the next - * sweep treats it as a fresh candidate and quarantines it again. + * `QUARANTINED` with nothing on it saying why, still held and now unexplained. * * The prior status comes from [DriftQuarantineKeys.PREVIOUS_STATUS], which the policy wrote at * quarantine time. A proposition carrying no readable value there is restored to - * [com.embabel.dice.proposition.PropositionStatus.ACTIVE], which is the only sensible reading of - * "let it back into use" when the record of where it came from is gone. + * [PropositionStatus.ACTIVE], which is the only sensible reading of "let it back into use" when + * the record of where it came from is gone. So is one whose recorded value reads `QUARANTINED`, + * since restoring that would leave the release doing nothing. * - * Both metadata keys are cleared, so releasing twice is safe: the second call finds nothing - * quarantined and answers `null`. + * The status move is announced as a [com.embabel.dice.common.PropositionStatusChanged], the same + * way [applyQuarantine] announces the move in. + * + * Which propositions are held is decided by status alone, so releasing twice is safe: the second + * call finds a proposition that isn't quarantined and answers `null`. * * @param propositionId The proposition to release. * @return The released proposition, or `null` when no proposition has that id, or when the one - * that does was never quarantined. + * that does is not quarantined. */ fun releaseFromQuarantine(propositionId: String): Proposition? diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt b/dice/src/main/kotlin/com/embabel/dice/spi/MentionTypeDriftQuarantinePolicy.kt similarity index 96% rename from dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt rename to dice/src/main/kotlin/com/embabel/dice/spi/MentionTypeDriftQuarantinePolicy.kt index f190e15d..36b09ea4 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/MentionTypeDriftQuarantinePolicy.kt +++ b/dice/src/main/kotlin/com/embabel/dice/spi/MentionTypeDriftQuarantinePolicy.kt @@ -13,18 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.embabel.dice.metamodel.support +package com.embabel.dice.spi import com.embabel.agent.core.Cardinality import com.embabel.dice.common.DiceMetadataKeys import com.embabel.dice.metamodel.DeclaredSchema -import com.embabel.dice.metamodel.DriftQuarantineKeys -import com.embabel.dice.metamodel.DriftQuarantinePolicy import com.embabel.dice.metamodel.MetamodelChange import com.embabel.dice.metamodel.MetamodelDiff import com.embabel.dice.metamodel.PropertySignature -import com.embabel.dice.metamodel.QuarantineDecision -import com.embabel.dice.metamodel.QuarantineResult import com.embabel.dice.proposition.Proposition import com.embabel.dice.proposition.PropositionStatus import org.slf4j.LoggerFactory @@ -105,14 +101,14 @@ import org.slf4j.LoggerFactory * the permissive direction leaves unreadable data looking healthy. A changed reference target is * always lossy: it names a different entity type, which is not a promotion of anything. * - * Quarantining moves the proposition to [PropositionStatus.STALE] and annotates it under - * [DiceMetadataKeys.QUARANTINE_REASON]. Both produce an immutable copy; the original is never - * mutated, and persisting the copies is the caller's job. + * Quarantining moves the proposition to [PropositionStatus.QUARANTINED], annotates it under + * [DiceMetadataKeys.QUARANTINE_REASON], and records the status it came from. All of that produces an + * immutable copy; the original is never mutated, and persisting the copies is the caller's job. * * A proposition an earlier sweep already quarantined comes back as * [QuarantineDecision.AlreadyQuarantined], untouched, with its original reason preserved and outside * the conforming bucket. That holds for any diff, an empty one included, because being already - * quarantined is a fact about the proposition. + * quarantined is a fact about the proposition — its status says so. * * ## Pinned propositions * @@ -248,11 +244,11 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { } // Where the proposition came from, written onto the copy so a release can put it back - // exactly there. `STALE` is a destination several roads lead to -- ordinary decay - // reaches it too -- so a release with nothing recorded here could only guess. + // exactly there. A quarantined proposition can have arrived from any status, so a + // release with nothing recorded here could only guess. val previousStatus = proposition.status val flagged = proposition - .withStatus(PropositionStatus.STALE) + .withStatus(PropositionStatus.QUARANTINED) .withMetadataValue(DiceMetadataKeys.QUARANTINE_REASON, reason) .withMetadataValue(DriftQuarantineKeys.PREVIOUS_STATUS, previousStatus.name) @@ -356,13 +352,17 @@ class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { ) /** - * Whether a proposition is one an earlier sweep already handled: `STALE` *and* carrying a - * quarantine reason. Both halves matter, because a proposition made stale by ordinary decay - * carries no reason and is still a live candidate here. + * Whether a proposition is one an earlier sweep already handled: its status is + * [PropositionStatus.QUARANTINED]. + * + * The status is the whole answer. Quarantine has a status of its own, so nothing else in DICE + * can put a proposition there and nothing else can take it out — a proposition made stale by + * ordinary decay is `STALE` and still a live candidate here, and one whose quarantine reason + * was edited away by hand is still held, because the hold is the status. Release is what lets + * one back through evaluation. */ private fun isAlreadyQuarantined(proposition: Proposition): Boolean = - proposition.status == PropositionStatus.STALE && - proposition.metadata.containsKey(DiceMetadataKeys.QUARANTINE_REASON) + proposition.status == PropositionStatus.QUARANTINED /** * Every name an entity type has gone by, mapped to what that type is called now, under every diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/PropositionStoreDriftSweep.kt b/dice/src/main/kotlin/com/embabel/dice/spi/PropositionStoreDriftSweep.kt similarity index 86% rename from dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/PropositionStoreDriftSweep.kt rename to dice/src/main/kotlin/com/embabel/dice/spi/PropositionStoreDriftSweep.kt index 80de9a2f..3a121585 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/support/PropositionStoreDriftSweep.kt +++ b/dice/src/main/kotlin/com/embabel/dice/spi/PropositionStoreDriftSweep.kt @@ -13,15 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.embabel.dice.metamodel.support +package com.embabel.dice.spi import com.embabel.agent.core.ContextId import com.embabel.dice.common.DiceEventListener import com.embabel.dice.common.DiceMetadataKeys import com.embabel.dice.common.PropositionStatusChanged -import com.embabel.dice.metamodel.DriftQuarantineKeys -import com.embabel.dice.metamodel.DriftSweepCapable -import com.embabel.dice.metamodel.QuarantineDecision import com.embabel.dice.proposition.Proposition import com.embabel.dice.proposition.PropositionStatus import com.embabel.dice.proposition.PropositionStore @@ -87,10 +84,6 @@ class PropositionStoreDriftSweep @JvmOverloads constructor( override fun applyQuarantine(decision: QuarantineDecision.Quarantined): Proposition { val saved = propositions.save(decision.proposition) logger.debug("Quarantined proposition (id={}): {}", saved.id, decision.reason) - // A proposition can arrive already STALE from ordinary decay (no quarantine reason yet, so - // the policy still treats it as a fresh candidate) and get quarantined without its status - // actually moving. Announcing a transition then would be a lie the listener has no way to - // catch, so this only fires when something really changed. announce(saved, decision.previousStatus, saved.status, decision.reason) return saved } @@ -99,14 +92,18 @@ class PropositionStoreDriftSweep @JvmOverloads constructor( * Restores the status recorded under [DriftQuarantineKeys.PREVIOUS_STATUS] and drops both * quarantine keys in one save. * + * Whether a proposition is held is read off its status, so a proposition that isn't + * [PropositionStatus.QUARANTINED] has nothing to release and answers `null` — including one + * whose quarantine reason was cleared by hand, which is still held. + * * A proposition with no readable previous status — quarantined by an older policy, or with the * key edited away — goes back to [PropositionStatus.ACTIVE]. Releasing says "let this back into * use", and `ACTIVE` is what that means when the record of where it came from is gone. */ override fun releaseFromQuarantine(propositionId: String): Proposition? { val quarantined = propositions.findById(propositionId) ?: return null - if (!quarantined.metadata.containsKey(DiceMetadataKeys.QUARANTINE_REASON)) { - logger.debug("Proposition (id={}) carries no quarantine reason; nothing to release", propositionId) + if (quarantined.status != PropositionStatus.QUARANTINED) { + logger.debug("Proposition (id={}) is not quarantined; nothing to release", propositionId) return null } @@ -127,12 +124,21 @@ class PropositionStoreDriftSweep @JvmOverloads constructor( /** * The status this proposition carried before it was quarantined, or [PropositionStatus.ACTIVE] - * when nothing readable was recorded. + * when nothing usable was recorded. + * + * A recorded `QUARANTINED` counts as unusable: restoring it would leave the release having + * cleared the reason and moved nothing, so the proposition would stay held with no explanation + * on it. A policy never writes that value, so this only fires on a hand-edited record. */ private fun previousStatusOf(proposition: Proposition): PropositionStatus { val recorded = proposition.metadata[DriftQuarantineKeys.PREVIOUS_STATUS] as? String ?: return PropositionStatus.ACTIVE - return runCatching { PropositionStatus.valueOf(recorded) }.getOrDefault(PropositionStatus.ACTIVE) + val parsed = runCatching { PropositionStatus.valueOf(recorded) }.getOrNull() + return if (parsed == null || parsed == PropositionStatus.QUARANTINED) { + PropositionStatus.ACTIVE + } else { + parsed + } } /** Tell the listener, and only when the status genuinely moved. */ diff --git a/dice/src/main/kotlin/com/embabel/dice/spi/StatusTransitionPolicy.kt b/dice/src/main/kotlin/com/embabel/dice/spi/StatusTransitionPolicy.kt index d567a948..fa34927b 100644 --- a/dice/src/main/kotlin/com/embabel/dice/spi/StatusTransitionPolicy.kt +++ b/dice/src/main/kotlin/com/embabel/dice/spi/StatusTransitionPolicy.kt @@ -64,6 +64,13 @@ fun interface StatusTransitionPolicy { * oscillation around a single cut-off. Pinned propositions are sweep-exempt and always * return `null`. * + * So are quarantined ones. [PropositionStatus.QUARANTINED] means schema governance is holding a + * proposition until a person looks at it, and utility says nothing about whether the schema change + * that stranded it has been dealt with. A confident, recently-reinforced proposition can be + * quarantined, so a decay sweep that judged it on utility alone would hand it straight back to + * ACTIVE, the next drift sweep would quarantine it again, and the two sweeps would take turns + * forever. Release is the one way out. + * * Utility composite: * ``` * utility = effectiveConfidence(kMultiplier) @@ -98,6 +105,7 @@ class DecayStatusPolicy( override fun evaluate(proposition: Proposition): PropositionStatus? { if (proposition.pinned) return null + if (proposition.status == PropositionStatus.QUARANTINED) return null val utility = proposition.effectiveConfidence(kMultiplier) * (1 + importanceWeight * proposition.importance) * (1 + reinforceWeight * kotlin.math.ln(1.0 + proposition.reinforceCount.toDouble())) diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt b/dice/src/test/kotlin/com/embabel/dice/spi/DriftQuarantinePolicyTest.kt similarity index 97% rename from dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt rename to dice/src/test/kotlin/com/embabel/dice/spi/DriftQuarantinePolicyTest.kt index c43031ca..4f8116bd 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftQuarantinePolicyTest.kt +++ b/dice/src/test/kotlin/com/embabel/dice/spi/DriftQuarantinePolicyTest.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.embabel.dice.metamodel +package com.embabel.dice.spi import com.embabel.agent.core.Cardinality import com.embabel.agent.core.DataDictionary @@ -21,7 +21,13 @@ import com.embabel.agent.core.DynamicType import com.embabel.agent.core.JvmType import com.embabel.agent.core.ValuePropertyDefinition import com.embabel.dice.common.DiceMetadataKeys -import com.embabel.dice.metamodel.support.MentionTypeDriftQuarantinePolicy +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.PropertySignature +import com.embabel.dice.metamodel.SchemaAliases import com.embabel.dice.metamodel.support.StructuralMetamodelDiffer import com.embabel.dice.proposition.EntityMention import com.embabel.dice.proposition.MentionRole @@ -141,7 +147,7 @@ class DriftQuarantinePolicyTest { assertEquals(1, result.conforming.size) assertEquals(1, result.quarantined.size) val decision = result.quarantined.single() - assertEquals(PropositionStatus.STALE, decision.proposition.status) + assertEquals(PropositionStatus.QUARANTINED, decision.proposition.status) assertTrue(decision.affectedMentionTypes.contains("LegacyType")) assertNotNull(decision.proposition.metadata[DiceMetadataKeys.QUARANTINE_REASON]) } @@ -185,7 +191,7 @@ class DriftQuarantinePolicyTest { val result = policy.evaluate(diff, listOf(original)) - assertEquals(PropositionStatus.STALE, result.quarantined.single().proposition.status) + assertEquals(PropositionStatus.QUARANTINED, result.quarantined.single().proposition.status) assertEquals(PropositionStatus.ACTIVE, original.status, "the caller's copy must be untouched") assertNull(original.metadata[DiceMetadataKeys.QUARANTINE_REASON]) } @@ -227,7 +233,7 @@ class DriftQuarantinePolicyTest { assertEquals(1, result.quarantined.size) val decision = result.quarantined.single() - assertEquals(PropositionStatus.STALE, decision.proposition.status) + assertEquals(PropositionStatus.QUARANTINED, decision.proposition.status) assertTrue(decision.affectedMentionTypes.contains("Person")) assertTrue(reasonOf(decision).contains("Agent"), "the reason should name the lost label") } @@ -377,7 +383,7 @@ class DriftQuarantinePolicyTest { decision.proposition.metadata[DiceMetadataKeys.QUARANTINE_REASON], ) assertEquals(stale.metadata[DiceMetadataKeys.QUARANTINE_REASON], decision.originalReason) - assertEquals(PropositionStatus.STALE, decision.proposition.status) + assertEquals(PropositionStatus.QUARANTINED, decision.proposition.status) assertTrue(second.allPropositions.contains(decision.proposition)) } @@ -402,7 +408,7 @@ class DriftQuarantinePolicyTest { assertEquals(1, result.total) val decision = result.alreadyQuarantined.single() assertEquals(stale.id, decision.proposition.id) - assertEquals(PropositionStatus.STALE, decision.proposition.status) + assertEquals(PropositionStatus.QUARANTINED, decision.proposition.status) assertEquals(stale.metadata[DiceMetadataKeys.QUARANTINE_REASON], decision.originalReason) } @@ -445,8 +451,8 @@ class DriftQuarantinePolicyTest { @Test fun `a proposition made stale by something other than quarantine is still evaluated`() { - // STALE alone isn't enough to skip one, because decay also makes propositions stale and - // those carry no quarantine reason. Skipping on status alone would let drifted data + // Quarantine has a status of its own, so STALE means ordinary decay reached this one + // and it is still a live candidate here. Treating STALE as held would let drifted data // through. val diff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) val staleByDecay = proposition("aged out", "RemovedType", status = PropositionStatus.STALE) @@ -1070,7 +1076,7 @@ class DriftQuarantinePolicyTest { * DICE promises pinned propositions cross-cutting immunity from reclamation (see * `PropositionStore.pin`): the decay collector, the sweep policy, and contradiction resolution * all leave them alone. Drift quarantine is another reclamation path and must honor the same - * promise: a pinned proposition stays untouched, never flipped to STALE. + * promise: a pinned proposition stays untouched, and is never flipped to QUARANTINED. */ @Nested inner class PinnedImmunity { @@ -1082,7 +1088,7 @@ class DriftQuarantinePolicyTest { val result = policy.evaluate(diff, listOf(pinned)) - assertEquals(0, result.quarantined.size, "a pinned match must never be flipped to STALE") + assertEquals(0, result.quarantined.size, "a pinned match must never be flipped to QUARANTINED") assertEquals(1, result.protected.size) val decision = result.protected.single() assertEquals(PropositionStatus.ACTIVE, decision.proposition.status, "pin means untouched") @@ -1120,14 +1126,14 @@ class DriftQuarantinePolicyTest { assertEquals(1, result.protected.size) assertEquals(1, result.quarantined.size) - assertEquals(PropositionStatus.STALE, result.quarantined.single().proposition.status) + assertEquals(PropositionStatus.QUARANTINED, result.quarantined.single().proposition.status) } @Test fun `a pinned proposition an earlier sweep already quarantined stays already-quarantined`() { - // Pin immunity only changes what a *fresh* match does. A proposition that is already - // STALE with a quarantine reason — however it got pinned since — is idempotency's - // concern, not this one's, and must not silently become Protected. + // Pin immunity only changes what a *fresh* match does. A proposition already + // QUARANTINED — however it got pinned since — is idempotency's concern, and must never + // silently become Protected. val diff = differ.diff(schemaWith("Person", "RemovedType"), schemaWith("Person")) val stale = policy .evaluate(diff, listOf(proposition("entity with removed type", "RemovedType"))) diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftSweepTest.kt b/dice/src/test/kotlin/com/embabel/dice/spi/DriftSweepTest.kt similarity index 87% rename from dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftSweepTest.kt rename to dice/src/test/kotlin/com/embabel/dice/spi/DriftSweepTest.kt index 9c9c8396..fb01f97b 100644 --- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DriftSweepTest.kt +++ b/dice/src/test/kotlin/com/embabel/dice/spi/DriftSweepTest.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.embabel.dice.metamodel +package com.embabel.dice.spi import com.embabel.agent.core.Cardinality import com.embabel.agent.core.ContextId @@ -24,9 +24,19 @@ import com.embabel.dice.common.DiceEventListener import com.embabel.dice.common.DiceMetadataKeys import com.embabel.dice.common.PropositionStatusChanged import com.embabel.dice.common.SafeDiceEventListener +import com.embabel.dice.metamodel.DeclaredSchema +import com.embabel.dice.metamodel.DriftCheckResult +import com.embabel.dice.metamodel.DriftReport +import com.embabel.dice.metamodel.DriftReportStore +import com.embabel.dice.metamodel.GovernedTypeSelector +import com.embabel.dice.metamodel.InMemoryMetamodelVersionStore +import com.embabel.dice.metamodel.MetamodelDiff +import com.embabel.dice.metamodel.MetamodelVersion +import com.embabel.dice.metamodel.MetamodelVersionStore +import com.embabel.dice.metamodel.ObservedSchema +import com.embabel.dice.metamodel.ObservedSchemaSource +import com.embabel.dice.metamodel.PropertySignature import com.embabel.dice.metamodel.support.DefaultDriftCheckRunner -import com.embabel.dice.metamodel.support.MentionTypeDriftQuarantinePolicy -import com.embabel.dice.metamodel.support.PropositionStoreDriftSweep import com.embabel.dice.metamodel.support.StructuralMetamodelDiffer import com.embabel.dice.projection.lineage.InMemoryProjectionRecordStore import com.embabel.dice.projection.lineage.ProjectionLifecycle @@ -138,7 +148,7 @@ class DriftSweepTest { val result = sweep.sweep(personLostAge(), policy, contextId) assertEquals(1, result.quarantined.size, "only context A holds a candidate") - assertEquals(PropositionStatus.STALE, propositions.findById(inScope.id)!!.status) + assertEquals(PropositionStatus.QUARANTINED, propositions.findById(inScope.id)!!.status) val untouched = propositions.findById(outOfScope.id)!! assertEquals( @@ -173,8 +183,8 @@ class DriftSweepTest { sweep.sweep(diff, policy, contextId) sweep.sweep(diff, policy, otherContextId) - assertEquals(PropositionStatus.STALE, propositions.findById(inA.id)!!.status) - assertEquals(PropositionStatus.STALE, propositions.findById(inB.id)!!.status) + assertEquals(PropositionStatus.QUARANTINED, propositions.findById(inA.id)!!.status) + assertEquals(PropositionStatus.QUARANTINED, propositions.findById(inB.id)!!.status) } } @@ -204,7 +214,7 @@ class DriftSweepTest { assertEquals(5, result.quarantined.size, "paging must reach the whole context: $result") assertTrue( - propositions.findAll().all { it.status == PropositionStatus.STALE }, + propositions.findAll().all { it.status == PropositionStatus.QUARANTINED }, "every candidate was quarantined", ) } @@ -272,7 +282,7 @@ class DriftSweepTest { assertEquals(1, result.quarantined.size) val quarantined = propositions.findById(affected.id)!! - assertEquals(PropositionStatus.STALE, quarantined.status) + assertEquals(PropositionStatus.QUARANTINED, quarantined.status) assertNotNull(quarantined.metadata[DiceMetadataKeys.QUARANTINE_REASON]) assertEquals(PropositionStatus.ACTIVE, propositions.findById(safe.id)!!.status) } @@ -306,8 +316,9 @@ class DriftSweepTest { @Test fun `a quarantine's status change reaches projection lineage through the listener`() { - // ProjectionLineageStaleCascade is how a proposition going STALE marks its projection - // records stale in turn; it reacts to PropositionStatusChanged. + // ProjectionLineageStaleCascade is how a proposition leaving ordinary use marks its + // projection records stale in turn; it reacts to PropositionStatusChanged, and + // QUARANTINED counts as leaving ordinary use. val affected = propositions.save(proposition("Alice is 40", "Person")) val recordStore = InMemoryProjectionRecordStore() recordStore.record( @@ -323,7 +334,7 @@ class DriftSweepTest { sweep.sweep(personLostAge(), policy, contextId) - assertEquals(PropositionStatus.STALE, propositions.findById(affected.id)!!.status) + assertEquals(PropositionStatus.QUARANTINED, propositions.findById(affected.id)!!.status) assertEquals( ProjectionLifecycle.STALE, recordStore.findByProposition(affected.id).single().lifecycle, @@ -341,7 +352,7 @@ class DriftSweepTest { val event = recording.events.filterIsInstance().single() assertEquals(PropositionStatus.ACTIVE, event.previousStatus) - assertEquals(PropositionStatus.STALE, event.newStatus) + assertEquals(PropositionStatus.QUARANTINED, event.newStatus) assertTrue(event.reason!!.contains("age"), event.reason) } @@ -357,10 +368,11 @@ class DriftSweepTest { } @Test - fun `a proposition already STALE from decay is quarantined without announcing a transition`() { - // The idempotency check only skips one that is already quarantined (STALE with a - // reason); one STALE from ordinary decay carries no reason and is a fresh candidate. It - // gets its reason written while its status stays put, so no event should claim a move. + fun `a proposition already STALE from decay is quarantined out of that status`() { + // The idempotency check skips one whose status is already QUARANTINED. Ordinary decay + // reaches STALE, which is a different place, so a decayed proposition is a fresh + // candidate here: it moves to QUARANTINED, the move is announced, and STALE is what a + // release will put it back to. val decayed = propositions.save( proposition("Alice is 40", "Person", status = PropositionStatus.STALE), ) @@ -370,10 +382,22 @@ class DriftSweepTest { val result = sweep.sweep(personLostAge(), policy, contextId) assertEquals(1, result.quarantined.size, "sanity: it was quarantined") - assertNotNull(propositions.findById(decayed.id)!!.metadata[DiceMetadataKeys.QUARANTINE_REASON]) - assertTrue( - recording.events.isEmpty(), - "previousStatus and newStatus are both STALE, so nothing actually transitioned", + assertEquals(PropositionStatus.STALE, result.quarantined.single().previousStatus) + val held = propositions.findById(decayed.id)!! + assertEquals(PropositionStatus.QUARANTINED, held.status) + assertNotNull(held.metadata[DiceMetadataKeys.QUARANTINE_REASON]) + assertEquals( + PropositionStatus.STALE.name, + held.metadata[DriftQuarantineKeys.PREVIOUS_STATUS], + ) + val event = recording.events.filterIsInstance().single() + assertEquals(PropositionStatus.STALE, event.previousStatus) + assertEquals(PropositionStatus.QUARANTINED, event.newStatus) + + assertEquals( + PropositionStatus.STALE, + sweep.releaseFromQuarantine(decayed.id)!!.status, + "release puts it back where decay had left it", ) } @@ -409,7 +433,7 @@ class DriftSweepTest { val original = propositions.save(proposition("Alice is 40", "Person")) val sweep = PropositionStoreDriftSweep(propositions) sweep.sweep(personLostAge(), policy, contextId) - assertEquals(PropositionStatus.STALE, propositions.findById(original.id)!!.status, "sanity") + assertEquals(PropositionStatus.QUARANTINED, propositions.findById(original.id)!!.status, "sanity") val released = sweep.releaseFromQuarantine(original.id) @@ -417,7 +441,7 @@ class DriftSweepTest { assertEquals( PropositionStatus.ACTIVE, released!!.status, - "clearing the reason alone would leave it STALE and out of ordinary retrieval", + "clearing the reason alone would leave it held with nothing on it saying why", ) assertNull(released.metadata[DiceMetadataKeys.QUARANTINE_REASON]) assertNull(released.metadata[DriftQuarantineKeys.PREVIOUS_STATUS]) @@ -462,7 +486,7 @@ class DriftSweepTest { sweep.releaseFromQuarantine(original.id) val event = recording.events.filterIsInstance().single() - assertEquals(PropositionStatus.STALE, event.previousStatus) + assertEquals(PropositionStatus.QUARANTINED, event.previousStatus) assertEquals(PropositionStatus.ACTIVE, event.newStatus) } @@ -488,9 +512,9 @@ class DriftSweepTest { @Test fun `a quarantine with no recorded previous status is released to ACTIVE`() { - // What an older quarantine looks like, or one whose metadata a person edited. + // What a quarantine whose previousStatus metadata a person edited away looks like. val legacy = propositions.save( - proposition("Alice is 40", "Person", status = PropositionStatus.STALE) + proposition("Alice is 40", "Person", status = PropositionStatus.QUARANTINED) .withMetadataValue(DiceMetadataKeys.QUARANTINE_REASON, "quarantined by an earlier build"), ) val sweep = PropositionStoreDriftSweep(propositions) @@ -606,7 +630,7 @@ class DriftSweepTest { val result = sweep.sweep(diffOf(before, after), policy, contextId) assertEquals(1, result.quarantined.size, "the simple mention must match the qualified declaration") - assertEquals(PropositionStatus.STALE, propositions.findById(mentioning.id)!!.status) + assertEquals(PropositionStatus.QUARANTINED, propositions.findById(mentioning.id)!!.status) } } @@ -734,6 +758,41 @@ class DriftSweepTest { events += event } } + + /** + * Somewhere for a drift check to write its report. These tests read what the sweep did, so a + * plain list is enough; `dice-metamodel`'s own suite is where the store contract is exercised. + */ + private class InMemoryDriftReportStore : DriftReportStore { + + private val reports = mutableListOf() + + override fun saveDriftReport(report: DriftReport) { + reports += report + } + + override fun driftReports(schemaName: String, limit: Int, since: Instant?): List = + page(limit, since) { it.schemaName == schemaName } + + override fun globalDriftReports(schemaName: String, limit: Int, since: Instant?): List = + page(limit, since) { it.schemaName == schemaName && it.contextId == null } + + override fun driftReportsInContext( + schemaName: String, + contextId: ContextId, + limit: Int, + since: Instant?, + ): List = page(limit, since) { it.schemaName == schemaName && it.contextId == contextId } + + private fun page(limit: Int, since: Instant?, scope: (DriftReport) -> Boolean): List { + require(limit > 0) { "limit must be positive, but was $limit" } + return reports + .filter(scope) + .filter { since == null || !it.capturedAt.isBefore(since) } + .sortedByDescending { it.capturedAt } + .take(limit) + } + } } /** diff --git a/dice/src/test/kotlin/com/embabel/dice/spi/QuarantineDecayInteractionTest.kt b/dice/src/test/kotlin/com/embabel/dice/spi/QuarantineDecayInteractionTest.kt new file mode 100644 index 00000000..2e4dd58c --- /dev/null +++ b/dice/src/test/kotlin/com/embabel/dice/spi/QuarantineDecayInteractionTest.kt @@ -0,0 +1,225 @@ +/* + * 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.spi + +import com.embabel.agent.core.Cardinality +import com.embabel.agent.core.ContextId +import com.embabel.dice.common.DiceMetadataKeys +import com.embabel.dice.metamodel.MetamodelDiff +import com.embabel.dice.metamodel.MetamodelVersion +import com.embabel.dice.metamodel.PropertySignature +import com.embabel.dice.metamodel.support.StructuralMetamodelDiffer +import com.embabel.dice.proposition.DecaySweepConfig +import com.embabel.dice.proposition.DecaySweepResult +import com.embabel.dice.proposition.EntityMention +import com.embabel.dice.proposition.MentionRole +import com.embabel.dice.proposition.Proposition +import com.embabel.dice.proposition.PropositionStatus +import com.embabel.dice.proposition.store.InMemoryDecayManager +import com.embabel.dice.proposition.store.InMemoryPropositionRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Two sweeps run over the same store on their own schedules: the decay sweep, which decides what has + * lost its usefulness, and the drift sweep, which holds back what a schema change stranded. This + * pins down what happens where they meet. + * + * The failure this exists to prevent: quarantine used to be a `STALE` proposition with a metadata + * note, and `STALE` is exactly where the decay policy looks for something to revive. A confident + * proposition quarantined for schema drift would be handed back to `ACTIVE` by the next decay sweep, + * quarantined again by the next drift sweep, and the two would take turns indefinitely. Recovery + * from quarantine was an accident of that overlap; here it is an operation with a name. + * + * The decay sweeps below are configured with every status as a target, which is wider than the + * shipped default of `{ACTIVE}`. A host that widens its sweep is the case where the overlap bites, + * so that is the case worth holding. + */ +class QuarantineDecayInteractionTest { + + private val contextId = ContextId("context-a") + private val schemaName = "test-schema" + + private lateinit var propositions: InMemoryPropositionRepository + private lateinit var decay: InMemoryDecayManager + private lateinit var sweep: PropositionStoreDriftSweep + private lateinit var policy: MentionTypeDriftQuarantinePolicy + + /** A decay sweep that looks at everything, so nothing is skipped by the status filter alone. */ + private val sweepEverything = DecaySweepConfig( + policy = DecayStatusPolicy(), + targetStatuses = PropositionStatus.entries.toSet(), + ) + + @BeforeEach + fun setUp() { + propositions = InMemoryPropositionRepository() + decay = InMemoryDecayManager(propositions) + sweep = PropositionStoreDriftSweep(propositions) + policy = MentionTypeDriftQuarantinePolicy() + } + + /** + * A proposition the decay policy would happily call healthy: `decay = 0.0` fixes its effective + * confidence at the raw value, and 0.9 sits well above the 0.2 recovery ceiling. + */ + private fun confidentPerson(): Proposition = propositions.save( + Proposition( + contextId = contextId, + text = "Alice is 40", + mentions = listOf(EntityMention(span = "alice", type = "Person", role = MentionRole.SUBJECT)), + confidence = 0.9, + decay = 0.0, + ), + ) + + private fun versionOf( + types: List, + properties: Map> = emptyMap(), + ): MetamodelVersion = MetamodelVersion( + schemaName = schemaName, + entityTypeNames = types, + entityTypeLabels = types.associateWith { setOf(it) }, + entityTypeProperties = types.associateWith { properties[it].orEmpty() }, + relationshipNames = emptyList(), + ) + + /** `Person` loses its `age` property, which is enough to strand a proposition mentioning it. */ + private fun personLostAge(): MetamodelDiff = StructuralMetamodelDiffer().diff( + versionOf( + listOf("Person"), + mapOf("Person" to setOf(PropertySignature("age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE))), + ), + versionOf(listOf("Person")), + ) + + @Test + fun `a decay sweep leaves a quarantined proposition exactly where the drift sweep put it`() { + val original = confidentPerson() + assertEquals( + PropositionStatus.ACTIVE, + DecayStatusPolicy().evaluate(original.withStatus(PropositionStatus.STALE)), + "sanity: this proposition is confident enough that a decay sweep revives it out of STALE", + ) + + sweep.sweep(personLostAge(), policy, contextId) + val quarantined = propositions.findById(original.id)!! + assertEquals(PropositionStatus.QUARANTINED, quarantined.status) + val reason = quarantined.metadata[DiceMetadataKeys.QUARANTINE_REASON] + assertNotNull(reason, "sanity: the quarantine recorded why") + + val result = decay.sweepAll(sweepEverything) + + val held = propositions.findById(original.id)!! + assertEquals( + PropositionStatus.QUARANTINED, + held.status, + "a decay sweep must have no way to lift a schema-governance hold", + ) + assertEquals(reason, held.metadata[DiceMetadataKeys.QUARANTINE_REASON], "and the reason survives it") + assertEquals( + PropositionStatus.ACTIVE.name, + held.metadata[DriftQuarantineKeys.PREVIOUS_STATUS], + "as does the record of where to put it back", + ) + + val swept = assertSwept(result) + assertTrue(swept.revived.isEmpty(), "nothing was revived: ${swept.revived}") + assertTrue(swept.transitioned.isEmpty(), "and nothing was transitioned: ${swept.transitioned}") + } + + @Test + fun `repeated decay sweeps never start the alternation`() { + // The original symptom was a loop, so one pass is a weak check: the two sweeps took turns, + // and each turn looked locally correct. Run both on their schedules and watch the record. + val original = confidentPerson() + val diff = personLostAge() + + val first = sweep.sweep(diff, policy, contextId) + assertEquals(1, first.quarantined.size, "sanity: the schema change stranded it") + + repeat(3) { + decay.sweepAll(sweepEverything) + val swept = sweep.sweep(diff, policy, contextId) + assertEquals(0, swept.quarantined.size, "a held proposition is never re-quarantined") + assertEquals(1, swept.alreadyQuarantined.size, "it is reported as already held, every time") + assertEquals(PropositionStatus.QUARANTINED, propositions.findById(original.id)!!.status) + } + } + + @Test + fun `after release the proposition is back under ordinary decay`() { + val original = confidentPerson() + sweep.sweep(personLostAge(), policy, contextId) + + val released = sweep.releaseFromQuarantine(original.id)!! + + assertEquals(PropositionStatus.ACTIVE, released.status, "release restores the status it came from") + assertNull( + released.metadata[DiceMetadataKeys.QUARANTINE_REASON], + "release clears the reason, so nothing is left claiming the proposition is held", + ) + assertNull(released.metadata[DriftQuarantineKeys.PREVIOUS_STATUS]) + assertEquals(released, propositions.findById(original.id), "and the release was persisted") + + // Ordinary decay applies again: drop the confidence under the staleness floor and the sweep + // that would not touch it a moment ago now moves it, which is the whole point of releasing. + propositions.save(released.copy(confidence = 0.05)) + + val swept = assertSwept(decay.sweepAll(sweepEverything)) + + assertEquals(1, swept.transitioned.size, "a released proposition is an ordinary decay candidate") + assertEquals(PropositionStatus.STALE, propositions.findById(original.id)!!.status) + } + + @Test + fun `a decay sweep cannot revive a proposition quarantined out of STALE`() { + // The nastiest shape of the original bug: decay had already made this one STALE, so the + // recorded previous status is STALE too. Reviving it would look locally reasonable and would + // still be lifting a hold nobody released. + val stale = propositions.save( + Proposition( + contextId = contextId, + text = "Alice is 40", + mentions = listOf(EntityMention(span = "alice", type = "Person", role = MentionRole.SUBJECT)), + confidence = 0.9, + decay = 0.0, + status = PropositionStatus.STALE, + ), + ) + + sweep.sweep(personLostAge(), policy, contextId) + decay.sweepAll(sweepEverything) + + val held = propositions.findById(stale.id)!! + assertEquals(PropositionStatus.QUARANTINED, held.status) + assertEquals(PropositionStatus.STALE.name, held.metadata[DriftQuarantineKeys.PREVIOUS_STATUS]) + assertEquals( + PropositionStatus.STALE, + sweep.releaseFromQuarantine(stale.id)!!.status, + "and release puts it back in the status decay had left it in", + ) + } + + private fun assertSwept(result: DecaySweepResult): DecaySweepResult.Swept { + assertTrue(result is DecaySweepResult.Swept, "expected a completed sweep, got $result") + return result as DecaySweepResult.Swept + } +} diff --git a/docs/design/metamodel-drift.md b/docs/design/metamodel-drift.md index 25fd8696..ace62331 100644 --- a/docs/design/metamodel-drift.md +++ b/docs/design/metamodel-drift.md @@ -72,9 +72,9 @@ sequenceDiagram loop one bounded page at a time, until a page comes back short Sweep->>Props: quarantineCandidates(contextId, mentionTypes, limit, afterId) Sweep->>Policy: evaluate(diff, page) - Policy-->>Sweep: STALE copies + reasons, pinned matches reported protected + Policy-->>Sweep: QUARANTINED copies + reasons, pinned matches reported protected Sweep->>Props: applyQuarantine(each flagged copy) - Sweep->>Listener: onEvent(PropositionStatusChanged), skipped if status didn't move + Sweep->>Listener: onEvent(PropositionStatusChanged) end Sweep-->>Host: QuarantineResult Host->>Versions: markSwept(stamp) — once every context is done @@ -202,6 +202,17 @@ The declared comparison is part of the record, so a `DriftReportStore` backend p the drifted type sets. A report read back out of the store a year later resolves to the same `quarantineDiff` the live result did. +### Where this lives + +The quarantine policy and the sweep SPI — `DriftQuarantinePolicy`, `DriftSweepCapable`, +`MentionTypeDriftQuarantinePolicy`, `PropositionStoreDriftSweep` — sit in `dice` core, in +`com.embabel.dice.spi`, beside the other proposition lifecycle policies. They move a proposition +between lifecycle statuses, which is what that package is for, and putting them there is what keeps +the module dependency pointing one way: `dice` reads a `MetamodelDiff`, and `dice-metamodel` stays a +leaf over the agent `DataDictionary` with no view of the proposition model at all. A drift check +therefore has no type through which it could reach a proposition, which is the structural half of +"a check changes nothing". + ### The sweep SPI `DriftSweepCapable` is three store operations plus one `sweep` that composes them: @@ -211,8 +222,8 @@ the drifted type sets. A report read back out of the store a year later resolves proposition id so `afterId` is a usable cursor. All three are contract requirements, spelled out in the KDoc. Reading every proposition and filtering afterwards materialises every tenant in one heap, and costs the size of the store on a change that touches one type. -- `applyQuarantine(decision)` — persist one `STALE` copy the policy built, and announce the - transition when the status genuinely moved. +- `applyQuarantine(decision)` — persist one `QUARANTINED` copy the policy built, and announce the + transition. - `releaseFromQuarantine(propositionId)` — see [Release](#release) below. The mention types come from `DriftQuarantinePolicy.candidateMentionTypes(diff)`, so the store needs no @@ -227,20 +238,50 @@ is honest about the cost: it reads the one context and applies the mention-type and the page bound in the JVM. The context bound is real there — the read never leaves the context — and the rest is the part a backend should push down. +### `QUARANTINED` is a status, so the hold has an owner + +A quarantined proposition carries `PropositionStatus.QUARANTINED`. It reads like `STALE` to anything +filtering on `ACTIVE`, so it drops out of retrieval, projection and consolidation the same way. The +difference is who owns it. + +`STALE` is decay's status: decay puts propositions there and `DecayStatusPolicy` takes them back out +again once utility climbs past the recovery ceiling. A quarantine expressed as `STALE` plus a metadata +note would sit directly in that path — and confidence says nothing about whether the schema change has +been dealt with, so a confident proposition held for drift would be revived by the next decay sweep, +held again by the next drift sweep, and the two would take turns indefinitely. Recovery from +quarantine would be a side effect of that overlap, with no operation behind it. + +With a status of its own the hold is exact: + +- Reads that filter on `ACTIVE` exclude it structurally, with nothing new to remember. +- `DecayStatusPolicy` never sees a `STALE` to revive, and returns `null` for `QUARANTINED` outright, + so a host that widens `DecaySweepConfig.targetStatuses` to every status still can't lift a hold. +- Contradiction resolution and the abstraction pass read `ACTIVE` propositions, so neither can move one. +- `pruneStale` deletes `STALE` propositions and leaves quarantined ones alone. +- The idempotency check is the status, so editing the reason metadata by hand doesn't release anything. +- Release is an explicit transition with a name. + +`ProjectionLineageStaleCascade` treats `QUARANTINED` as terminal alongside `SUPERSEDED`, +`CONTRADICTED` and `STALE`: the proposition has left ordinary use, so anything projected from it is no +longer backed by a live belief. + ### Release Quarantine is reversible, and `releaseFromQuarantine` is what reverses it: it restores the status the proposition carried before quarantine and clears both quarantine keys in one write. -Clearing `dice.metamodel.quarantine.reason` by hand does half the job and leaves the proposition -`STALE`, so it stays out of ordinary retrieval with nothing on it saying why, and the next sweep -treats it as a fresh candidate and quarantines it again. The status to restore comes from -`dice.metamodel.quarantine.previousStatus`, which the policy writes onto the `STALE` copy at -quarantine time — `STALE` is a destination several roads lead to, ordinary decay included, so a -release with nothing recorded could only guess. A proposition carrying no readable value there goes -back to `ACTIVE`, which is what "let this back into use" means once the record is gone. +Release is the **only** way out. Which propositions are held is read off `PropositionStatus.QUARANTINED`, +so clearing `dice.metamodel.quarantine.reason` by hand changes nothing about the hold: the proposition +stays out of ordinary retrieval with nothing on it saying why. Every lifecycle policy in DICE leaves +`QUARANTINED` alone, so no decay sweep and no consolidation pass can lift it either. -Releasing something that was never quarantined answers `null` and changes nothing, so releasing twice +The status to restore comes from `dice.metamodel.quarantine.previousStatus`, which the policy writes +onto the quarantined copy at the moment it flags it — a proposition can be quarantined from any +status, ordinary decay's `STALE` included, so a release with nothing recorded could only guess. A +proposition carrying no usable value there goes back to `ACTIVE`, which is what "let this back into +use" means once the record is gone. + +Releasing a proposition that isn't quarantined answers `null` and changes nothing, so releasing twice is safe. #### The baseline only moves once a sweep finishes @@ -308,6 +349,13 @@ A rename is a fact the declaration states, so on its own it strands nothing. `EntityTypeRenamed` and `PropertyRenamed` are non-lossy per se, and `EntityTypeAliasesChanged` never quarantines at all. +**A declared rename or alias changes how the diff and the drift check read old data. Nothing rewrites +a stored mention type.** A proposition extracted under `Person` still says `Person` after the schema +renames the type to `Human`, and it says `Person` forever; what the declaration buys is that both +halves of a drift check know the two names belong together, so a later loss on `Human` reaches that +proposition and an observed `Person` in the graph is read as declared. There is no migration here and +no backfill to schedule. + That holds for a type rename **by construction**, because of what the differ does upstream. A type's own name is one of its labels, so `Person` becoming `Human` mechanically loses the label `Person`; the same swap propagates into every referrer's signature and every child's inherited label. The @@ -409,14 +457,14 @@ Swap in a different `DriftQuarantinePolicy` if your storage makes more promotion Three properties make this safe to run as routine maintenance: - **Non-destructive.** Nothing is deleted and nothing is mutated. An affected proposition comes back - as an immutable copy moved to `STALE`, annotated with a human-readable reason under + as an immutable copy moved to `QUARANTINED`, annotated with a human-readable reason under `dice.metamodel.quarantine.reason`. Leaving drifted propositions in normal retrieval would corrupt query results; deleting them would destroy something a person might want to rescue. - **Idempotent.** A proposition an earlier sweep quarantined comes back in its own `alreadyQuarantined` bucket, untouched and with its original reason intact, so it is neither - re-flagged nor counted in `conforming`. To force one back through evaluation, clear its reason - metadata first. Status alone is not enough to skip a proposition: ordinary decay also makes - propositions stale, and those carry no reason and are still candidates. The classification doesn't + re-flagged nor counted in `conforming`. To force one back through evaluation, release it. The + check is the status and nothing else: `QUARANTINED` means held, while a proposition ordinary decay + left `STALE` is a live candidate here like any other. The classification doesn't depend on the diff in front of it: being already quarantined is a fact about the proposition, so an empty or purely additive diff still sorts one into `alreadyQuarantined`. Skipping the check on an empty diff would report quarantined records as conforming on every run that finds no drift. @@ -428,7 +476,7 @@ Three properties make this safe to run as routine maintenance: quarantined before it was pinned is unaffected: idempotency is checked first, so it stays `alreadyQuarantined`. -The policy decides and doesn't write. The `STALE` copies it returns come back to the caller, and the +The policy decides and doesn't write. The `QUARANTINED` copies it returns come back to the caller, and the sweep persists them through `applyQuarantine`. A drift check never calls `evaluate` at all, so there is no policy decision for it to persist. @@ -440,16 +488,15 @@ backend out of drift work over capabilities it never uses. ### Announcing a quarantine Each proposition a sweep actually quarantines is announced to a `DiceEventListener` as a -`PropositionStatusChanged` (`previousStatus` the status it carried in, `newStatus` `STALE`, `reason` -the same text the metadata carries), right after it is saved. A release announces the transition back. -This is what lets something like `ProjectionLineageStaleCascade` hear that a proposition went stale -and mark its projection records stale in turn. +`PropositionStatusChanged` (`previousStatus` the status it carried in, `newStatus` `QUARANTINED`, +`reason` the same text the metadata carries), right after it is saved. A release announces the +transition back. This is what lets something like `ProjectionLineageStaleCascade` hear that a +proposition left ordinary use and mark its projection records stale in turn. -A proposition can arrive at the sweep already `STALE` from ordinary decay, with no quarantine reason -yet, and the policy correctly treats that as a fresh candidate — the idempotency rule only skips one -that's *already quarantined*, not one that's merely stale for some other reason. Quarantining it -writes the reason but doesn't move its status, so no event fires for it: the event promises a -transition happened, and here one didn't. +A proposition can arrive at the sweep already `STALE` from ordinary decay, and the policy treats that +as a fresh candidate — the idempotency rule skips one that is *already quarantined*, which is a +status of its own. Quarantining that proposition moves it from `STALE` to `QUARANTINED`, the event +says exactly that, and a later release puts it back to `STALE`. The sweep emits this itself. The injected `PropositionStore` is never asked to notice the transition and emit it on its own — the way `EventEmittingPropositionRepository` does when an @@ -470,8 +517,8 @@ record each violation with what was expected and where, don't block the write. column whose value doesn't fit the declared schema, the value is captured into a `_rescued_data` column rather than dropped, and the row still lands. The stance is that data an extraction already produced is evidence: a schema that no longer describes it sets that data aside for a person to look -at rather than deleting it. Quarantine is the same move on a proposition: `STALE`, annotated -with a reason, still in the store, still readable, and reversible through `releaseFromQuarantine`. +at, and deletes nothing. Quarantine is the same move on a proposition — `QUARANTINED`, annotated with +a reason, still in the store, still readable, and reversible through `releaseFromQuarantine`. **Enforcement and evolution are separate settings**, which is how Delta and the Snowflake-style lakehouses organize this. Enforcement asks whether an incoming write matches; evolution asks whether From 8f20d259abc7a207db4217b48e94eb091c08fca9 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:39:53 -0400 Subject: [PATCH 07/11] Align the drift and lifecycle docs with the shipped model The architecture doc inverted the module direction and still described quarantine as STALE plus a reason, the lifecycle doc had no QUARANTINED entry, and the QUARANTINE_REASON KDoc carried the old model. All three now state what ships: dice-metamodel is a leaf, QUARANTINED is its own status entered only by a deliberate sweep and left only by release, and DriftMode is OFF and OBSERVE. The quarantine and swept-baseline entries carry the EXPERIMENTAL marker and their opt-in triggers. --- CHANGELOG.md | 3 ++- .../com/embabel/dice/common/DiceMetadataKeys.kt | 4 ++-- docs/design/architecture.md | 14 +++++++------- docs/design/proposition-lifecycle.md | 7 +++++++ 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f73c80c0..1c9e5cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,7 +173,8 @@ and the consumer PRs that deliver it). 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. - Drift checking and quarantine contracts in `dice-metamodel`, plus the default runner and a - reference sweep. **A drift check reports and changes nothing.** `DriftCheckRunner` has one mode: + reference sweep. **EXPERIMENTAL** (shape may change before 1.0) — opt-in: a host calls `DriftSweepCapable.sweep()`; nothing quarantines until then. + **A drift check reports and changes nothing.** `DriftCheckRunner` has one mode: `run()` declares, stamps, observes, compares and persists a `DriftReport`, and holds no quarantine policy and no proposition store, so no path through it can move a proposition or the swept baseline. `DefaultDriftCheckRunner` stamps the declared version into the `MetamodelVersionStore` on diff --git a/dice/src/main/kotlin/com/embabel/dice/common/DiceMetadataKeys.kt b/dice/src/main/kotlin/com/embabel/dice/common/DiceMetadataKeys.kt index ff599f11..812c8960 100644 --- a/dice/src/main/kotlin/com/embabel/dice/common/DiceMetadataKeys.kt +++ b/dice/src/main/kotlin/com/embabel/dice/common/DiceMetadataKeys.kt @@ -36,8 +36,8 @@ object DiceMetadataKeys { /** * Human-readable reason a proposition was quarantined due to schema drift. * - * The presence of this key (alongside `STALE` status) means the proposition was - * quarantined by a drift policy rather than ordinary confidence decay. + * The presence of this key (alongside `PropositionStatus.QUARANTINED`) means the proposition was + * quarantined by a drift policy. Cleared when the proposition is released via `releaseFromQuarantine`. */ const val QUARANTINE_REASON = "dice.metamodel.quarantine.reason" } diff --git a/docs/design/architecture.md b/docs/design/architecture.md index e7962725..fc362ee0 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -16,7 +16,7 @@ DICE is a multi-module Maven build. Each module's intent, and what it's allowed | `dice-storage-autoconfigure` | Spring Boot autoconfiguration that wires `dice-storage`'s beans (repository, projectors, trust scorer) into a host application. Depends on `dice-storage`. | | `dice-ingestion` | Content-hash dedup ledger and source adapters that sit in front of `PropositionPipeline`, so the same artifact is never extracted twice concurrently. Depends on `dice`. | | `dice-report` | Rationale and structured report generation over propositions and their lineage. Depends on `dice`. | -| `dice-metamodel` | Schema governance: content-hash stamps over the governed part of a `DataDictionary`, the declared-schema seam, the version and drift-report store contracts, diffing, drift checking, and non-destructive quarantine. Depends on `dice`, plus `embabel-agent-api` at provided scope. `dice-storage` implements its store contracts. | +| `dice-metamodel` | Schema governance: content-hash stamps over the governed part of a `DataDictionary`, the declared-schema seam, the version and drift-report store contracts, diffing, drift checking, and non-destructive quarantine. A leaf over `embabel-agent-api`, with no dependency on `dice`; `dice-storage` implements its store contracts. | | `dice-integration-tests` | End-to-end tests exercising the real Neo4j backend and full pipeline across module boundaries. Depends on `dice`, `dice-ingestion`, `dice-report` (and transitively `dice-storage`). Not shipped. | ```mermaid @@ -31,7 +31,7 @@ flowchart TB storage --> dice storage --> metamodel - metamodel --> dice + dice --> metamodel autoconf --> storage ingestion --> dice report --> dice @@ -40,11 +40,11 @@ flowchart TB itest --> report ``` -`dice` never depends on any other DICE module — it's the leaf of the graph, so every other module -can be added or removed without touching core logic. `dice-metamodel` depends on `dice`, because -quarantine marks a stranded proposition `STALE` and that touches the proposition model. Beyond -`dice` it takes no storage, no Spring, and no graph driver. One DICE module depends on it: -`dice-storage`, which implements its `MetamodelVersionStore` and `DriftReportStore` against Neo4j. +`dice-metamodel` is a leaf with no dependency on `dice` — it takes no storage, no Spring, and no +graph driver. `dice` depends on `dice-metamodel` to read a `MetamodelDiff`. One DICE module depends on both: +`dice-storage`, which implements the `MetamodelVersionStore` and `DriftReportStore` contracts against Neo4j. +Quarantine marks a proposition `PropositionStatus.QUARANTINED` and is declared in `dice`, so the machinery +stays in core logic without back-depending to the schema model. `dice-storage-autoconfigure` is the only module that knows about Spring Boot autoconfiguration; plain `dice-storage` stays framework-neutral so it can be wired by hand outside Spring Boot. diff --git a/docs/design/proposition-lifecycle.md b/docs/design/proposition-lifecycle.md index 587cf256..977928f5 100644 --- a/docs/design/proposition-lifecycle.md +++ b/docs/design/proposition-lifecycle.md @@ -19,14 +19,21 @@ stateDiagram-v2 ACTIVE --> CONTRADICTED : a newer fact clashes with it (revision or ContradictionResolutionPass) ACTIVE --> SUPERSEDED : folded into a higher-level abstraction (AbstractionPass) ACTIVE --> STALE : DecayStatusPolicy.evaluate — utility drops below stalenessThreshold + ACTIVE --> QUARANTINED : DriftSweepCapable.sweep — schema drift detected (see metamodel-drift.md) STALE --> ACTIVE : DecayStatusPolicy.evaluate — utility recovers above recoveryThreshold + QUARANTINED --> ACTIVE : releaseFromQuarantine — operator releases the hold STALE --> [*] : deliberately retired by hard delete CONTRADICTED --> [*] : kept for audit, no auto-revival SUPERSEDED --> [*] : kept for audit, no auto-revival + QUARANTINED --> [*] : deliberately retired by hard delete note right of ACTIVE pinned=true: immune to STALE transition and contradiction demotion end note + note right of QUARANTINED + immune to decay and contradiction; + exited only by releaseFromQuarantine + end note ``` What triggers each transition: From 0c77081b79cb9af3e28cf03e7eee7e2155374e09 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 08/11] Mark the drift surface experimental and draw quarantine honestly The lifecycle diagram showed one road in: ACTIVE to QUARANTINED. The policy quarantines from any non-quarantined status, STALE included, and release restores the recorded prior status. The diagram now draws both, and the trigger list carries the new transitions the way every other transition has one. The drift and sweep types carry ApiStatus.Experimental. --- dice-metamodel/pom.xml | 11 +++++++++++ .../com/embabel/dice/metamodel/DriftCheckRunner.kt | 3 +++ .../com/embabel/dice/metamodel/DriftReport.kt | 3 +++ dice/pom.xml | 11 +++++++++++ .../com/embabel/dice/spi/DriftQuarantinePolicy.kt | 3 +++ .../com/embabel/dice/spi/DriftSweepCapable.kt | 3 +++ .../dice/spi/MentionTypeDriftQuarantinePolicy.kt | 3 +++ .../embabel/dice/spi/PropositionStoreDriftSweep.kt | 3 +++ docs/design/proposition-lifecycle.md | 13 ++++++++++++- 9 files changed, 52 insertions(+), 1 deletion(-) diff --git a/dice-metamodel/pom.xml b/dice-metamodel/pom.xml index 6f3e3c86..ec3963dd 100644 --- a/dice-metamodel/pom.xml +++ b/dice-metamodel/pom.xml @@ -46,6 +46,17 @@ slf4j-api + + + org.jetbrains + annotations + 26.0.2 + provided + + org.springframework.boot diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt index ccff59ff..94ebab55 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftCheckRunner.kt @@ -15,6 +15,8 @@ */ package com.embabel.dice.metamodel +import org.jetbrains.annotations.ApiStatus + import com.embabel.agent.core.ContextId import java.util.Objects @@ -32,6 +34,7 @@ import java.util.Objects * stamp's [MetamodelVersion.contentHash], and holding the stamp itself is what lets [quarantineDiff] * answer without a second trip to the version store. */ +@ApiStatus.Experimental class DriftCheckResult( val report: DriftReport, val declaredVersion: MetamodelVersion, diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt index 54ac0258..0c2d012d 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DriftReport.kt @@ -15,6 +15,8 @@ */ package com.embabel.dice.metamodel +import org.jetbrains.annotations.ApiStatus + import com.embabel.agent.core.ContextId import java.time.Instant import java.util.Objects @@ -60,6 +62,7 @@ import java.util.Objects * above. `null` when the store tracked no baseline to compare against, which is the state of every * schema before its first sweep finishes. */ +@ApiStatus.Experimental class DriftReport @JvmOverloads constructor( val schemaName: String, val versionHash: String, diff --git a/dice/pom.xml b/dice/pom.xml index 029c32a9..f77a8b9e 100644 --- a/dice/pom.xml +++ b/dice/pom.xml @@ -110,6 +110,17 @@ ${tuprolog.version} + + + org.jetbrains + annotations + 26.0.2 + provided + + com.embabel.agent diff --git a/dice/src/main/kotlin/com/embabel/dice/spi/DriftQuarantinePolicy.kt b/dice/src/main/kotlin/com/embabel/dice/spi/DriftQuarantinePolicy.kt index e4a3454c..066673a7 100644 --- a/dice/src/main/kotlin/com/embabel/dice/spi/DriftQuarantinePolicy.kt +++ b/dice/src/main/kotlin/com/embabel/dice/spi/DriftQuarantinePolicy.kt @@ -15,6 +15,8 @@ */ package com.embabel.dice.spi +import org.jetbrains.annotations.ApiStatus + import com.embabel.dice.metamodel.MetamodelDiff import com.embabel.dice.proposition.Proposition import com.embabel.dice.proposition.PropositionStatus @@ -27,6 +29,7 @@ import com.embabel.dice.proposition.PropositionStatus * proposition model has no business knowing about schema versioning. The naming follows the same * `dice..` convention, so nothing collides with a consumer's own keys. */ +@ApiStatus.Experimental object DriftQuarantineKeys { /** diff --git a/dice/src/main/kotlin/com/embabel/dice/spi/DriftSweepCapable.kt b/dice/src/main/kotlin/com/embabel/dice/spi/DriftSweepCapable.kt index 3f9d088f..00b963e9 100644 --- a/dice/src/main/kotlin/com/embabel/dice/spi/DriftSweepCapable.kt +++ b/dice/src/main/kotlin/com/embabel/dice/spi/DriftSweepCapable.kt @@ -15,6 +15,8 @@ */ package com.embabel.dice.spi +import org.jetbrains.annotations.ApiStatus + import com.embabel.agent.core.ContextId import com.embabel.dice.metamodel.DriftCheckResult import com.embabel.dice.metamodel.DriftCheckRunner @@ -68,6 +70,7 @@ import com.embabel.dice.proposition.PropositionStatus * restores the status the proposition carried before quarantine and clears both quarantine keys in * one write. */ +@ApiStatus.Experimental interface DriftSweepCapable { /** diff --git a/dice/src/main/kotlin/com/embabel/dice/spi/MentionTypeDriftQuarantinePolicy.kt b/dice/src/main/kotlin/com/embabel/dice/spi/MentionTypeDriftQuarantinePolicy.kt index 36b09ea4..89369730 100644 --- a/dice/src/main/kotlin/com/embabel/dice/spi/MentionTypeDriftQuarantinePolicy.kt +++ b/dice/src/main/kotlin/com/embabel/dice/spi/MentionTypeDriftQuarantinePolicy.kt @@ -15,6 +15,8 @@ */ package com.embabel.dice.spi +import org.jetbrains.annotations.ApiStatus + import com.embabel.agent.core.Cardinality import com.embabel.dice.common.DiceMetadataKeys import com.embabel.dice.metamodel.DeclaredSchema @@ -123,6 +125,7 @@ import org.slf4j.LoggerFactory * * Rename awareness and the widening allow-list are experimental: behavior may change before 1.0. */ +@ApiStatus.Experimental class MentionTypeDriftQuarantinePolicy : DriftQuarantinePolicy { private val logger = LoggerFactory.getLogger(MentionTypeDriftQuarantinePolicy::class.java) diff --git a/dice/src/main/kotlin/com/embabel/dice/spi/PropositionStoreDriftSweep.kt b/dice/src/main/kotlin/com/embabel/dice/spi/PropositionStoreDriftSweep.kt index 3a121585..820dad51 100644 --- a/dice/src/main/kotlin/com/embabel/dice/spi/PropositionStoreDriftSweep.kt +++ b/dice/src/main/kotlin/com/embabel/dice/spi/PropositionStoreDriftSweep.kt @@ -15,6 +15,8 @@ */ package com.embabel.dice.spi +import org.jetbrains.annotations.ApiStatus + import com.embabel.agent.core.ContextId import com.embabel.dice.common.DiceEventListener import com.embabel.dice.common.DiceMetadataKeys @@ -50,6 +52,7 @@ import java.time.Instant * without depending on whichever concrete [propositions] store happens to be wired in. Defaults to * a no-op: everything else here holds with nobody listening. */ +@ApiStatus.Experimental class PropositionStoreDriftSweep @JvmOverloads constructor( private val propositions: PropositionStore, private val listener: DiceEventListener = DiceEventListener.DEV_NULL, diff --git a/docs/design/proposition-lifecycle.md b/docs/design/proposition-lifecycle.md index 977928f5..9f2ac6b5 100644 --- a/docs/design/proposition-lifecycle.md +++ b/docs/design/proposition-lifecycle.md @@ -20,8 +20,14 @@ stateDiagram-v2 ACTIVE --> SUPERSEDED : folded into a higher-level abstraction (AbstractionPass) ACTIVE --> STALE : DecayStatusPolicy.evaluate — utility drops below stalenessThreshold ACTIVE --> QUARANTINED : DriftSweepCapable.sweep — schema drift detected (see metamodel-drift.md) + STALE --> QUARANTINED : DriftSweepCapable.sweep — schema drift detected (see metamodel-drift.md) + CONTRADICTED --> QUARANTINED : DriftSweepCapable.sweep — schema drift detected (see metamodel-drift.md) + SUPERSEDED --> QUARANTINED : DriftSweepCapable.sweep — schema drift detected (see metamodel-drift.md) STALE --> ACTIVE : DecayStatusPolicy.evaluate — utility recovers above recoveryThreshold - QUARANTINED --> ACTIVE : releaseFromQuarantine — operator releases the hold + QUARANTINED --> ACTIVE : releaseFromQuarantine — operator releases the hold (when prior status was ACTIVE) + QUARANTINED --> STALE : releaseFromQuarantine — operator releases the hold (when prior status was STALE) + QUARANTINED --> CONTRADICTED : releaseFromQuarantine — operator releases the hold (when prior status was CONTRADICTED) + QUARANTINED --> SUPERSEDED : releaseFromQuarantine — operator releases the hold (when prior status was SUPERSEDED) STALE --> [*] : deliberately retired by hard delete CONTRADICTED --> [*] : kept for audit, no auto-revival SUPERSEDED --> [*] : kept for audit, no auto-revival @@ -40,7 +46,12 @@ What triggers each transition: - **ACTIVE → CONTRADICTED**: `LlmPropositionReviser` at ingest time, or `ContradictionResolutionPass` during a dream-loop cycle. - **ACTIVE → SUPERSEDED**: `AbstractionPass` during a dream-loop cycle, when a cluster of facts is folded into a higher-level proposition. - **ACTIVE → STALE**: `StatusTransitionPolicy.evaluate` (default `DecayStatusPolicy`), run per-proposition by `DecayManager`/`DecaySweeper` (`sweep` / `sweepAll` / `tick`), or by a mark-and-sweep collector run. +- **ACTIVE → QUARANTINED**: `DriftSweepCapable.sweep` (default `PropositionStoreDriftSweep`), run by `DriftCheckRunner` when schema drift is detected, evaluated with `DriftQuarantinePolicy`. +- **STALE → QUARANTINED**: `DriftSweepCapable.sweep` (default `PropositionStoreDriftSweep`), run by `DriftCheckRunner` when schema drift is detected, evaluated with `DriftQuarantinePolicy`. +- **CONTRADICTED → QUARANTINED**: `DriftSweepCapable.sweep` (default `PropositionStoreDriftSweep`), run by `DriftCheckRunner` when schema drift is detected, evaluated with `DriftQuarantinePolicy`. +- **SUPERSEDED → QUARANTINED**: `DriftSweepCapable.sweep` (default `PropositionStoreDriftSweep`), run by `DriftCheckRunner` when schema drift is detected, evaluated with `DriftQuarantinePolicy`. - **STALE → ACTIVE**: the same `DecayStatusPolicy.evaluate` call, when a proposition's decayed utility recovers back above `recoveryThreshold`. The reviser itself never flips status — `reinforceProposition` only boosts confidence and resets the decay clock, which is what lets the next sweep's utility calculation cross back over the threshold. +- **QUARANTINED → ACTIVE/STALE/CONTRADICTED/SUPERSEDED**: `DriftSweepCapable.releaseFromQuarantine` — operator releases the hold, restoring the status the proposition carried before quarantine (stored in `DriftQuarantineKeys.PREVIOUS_STATUS`). A projected proposition keeps its ACTIVE status so it stays retrievable — projection records the lineage on the graph side rather than moving the proposition off ACTIVE. `PROMOTED` is a reserved status in the enum for a projected fact, and the decay sweep and collector already exclude it from From 468ed29e03d8ee5741b8b69576397f1691d09319 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:05:59 -0400 Subject: [PATCH 09/11] Name no consumer in the quarantine changelog entry --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c9e5cde..f41b03f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -285,8 +285,8 @@ and the consumer PRs that deliver it). `QUARANTINED`, so an exhaustive `when` over the enum needs a new branch too; inside DICE there was exactly one (`DefaultDreamLoopOrchestrator.statusStrength`, where `QUARANTINED` now ranks above every automatic retirement, since letting one overwrite a governance hold would drop the reason and - the recorded prior status with it), and `me`'s status matching is the known external consumer, which - recompiles. Persistence is by enum *name* throughout (`PropositionGraphMapper`, + the recorded prior status with it), and a host that matches on status exhaustively is the known + consumer shape, which recompiles. Persistence is by enum *name* throughout (`PropositionGraphMapper`, `CollectorTraceRowMappers`, `LineageRowMappers`), so no stored value changes meaning. The quarantine types keep their names and move package, from `com.embabel.dice.metamodel` and `com.embabel.dice.metamodel.support` to `com.embabel.dice.spi`; they were added in this same From 1416baaa9a4a2dc58a6d6af54e8a7b27f4f6bcf4 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:18:30 -0400 Subject: [PATCH 10/11] Declare the annotations dependency once and state the sweep overlap contract dice-metamodel declared org.jetbrains:annotations twice, the second time with a hard-coded version. The first declaration resolves from the root dependencyManagement and is the one that stays. markSwept is last-write-wins, so its KDoc now says that sweeps of one schema must not overlap and that the call site runs them one at a time. --- dice-metamodel/pom.xml | 11 ----------- .../embabel/dice/metamodel/MetamodelVersionStore.kt | 5 +++++ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/dice-metamodel/pom.xml b/dice-metamodel/pom.xml index ec3963dd..6f3e3c86 100644 --- a/dice-metamodel/pom.xml +++ b/dice-metamodel/pom.xml @@ -46,17 +46,6 @@ slf4j-api - - - org.jetbrains - annotations - 26.0.2 - provided - - org.springframework.boot diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt index 605ea8a7..7f9cf927 100644 --- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt +++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt @@ -162,6 +162,11 @@ interface SweptBaselineStore : MetamodelVersionStore { * Marking after a sweep that found nothing to quarantine is correct: "nothing needed doing" is a * completed reconciliation against that declaration. * + * Sweeps of one schema must not overlap. The store records whichever completion arrives last, + * so a sweep that started against an older declaration and finished after a newer one would + * move the baseline back to the older stamp, and the next check would report changes that were + * already swept. The call site runs sweeps of a schema one at a time. + * * @param version The version to record as reconciled. */ fun markSwept(version: MetamodelVersion) From 56d1bd6c89fc67c35abffe6bdcd7c985ace7f9e6 Mon Sep 17 00:00:00 2001 From: James Dunnam <7660553+jimador@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:26:01 -0400 Subject: [PATCH 11/11] Declare the annotations dependency once in the dice module Commit 959712c added a duplicate org.jetbrains:annotations block here as it did to dice-metamodel. The first declaration, with no version, is the correct one; the version comes from the root pom's dependencyManagement through jetbrains.annotations.version. --- dice/pom.xml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/dice/pom.xml b/dice/pom.xml index f77a8b9e..029c32a9 100644 --- a/dice/pom.xml +++ b/dice/pom.xml @@ -110,17 +110,6 @@ ${tuprolog.version} - - - org.jetbrains - annotations - 26.0.2 - provided - - com.embabel.agent