From 95f3b4dc8a0faa9d23fda1b1d41b095f5835d215 Mon Sep 17 00:00:00 2001 From: Emmanuel Prunet Date: Mon, 5 Oct 2026 14:29:21 +0200 Subject: [PATCH 1/2] =?UTF-8?q?spec(0.10.0):=20=C2=A72.4=20=E2=80=94=20ret?= =?UTF-8?q?ention=5Fprofile=20names=20behavior.ngram=5Fsize;=20=C2=A712.1?= =?UTF-8?q?=20omits=20behavior=20when=20two=20composed=20inputs=20differ?= =?UTF-8?q?=20in=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ngram_size was in neither processing identifier's list, so two documents produced at different sequence orders could carry equal identifiers and pass §2.4's comparability gate; compose() then picked one input's required ngram_size (breaking §12.2's commutativity MUST), and a MetaLogDiff across them reported total n-gram turnover as a witness of change. §2.4 now states what retention_profile names as a rule (every parameter fixing which entries a bounded block retains, how they are ranked, and what an entry's key denotes), with behavior.ngram_size among its examples, so the existing gate refuses a cross-order compose and diff. §12.1 covers the case that gate does not reach (an input without the identifier): C.behavior MUST be omitted when both inputs carry it at different ngram_size, never merged at a minimum and never carried from one side. The §1 table and the schema's retention_profile description name the parameter. RFC #8, P3 (f). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++-- SPEC.md | 29 ++++++++++++++++++++++++----- schema/metalog.v0.schema.json | 2 +- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d24288..5cf1597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,8 @@ joint probability at `ngram_size` above 2 now violates, a rule on which template `branching_delta` row (§13.1) that a producer emitting one for a template branching on one side only now violates, and a binding of each param to a whole-token wildcard (§3.5) that a producer emitting a param for a wildcard inside a token, or none for a whole-token wildcard, -now violates. +now violates, and two rules on `behavior.ngram_size` (§2.4, §12.1) that a producer keeping one +`retention_profile` across two orders, or a composer merging two orders' blocks, now violates. It also adds one optional member (`withheld_signals`, §13.2.2), which is additive on its own. MINOR bump: MAJOR stays `0`, so §6's *"the MAJOR field of `metalog_version` must equal the MAJOR of the spec"* is @@ -71,10 +72,36 @@ in pull request [#14](https://github.com/CodeRoasted/metalog-spec/pull/14) under 0.x line without an `rfc:` issue or a comment window, while the reference implementation is the only producer and consumer. The RFC comes back at the v1.0 freeze, or earlier the day a second implementation is listed. P1, P3 and P4 stay -proposed; any of them that is taken lands as an editor change recorded here. +proposed; any of them that is taken lands as an editor change recorded here. **P3's item (f)**, +the order-dependent `behavior.ngram_size`, is the first taken: see the §2.4 / §12.1 entry under +*Changed*. The rest of P3 (the caps rule) stays proposed. ### Changed +- **§2.4 — `retention_profile` names `behavior.ngram_size`, and §12.1 omits `behavior` when + two composed inputs differ in it.** `ngram_size` was in neither identifier's list, so two + documents produced at different sequence orders could carry equal identifiers and pass + §2.4's comparability gate. `compose()` then had to pick one input's `ngram_size` — a + required field — so `compose(A, B)` and `compose(B, A)` declared different values, against + §12.2's commutativity MUST; and a `MetaLogDiff` across the pair reported every n-gram of + one side as vanished and every n-gram of the other as new, which §13.2 counts as a witness + of `"changed"`. An order-`m` key and an order-`n` key denote different objects, so no + minimum repairs it: the smaller order either declares an order the array does not hold or + drops one input's n-grams with no field reporting the loss. Two changes. **§2.4** states + what `retention_profile` names as a rule — every parameter that fixes which entries a + bounded block retains, how they are ranked, and what an entry's key denotes — with the + existing items and `behavior.ngram_size` as its examples, so the existing gate refuses a + cross-order `compose()` and `MetaLogDiff` at both sites with no new mechanism. **§12.1** + covers the case §2.4's gate does not reach (an input without the identifier): when both + inputs carry `behavior` at different `ngram_size`, `C.behavior` **MUST** be omitted, never + merged at a minimum and never carried from one side; when they agree, `C.behavior.ngram_size` + is that value. Omitting the block also drops `dropped_ngram_observations`: a count lost, + not falsified. The §1 table and the schema's `retention_profile` description name the + parameter. **Breaking** — a producer keeping one `retention_profile` across two orders, or a + composer merging two orders' blocks, becomes non-conformant — and editor-merged under + [`GOVERNANCE.md`](GOVERNANCE.md) §2's 0.x rule. No document's validity changes: both + identifiers stay opaque strings and `behavior` stays optional. RFC #8, P3 (f). + - **A window with no event time has no envelope (§2.2).** `window.start`, `window.end` and `window.duration_seconds` are present together, or absent together when the producer attributes an event time to none of the window's lines — a window with no line diff --git a/SPEC.md b/SPEC.md index 610016f..afb79da 100644 --- a/SPEC.md +++ b/SPEC.md @@ -78,7 +78,7 @@ top-level fields: | `window` | object | yes | The line count and, when any line has one, the event-time envelope of the lines the window contains. See §2.2. | | `source` | object | yes | What was observed (service, host, fleet). See §2.3. | | `canonicalization_version` | string | no | Opaque identifier for the canonicalization rules in effect. Gates `compose()`/diff comparability. See §2.4. | -| `retention_profile` | string | no | Opaque identifier for the retention parameters (top_k, reservoir, salience weights, diversity caps). Gates `compose()`/diff comparability. See §2.4. | +| `retention_profile` | string | no | Opaque identifier for the retention parameters (top_k, reservoir, salience weights, diversity caps, `behavior.ngram_size`). Gates `compose()`/diff comparability. See §2.4. | | `stats` | object | yes | Per-template counts and frequency metrics. See §3. | | `templates` | object | no | Optional dedup map `template_id → template_str`. See §3.4. **RESERVED** — see §2. | | `behavior` | object | no | Sequence/transition fingerprint. See §4. | @@ -233,10 +233,14 @@ A MetaLog **MAY** carry two opaque processing-identifier strings that name the templates and structural metadata). It **MUST** be bumped when those rules' *output-affecting* semantics change; a binary rebuild with no rule change **MUST NOT** bump it. It is **not** a binary build id. -- `retention_profile` — names the **retention parameters** in effect: `top_k` - size (§3.1), reservoir admission weights and size and diversity caps (§3.7), - and the salience arithmetic. It **MUST** be bumped when any of those - parameters change. +- `retention_profile` — names the **retention parameters** in effect: every + parameter that fixes which entries a bounded block retains, how they are ranked, + and **what an entry's key denotes**. At this version those are `top_k` size + (§3.1), the reservoir admission weights, size and diversity caps and the salience + arithmetic (§3.7), and `behavior.ngram_size` (§4) — the sequence order, which + fixes the *domain* of `top_ngrams` rather than a bound on it. That list + illustrates the rule and does not close it: a parameter meeting the rule joins it + without an edit here. It **MUST** be bumped when any such parameter changes. The values are **opaque strings**. This spec defines neither a registry of names nor a canonical format; producers and consumers within an environment **MUST** @@ -1364,6 +1368,21 @@ to a 1-hour MetaLog). - `C.behavior.dominant_path` is re-derived greedily from the merged graph; consumers **MUST NOT** assume it equals the path of either input. +- `C.behavior` **MUST** be omitted entirely when both inputs carry a `behavior` block + and their `ngram_size` values **differ**. An order-`m` key and an order-`n` key with + `m ≠ n` denote different objects, so there is no merged n-gram population to derive: + counts across the two orders are not comparable (every order-`n` occurrence implies + an order-`m` one for `m < n`, so a joint ranking is biased toward the shorter order) + and `probability` is conditioned to a different depth on each side. Where the two + agree, `C.behavior.ngram_size` is that value. A composer **MUST NOT** instead take + the smaller order, nor carry one input's block through unchanged: the first declares + an order the merged array does not hold or silently drops one input's n-grams, and + the second describes one input's lines under a `window` covering both. Omitting the + block also drops `dropped_ngram_observations`; that **loses** a count and does not + falsify one, because §4's omission rule governs the field inside a present block, + not the block. The precondition is block-grained, like the `cube` clause below: the + rest of `C` composes as usual. It is the residual case only — whenever both inputs + carry a `retention_profile`, §2.4's gate refuses the pair before composition begins. - `C.stability` **MUST** be omitted (it is meaningless across composed inputs); consumers wanting a current-vs-prior view of a composed document should use §13 Diff explicitly. diff --git a/schema/metalog.v0.schema.json b/schema/metalog.v0.schema.json index 69dda8a..7b2dd9e 100644 --- a/schema/metalog.v0.schema.json +++ b/schema/metalog.v0.schema.json @@ -467,7 +467,7 @@ }, "retention_profile": { "type": "string", - "description": "Opaque identifier for the retention parameters — top_k size, reservoir admission weights/size/diversity caps, and the salience arithmetic; gates compose()/diff comparability (SPEC §2.4). Added in v0.5.0." + "description": "Opaque identifier for the retention parameters — top_k size, reservoir admission weights/size/diversity caps, the salience arithmetic, and behavior.ngram_size; gates compose()/diff comparability (SPEC §2.4). Added in v0.5.0." }, "coordinate": { "description": "Re-derivation coordinate addressing this window back to its source: raw(window) = replay(source, bounds) (SPEC §15). A raw coordinate XOR a composed coordinate. Added in v0.5.0.", From f323bce6fdc286cc8a8838b275f32de2c8fd0a6b Mon Sep 17 00:00:00 2001 From: Emmanuel Prunet Date: Tue, 6 Oct 2026 11:19:29 +0200 Subject: [PATCH 2/2] =?UTF-8?q?W475:=20a=20MetaLogDiff=20across=20two=20n-?= =?UTF-8?q?gram=20orders=20withholds=20ngram=5Fdelta=20and=20says=20so=20(?= =?UTF-8?q?spec=200.10.0=20=C2=A713.1,=20=C2=A713.2.3=20incomparable=5Fsig?= =?UTF-8?q?nals;=20the=20reference=20producer)=20=E2=80=94=20DN-56.D12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Founder's ruling of 2026-10-06: never a silent absence, never total turnover, never a reduction of one order into the other. One lockstep unit, because the commit gate's metalog_vocabulary module (S41) refuses the four new wire names without a producer path. metalog-spec (PR #21's branch): §13.1 — when both inputs carry behavior at different ngram_size, ngram_delta MUST be omitted and the omission stated, stamped or not; §13.2.3 (new) — incomparable_signals, an optional root object keyed by the omitted property, its value a reason from a schema-closed vocabulary plus the evidence: {"ngram_delta":{"reason":"ngram_size_differs","previous_ngram_size":2,"current_ngram_size":3}}. A descriptor, never a witness; a root dependentSchemas refuses a named key present or named in withheld_signals. Three conformance fixtures and the incomparable-signal-is-not-a-witness control; ADR 0007 (silent absence, total turnover, decay, reuse of withheld_signals, refusing the whole diff and a free-text reason each rejected); CHANGELOG 0.10.0 Changed + Added. insight-metalog: MetaLogDiff::ngram_order_mismatch (std::optional, both orders as std::size_t), set by diff_ngram_delta's cross-order branch, which returns with no delta; the either-side-absent branch is unchanged. The serializer writes dto::Diff::incomparable_signals after withheld_signals and before extensions, only when the member is engaged. comparison_outcome_of gains no clause (a descriptor never witnesses). No consumer cascades: the five production diff() call sites (four in insight-eidos's pyramid, one in Sift's diff_engine.cpp) compare documents of one pipeline at one order. Red first: NgramOrderComparability.UnstampedDiffAcrossOrdersStatesTheNgramDeltaIncomparable failed before the member existed (insight-metalog 356/357, both directions missing the member). Suites: insight-metalog 357/357 on clang-21 and on gcc-16.2; insight-eidos clang-21 llm 24/24, root 392/392, sift 879/879, insight-e2e 228/228; coderoast-server clang-21 clickhouse 30/30, postgres 29/29, redis 20/20, insight-mcp 82/82, server 472/472. Determinism digest (7 corpus + 8 synthetic sections, clang-21 det_fixture) byte-identical before and after: sha256 3295f8f8…ab39, 928 328 bytes. metalog_validate.py --selftest 35/35; spec_conformance_gate.sh rc 0 on that digest. malf format --check 0 misformatted, 0 CCC violations; malf lint 17/17 TUs, 0 findings in the touched files. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 42 +++++- SPEC.md | 123 +++++++++++++++++- ...07-a-comparison-not-performed-is-stated.md | 112 ++++++++++++++++ conformance/README.md | 7 +- .../changed_on_incomparable_only.diff.json | 47 +++++++ ..._order_ngram_delta_beside_marker.diff.json | 53 ++++++++ conformance/fixtures/manifest.json | 50 +++++++ .../cross_order_ngram_incomparable.diff.json | 47 +++++++ conformance/metalog_validate.py | 4 + schema/metalog_diff.v0.schema.json | 31 +++++ 10 files changed, 504 insertions(+), 12 deletions(-) create mode 100644 adr/0007-a-comparison-not-performed-is-stated.md create mode 100644 conformance/fixtures/invalid/changed_on_incomparable_only.diff.json create mode 100644 conformance/fixtures/invalid/cross_order_ngram_delta_beside_marker.diff.json create mode 100644 conformance/fixtures/valid/cross_order_ngram_incomparable.diff.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cf1597..a0d518b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,9 +43,11 @@ joint probability at `ngram_size` above 2 now violates, a rule on which template side only now violates, and a binding of each param to a whole-token wildcard (§3.5) that a producer emitting a param for a wildcard inside a token, or none for a whole-token wildcard, now violates, and two rules on `behavior.ngram_size` (§2.4, §12.1) that a producer keeping one -`retention_profile` across two orders, or a composer merging two orders' blocks, now violates. -It also adds one optional member -(`withheld_signals`, §13.2.2), which is additive on its own. MINOR bump: MAJOR stays `0`, so +`retention_profile` across two orders, or a composer merging two orders' blocks, now violates, +and a rule on `ngram_delta` across two orders (§13.1, §13.2.3) that a diff producer computing the +delta across them, or omitting it without saying so, now violates. +It also adds two optional members +(`withheld_signals`, §13.2.2, and `incomparable_signals`, §13.2.3), each additive on its own. MINOR bump: MAJOR stays `0`, so §6's *"the MAJOR field of `metalog_version` must equal the MAJOR of the spec"* is satisfied unchanged and **both schema files keep their `v0` filenames** — a reader who expects a `v1` schema and a MAJOR check that starts refusing documents will @@ -102,6 +104,25 @@ the order-dependent `behavior.ngram_size`, is the first taken: see the §2.4 / [`GOVERNANCE.md`](GOVERNANCE.md) §2's 0.x rule. No document's validity changes: both identifiers stay opaque strings and `behavior` stays optional. RFC #8, P3 (f). +- **§13.1, §13.2.3 — a `MetaLogDiff` across two n-gram orders withholds `ngram_delta` and + says so, with its reason.** §2.4's gate binds only when both inputs carry + `retention_profile`, so an unstamped pair at two orders still reaches §13, and the text + said nothing about its `ngram_delta`. Computing it reports total turnover — no key can + match across orders — which §13.2 counts as a witness of `"changed"`; omitting it, as the + reference implementation did, makes the document read like a comparison that found no + n-gram movement, an all-clear no reader can tell from a true one. When both inputs carry + `behavior` at different `ngram_size`, a producer now **MUST** omit `ngram_delta` and + **MUST** carry `incomparable_signals.ngram_delta` with the reason `ngram_size_differs` + and both orders (§13.2.3, under *Added*). It **MUST NOT** reduce one order to the other: + `top_ngrams` is truncated, so a marginal over it is a lower bound of unknown slack, and + `probability` is conditioned at a different depth on each side with neither the pre-cut + denominator nor the longer prefixes' weights on the wire. The rest of the diff is computed + as usual. **Breaking by the letter** — a producer that computes the delta across two + orders, or omits it without the statement, becomes non-conformant — and editor-merged + under [`GOVERNANCE.md`](GOVERNANCE.md) §2's 0.x rule. No existing document's validity + changes: the member is new and optional. Decision and alternatives: + [ADR 0007](adr/0007-a-comparison-not-performed-is-stated.md). + - **A window with no event time has no envelope (§2.2).** `window.start`, `window.end` and `window.duration_seconds` are present together, or absent together when the producer attributes an event time to none of the window's lines — a window with no line @@ -242,7 +263,7 @@ the order-dependent `behavior.ngram_size`, is the first taken: see the §2.4 / signal property whose shape nobody anticipated writes the predicate that decides it and needs no change to this section. - **All thirteen** optional signal properties of `metalog_diff.v0.schema.json` + **All fourteen** optional signal properties of `metalog_diff.v0.schema.json` carry one. Coverage is a MUST and an absent declaration is a **defect of the schema, not a permission**: a validator deciding §13.2 **MUST** refuse to run rather than return a verdict about a finding it cannot define. @@ -310,6 +331,19 @@ the order-dependent `behavior.ngram_size`, is the first taken: see the §2.4 / Producers unaffected: the member is optional and a producer that serialises every finding it makes never emits it. +- **§13.2.3 — `incomparable_signals`, a comparison this document did not perform.** An + optional object at the `MetaLogDiff` root, keyed by the name of a signal property the + document omits because its two inputs cannot be compared on it; each value carries a + `reason` from a vocabulary the schema closes, and the evidence for it. At this version the + vocabulary holds one entry: `ngram_delta`, reason `ngram_size_differs`, with + `previous_ngram_size` and `current_ngram_size`. It is declared a **descriptor** (§13.2.1 + clause 5), never a witness: a comparison not performed is evidence of neither outcome, so + `"unchanged"` stands beside it and a consumer reads neither outcome as covering a property + it names. The schema closes the vocabulary and, with a root `dependentSchemas`, refuses a + document that carries a named property or names it in `withheld_signals`. The + conformance tool gains three fixtures and the `incomparable-signal-is-not-a-witness` + control. A new key or reason is an additive change. + ### Clarified - **§4 — a `top_ngrams` `sequence` is `ngram_size` consecutive records of one diff --git a/SPEC.md b/SPEC.md index afb79da..e338cec 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1566,6 +1566,8 @@ not directly comparable. ] }, "withheld_signals": [ ], // array, optional — see §13.2.2 (new in v0.10.0) + // "incomparable_signals": { ... } // object, optional — see §13.2.3 (new in v0.10.0); never beside + // a property it names, so not shown with the ngram_delta above "extensions": { // object, optional — vendor data, §7 (placement granted in v0.9.0) "com.example.deploy_window": "2026-01-14.3" } @@ -1580,6 +1582,18 @@ A consumer **MUST NOT** read a missing row, or a template absent from one side's `branching`, as an entropy of zero. The rule involves both input documents, which no schema keyword can see, so it is a producer obligation stated here and not a schema constraint. +**`ngram_delta` across two orders.** When both compared documents carry `behavior` and their +`behavior.ngram_size` values **differ**, a producer **MUST** omit `ngram_delta` and **MUST** +state the omission in `incomparable_signals.ngram_delta` (§13.2.3), with the reason +`ngram_size_differs` and both orders. An order-`m` key and an order-`n` key with `m ≠ n` denote +different objects (§12.1), so no key can match across the pair: a delta computed anyway reports +every n-gram of one side as vanished and every n-gram of the other as new, a turnover produced by +the configuration rather than by the workload. Nor may the producer reduce one order to the +other — see §13.2.3 for why no reduction is exact. The rule binds whether or not the inputs carry +`retention_profile`: when both do, their values differ by §2.4's bump MUST and the gate refuses +the pair first, so this is what a pair the gate does not reach receives. When either document carries no `behavior`, this rule does not +apply. + ### 13.1.1 `diff_version` — which version this is **`diff_version` is the version of THIS specification that the document @@ -1633,8 +1647,10 @@ will catch it. - `comparison_outcome` is `"changed"` or `"unchanged"`. It is the producer's **assertion about the comparison it performed**, not a summary of which fields it chose to serialise. -- `"unchanged"` means *the comparison ran and found no change*. That is - a **positive result**, not an empty document: an instrument that +- `"unchanged"` means *the comparison ran and found no change* in the + signal properties it compared; `incomparable_signals` (§13.2.3) names + any it could not compare, and `"unchanged"` says nothing about those. + That is a **positive result**, not an empty document: an instrument that cannot say "I compared and found nothing" is an instrument whose silence is unreadable. A document whose `comparison_outcome` is `"unchanged"` **MAY** carry signal properties and is **NOT** required @@ -1651,7 +1667,9 @@ will catch it. `js_divergence` is emitted whenever computable, so presence alone is satisfied by every producer regardless of what it found. A producer whose only finding lies in a property it does not serialise reports it in - `withheld_signals`, which is its witness — see §13.2.2. + `withheld_signals`, which is its witness — see §13.2.2. A property the + producer could not compare at all is neither a witness nor the absence + of one, and is named in `incomparable_signals` — see §13.2.3. - The witness set is **every optional signal property of [`schema/metalog_diff.v0.schema.json`](schema/metalog_diff.v0.schema.json)** — derived from the schema, never enumerated here. A signal property @@ -1738,8 +1756,11 @@ defect.** §13.7 forbids emitting `reservoir_delta` with all three of its lists empty, which is exactly the state its declaration calls vacuous — so in a conformant document the property witnesses whenever it is present. Clause 1 forbids the two boolean spellings, so this is the only -conformant way to write that declaration; a reader should simply not -expect all twelve to be able to decide both ways. +conformant way to write that declaration. **Its mirror is never a witness, +and that is not a defect either:** `incomparable_signals` (§13.2.3) is +declared a descriptor under clause 5, because a comparison not performed +is evidence of neither outcome. A reader should simply not expect every +declaration to be able to decide both ways. **Coverage.** Every property of the root `properties` object that is neither listed in the root `required` array nor the §7 `extensions` container **MUST** @@ -1819,6 +1840,8 @@ of it alone. document** — absent from it, or present at its declared vacuous value. Naming a property whose serialised value already witnesses is a false statement about what the document withholds. +- A member **MUST NOT** name a key of `incomparable_signals` (§13.2.3): a + property whose inputs were not compared has no finding to withhold. - A producer **MUST NOT** name a property merely because it does not implement one. This member reports a **finding**, not an inventory of omissions: a producer that computes no cube computes no `cube_diff` finding and has nothing @@ -1853,6 +1876,96 @@ schema carries `uniqueItems` and nothing more. Like §13.1.1's version rule and §13.7's orderings, these bind the **producer** and are decidable by an implementer over its own output. +### 13.2.3 `incomparable_signals` — a comparison this document did not perform (new in v0.10.0) + +§13.2.2 covers a change the producer **found** and did not serialise. The opposite +state exists too: a signal property whose two inputs cannot be compared at all, so +the producer has found nothing about it — neither a change nor its absence. Omitting +the property is the only correct thing to do with its value. Omitting it **silently** +is the one wrong thing to do with the fact: under §13.2.1 step 3 an absent property +is not a witness, so the document then reads exactly like a comparison that ran and +found the property unmoved — an all-clear no reader can tell from a true one. + +**`incomparable_signals`** (object, optional) states the omission. Each member is +keyed by the name of a signal property this document omits because its inputs are +incomparable on it, and its value carries the reason, from a vocabulary the schema +declares, with the evidence for it: + +```jsonc +{ + "diff_version": "0.10.0", + "comparison_outcome": "unchanged", + "current": { "window": { "start": "...", "end": "..." } }, + "previous": { "window": { "start": "...", "end": "..." } }, + "js_divergence": 0, + "incomparable_signals": { + "ngram_delta": { "reason": "ngram_size_differs", "previous_ngram_size": 2, "current_ngram_size": 3 } + } +} +``` + +- It is a **descriptor** (§13.2.1 clause 5): its `x-metalog-vacuous` declaration + carries no assertion keyword, so it is **never a witness**. A comparison that was + not performed is evidence of neither outcome, so this member never makes + `"changed"` legal and never makes `"unchanged"` false. A producer whose inputs + differ only in a property they cannot be compared on has found no change, and + reports `"unchanged"` beside this member. +- A consumer **MUST NOT** read `comparison_outcome`, of either value, as a statement + about a property this member names, and **MUST NOT** read that property's absence + as "no change". +- Every key **MUST** be absent from the document, and **MUST NOT** be named in + `withheld_signals` (§13.2.2). +- The member is omitted when nothing was incomparable; when present it carries at + least one key. +- **The vocabulary is closed, in the schema.** Which properties can be incomparable, + and for which reasons, is declared in + [`schema/metalog_diff.v0.schema.json`](schema/metalog_diff.v0.schema.json); a + producer **MUST NOT** use this member for a property or a reason the schema does + not declare. Adding one is an additive change under + [`GOVERNANCE.md`](GOVERNANCE.md) §2. At this version the vocabulary holds one + entry. + +**`ngram_delta`, reason `ngram_size_differs`.** Emitted exactly when both compared +documents carry `behavior` and their `behavior.ngram_size` values differ (§13.1). +`previous_ngram_size` and `current_ngram_size` **MUST** equal `previous`'s and +`current`'s `behavior.ngram_size`, and so differ from each other; the schema types +them, and the inequality, which relates two members, is the producer's obligation. + +**Why no reduction from one order to the other.** A producer **MUST NOT** derive an +`ngram_delta` by reducing either side to the other's order, because no such reduction +is exact on what a document carries: + +- **Down (order `n` to order `m < n`, summing over the trailing ids).** `top_ngrams` + is a **truncated** table: every key below the `top_ngrams_size` cut, and every + observation refused before counting (`dropped_ngram_observations`, §4), is missing + from the sum with no record of which order-`m` key it would have fed. The reduced + counts are lower bounds of unknown slack, and the reduced ranking can be wrong. + The table also loses the last `n − m` order-`m` sequences of every observation + stream, which have no order-`n` extension, so the sum undercounts even when nothing + was truncated. +- **`probability` cannot be carried across at all.** It is p(last | first n − 1), + normalised over every counted sequence sharing the prefix **before** the cut (§4). + The conditional at one depth does not determine the conditional at another without + the weight of every longer prefix, and neither those weights nor the pre-cut + denominator is on the wire. A `rate_changed` row across two orders would compare + two different quantities. +- **Up (order `m` to order `n > m`)** needs sequences the order-`m` table never held. + +Any cross-order n-gram number would therefore be computed from what the documents do +not carry. The incomparability is confined to one block, so the rest of the diff is +computed as usual — the same block-grained precondition §12.1 applies to `behavior` +under composition. + +**Also stated rather than left implicit: the schema reaches only part of this.** It +closes the vocabulary, requires the reason and both orders, and refuses a document +that carries `ngram_delta` beside `incomparable_signals.ngram_delta` or names +`ngram_delta` in `withheld_signals` beside it. That the two orders equal the inputs' +and differ from each other relates the diff to its inputs, which no keyword can see, +and binds the producer. + +[`adr/0007-a-comparison-not-performed-is-stated.md`](adr/0007-a-comparison-not-performed-is-stated.md) +carries the decision and the alternatives it was chosen against. + ### 13.3 Direction and sign - `previous` is the **earlier** document; `current` is the **later** diff --git a/adr/0007-a-comparison-not-performed-is-stated.md b/adr/0007-a-comparison-not-performed-is-stated.md new file mode 100644 index 0000000..1046001 --- /dev/null +++ b/adr/0007-a-comparison-not-performed-is-stated.md @@ -0,0 +1,112 @@ +# ADR 0007 — A comparison not performed is stated, never left as an absence + +- **Status:** Proposed — pull request #21; accepted when the editor merges it +- **Date:** 2026-10-06 +- **Spec version affected:** 0.10.0 (unreleased at the time of writing) +- **Related:** SPEC §2.4 (the comparability gate and `behavior.ngram_size` in + `retention_profile`), §4 (`top_ngrams`, `probability`), §12.1 (the compose clause + for two orders), §13.1 (`ngram_delta` across two orders), §13.2 (the witness rule), + §13.2.1 (`x-metalog-vacuous`, clause 5: descriptors), §13.2.2 (`withheld_signals`), + §13.2.3 (the member this ADR decides), GOVERNANCE §2 + +## Context + +`behavior.ngram_size` fixes what a `top_ngrams` key denotes: an order-2 key and an +order-3 key are different objects. The same pull request adds the parameter to +`retention_profile` (§2.4), so two documents produced at different orders and both +stamped are refused by the comparability gate. But that gate is conditional: it binds +only when **both** inputs carry the identifier. An unstamped pair at two orders still +reaches §13. + +For that pair, a producer has three things it could do with `ngram_delta`, and the +text before this decision said nothing about any of them: + +- **Compute it.** No key can match across orders, so every n-gram of one side reads as + vanished and every n-gram of the other as new. Under §13.2 that non-empty delta is a + **witness**, and the document asserts `"changed"` for a change in configuration. +- **Omit it.** This is what the reference implementation does. Under §13.2.1 step 3 an + absent property is not a witness, so the document reads exactly like a comparison that + ran and found no n-gram movement. A reader cannot tell the two apart. +- **Reduce one order to the other** and compare at a common order. + +## Decision + +When both compared documents carry `behavior` at different `ngram_size`, a producer +**MUST** omit `ngram_delta` and **MUST** state the omission, with its reason, in a new +optional member at the `MetaLogDiff` root: + +```jsonc +"incomparable_signals": { + "ngram_delta": { "reason": "ngram_size_differs", "previous_ngram_size": 2, "current_ngram_size": 3 } +} +``` + +- The member is keyed by the omitted signal property's name; its value carries a + `reason` from a vocabulary the schema closes, and the evidence for that reason. +- It is a **descriptor** under §13.2.1 clause 5: never a witness. A comparison that was + not performed is evidence of neither outcome, so the member never makes `"changed"` + legal and never makes `"unchanged"` false. `"unchanged"` beside it means the + properties that *were* compared did not change, and a consumer **MUST NOT** read + either outcome as a statement about a property the member names. +- Every key is absent from the document and is not named in `withheld_signals`. The + schema states both with a root `dependentSchemas`. +- The rule binds whether or not the inputs are stamped. Stamped pairs are normally + refused by §2.4 first; this is what the residual pair receives. + +**No reduction between orders**, in either direction: + +- Down (order `n` to `m < n`): `top_ngrams` is truncated at `top_ngrams_size`, and + `dropped_ngram_observations` counts observations refused before counting. Neither + says which order-`m` key the missing mass would have fed, so marginal counts are + lower bounds of unknown slack and the reduced ranking can be wrong. The last `n − m` + order-`m` sequences of each stream have no order-`n` extension, so the sum + undercounts even on an untruncated table. +- `probability` is p(last | first n − 1), normalised before the cut (§4). The + conditional at one depth does not determine the conditional at another without the + weight of every longer prefix, and neither those weights nor the pre-cut denominator + is on the wire. +- Up (order `m` to `n > m`) needs sequences the order-`m` table never held. + +## Alternatives considered + +1. **Silent absence** (the reference implementation's behaviour before this decision). + Rejected: a false all-clear. The document is indistinguishable from one whose + comparison found no n-gram movement. +2. **Total turnover** (compute the delta anyway). Rejected: it witnesses `"changed"` for + a configuration difference, which is the defect the same pull request's §2.4 change + removes for stamped pairs. +3. **Decay: reduce the higher order to the lower, or carry the lower order up.** + Rejected: no reduction is exact on a truncated table, and `probability` is + conditioned at a different depth on each side (above). A reduced delta would present + an estimate of unknown error as a measurement. +4. **Reuse `withheld_signals` (§13.2.2).** Rejected: that member names a change the + producer **found** and did not serialise, and it is a witness by declaration. Naming + `ngram_delta` there would assert `"changed"` for a comparison nobody performed. + Spelling both under one word, with opposite witness semantics, would also invite + exactly that confusion, which is why the new member is not called `withheld_*`. +5. **Fail or refuse the whole diff.** Rejected: the incomparability is confined to one + block. Refusing the document would destroy a well-defined template, divergence and + tail comparison, the same reason §12.1 omits only `behavior` under composition. +6. **A free-text reason, or an n-gram-specific member** (for example a `reason` string + inside a stub `ngram_delta`). Rejected: a free-text reason cannot be checked or + branched on, and a stub `ngram_delta` must declare one `ngram_size` where two exist. + The keyed member with a closed vocabulary is the smallest shape that states a reason + a machine can read, and its vocabulary grows additively. + +## Consequences + +- One new optional member, a descriptor, in the witness set by §13.2.1 step 2. The + shipped schema now declares fourteen optional signal properties. +- A producer that omits `ngram_delta` across two orders without the member is + non-conformant; so is one that computes the delta across them. Both are breaking by + the letter under GOVERNANCE §2, and both land in 0.10.0, which has no tag and no + Release. +- The conformance tool gains three fixtures and one control + (`incomparable-signal-is-not-a-witness`). Two relations stay producer obligations + because no keyword can see the inputs: the two orders equal the inputs' and differ + from each other. +- **Not decided here, and named so nobody assumes it was:** a pair where only ONE + input carries `behavior`, and a `cube_diff` across unequal `axes` (§13.6), are absent + from the diff today with no statement either. Both have the same shape as this + decision's case, and the vocabulary can take them additively; neither is in this + text. diff --git a/conformance/README.md b/conformance/README.md index 768fa65..1633518 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -144,13 +144,13 @@ never looked. ## Why the self-test exists -A validator that cannot fail is decoration. `--selftest` runs thirty-one fixtures whose +A validator that cannot fail is decoration. `--selftest` runs thirty-five fixtures whose expected results are **hand-authored in `fixtures/manifest.json` from the spec and the schemas** — never captured from a run, because an expectation copied out of the tool under test makes the tool its own oracle, and the pair then agree forever while both are wrong. -Nineteen of those fixtures carry a `control` tag, naming **seventeen** distinct blindnesses +Twenty-one of those fixtures carry a `control` tag, naming **nineteen** distinct blindnesses (`instrument-failure` is carried by three), and the self-test **refuses to run** if any tag is missing from the manifest — deleting a fixture cannot quietly widen what a green covers. Each control forecloses one specific way this validator could @@ -165,12 +165,13 @@ have gone green while blind: | `instrument-failure` | A truncated corpus, a `--pointer` that does not resolve, and a pointer whose envelope changed shape underneath it must all exit 2. **There is no code path that skips a line or a file**: every non-blank line is either a section header or a document, anything else stops the run, and an unreachable pointer refuses rather than dropping the file. A parser bug cannot express itself as a smaller document count — only as a refusal to answer. Each of these fixtures also pins the *reason*, not only the number: exit 2 is a class, and an instrument refusing for a reason nobody intended would otherwise satisfy a fixture that reads the code alone. | | `pointer-array-every-element` | An envelope carries as many documents as its producer performed comparisons, and the shape it takes is a **list**. A pointer that resolves to one document judges the first and prints the same confident verdict over the rest — not a smaller check, a green covering a subject nobody chose. This fixture is a four-entry envelope whose violation sits at entry **2**, and its `/raw/0/diff` twin in the same manifest pins what the single-document reading does with those exact bytes: **exit 0, CONFORMANT**. The contrast is executable rather than remembered. | | `pointer-empty-selection` | The other way a corpus reaches zero, and the only one no exception-shaped guard can see: the pointer resolves perfectly onto an array that is **empty**. Every path taken is correct and the subject is nothing. Exit 2 — a verdict over zero documents is green for the one reason that matters, that it never looked. | -| `vacuity-is-declared-not-shaped` | §8 clause 6 asks whether a signal property carries a **finding**, and its JSON *shape* does not answer that. The fixture is a conformant `"unchanged"` diff carrying **eleven of the twelve** optional signal properties, every one present and vacuous by its own `x-metalog-vacuous` declaration. A **presence** reader finds eleven witnesses on a document that carries none — the exact clause 0.10.0 deleted. An **emptiness** reader finds three (`template_deltas` holds a row whose `delta` is `0`; `tail_delta` holds nine members and no array; `cube_diff.axes` is a required non-empty **descriptor**). A reader that assumed `const: 0` for every scalar finds a fourth, `stability_score: 1`. The twelfth property, `reservoir_delta`, is absent on purpose: §13.7 forbids emitting an all-empty block, so its declared vacuous state is unreachable by construction and writing it here would have made a "valid" fixture violate §13.7. | +| `vacuity-is-declared-not-shaped` | §8 clause 6 asks whether a signal property carries a **finding**, and its JSON *shape* does not answer that. The fixture is a conformant `"unchanged"` diff carrying **twelve of the fourteen** optional signal properties, every one present and vacuous by its own `x-metalog-vacuous` declaration. A **presence** reader finds twelve witnesses on a document that carries none — the exact clause 0.10.0 deleted. An **emptiness** reader finds three (`template_deltas` holds a row whose `delta` is `0`; `tail_delta` holds nine members and no array; `cube_diff.axes` is a required non-empty **descriptor**). A reader that assumed `const: 0` for every scalar finds a fourth, `stability_score: 1`. Two properties are absent on purpose. `reservoir_delta`: §13.7 forbids emitting an all-empty block, so its declared vacuous state is unreachable by construction and writing it here would have made a "valid" fixture violate §13.7. `incomparable_signals`: it is omitted when nothing was incomparable (§13.2.3), and its own control is the row below. | | `witness-rule-changed-arm` | `"changed"` obliges a witness. The fixture is the control above with **one token** changed, so the exit-code difference between the two is attributable to that token and nothing else. The finding names no member, because on this arm the defect *is* an absence. | | `witness-rule-unchanged-arm` | `"unchanged"` forbids one, and a rule that binds only the other arm is satisfied forever by a producer that always writes `"unchanged"`. The fixture is the same control with **one row** changed — `template_deltas[0]` from `delta: 0` to `delta: 3`, with `current_count` moved to match (§13.3) — so the witness sits inside an array that is non-empty and the same length in both documents, where a presence reader and an emptiness reader are blind to the mutation. | | `witness-set-from-schema` | §13.2.1 step 2 reads the witness set from the **schema**, never from the document. The fixture carries a bare `vendor_private_counter` at the diff root, which is open: legal-but-undescribed, exit 0, and **not** a witness. Read the set from the document instead and it becomes one — a producer could then manufacture a witness by inventing a member at an open root. Measured 2026-09-01: that mutation passed the other twenty-five fixtures **25/25**, so until this entry existed the sentence had no arm at all. | | `pointer-token-literal-in-object` | The extension must not swallow the standard it extends. `-` means *every element* where an **array** sits, because RFC 6901 gives that token no resolvable meaning there; where an **object** sits it is a literal member name and stays one. This fixture is an envelope carrying a member spelled `-` beside a second member, so a reading that wildcards the token everywhere judges two documents where one was addressed. | | `withheld-signal-is-a-witness` | §13.2.2 lets a `"changed"` diff name, in `withheld_signals`, a finding it computed and did not serialise. The fixture is `changed_without_witness` with **one array's content** changed — `withheld_signals` from `[]` to `["field_histogram_deltas"]` — and it is CONFORMANT while its twin is not. Judge `withheld_signals` by presence rather than by its declared vacuity and both twins flip, which turns the escape into a hole any producer can climb through. | +| `incomparable-signal-is-not-a-witness` | §13.2.3's `incomparable_signals` names a signal property the producer omitted because its two inputs cannot be compared on it — at v0.10.0, `ngram_delta` across two n-gram orders. It is declared a **descriptor**: a comparison not performed is evidence of neither outcome. The fixture is the diff of an unstamped cross-order pair that found nothing else, `"unchanged"` beside the member, and it is CONFORMANT; its twin, one token away, asserts `"changed"` on the member alone and is not. Declare the member witness-shaped, as `withheld_signals` is, and both twins flip — a configuration difference becomes a change. A third fixture carries the omitted `ngram_delta` beside the member, a clause-1 finding the schema states with `dependentSchemas`. | | `window-consistency-violation` | §2.2's relations between `window`'s members are unreachable from the schema, so every document here is **schema-valid** and wrong between its members: `duration_seconds` 300 over a 600-second window, `start` after `end`, an `end` with a legal non-UTC offset (also the withholding witness: with `end` unreadable, ordering and duration are not decided), and the §7 estimated-lines flag at `false`. | | `window-composed-envelope` | A composed document whose two raw children are **not contiguous** — 240 seconds of covered span inside a 600-second window. `duration_seconds` is `end - start`, 600; an arm that computed it from the children, or that treated the gap between shards as a defect, reds this conformant document. It is also the one positive witness that the estimated-lines flag admits `true`. | | `window-empty-extent` | §2.2 (v0.10.0): a window with **no line** (`lines_observed` 0) has no event time, so it carries **no envelope**. The fixture is one schema-valid document with `lines_observed` 0 that carries one anyway, whose `start` precedes its `end` by 600 seconds and whose `duration_seconds` is that 600, so ordering and duration both hold and this clause is the only thing that can catch it. Until v0.10.0 the clause read `start` equal to `end`. | diff --git a/conformance/fixtures/invalid/changed_on_incomparable_only.diff.json b/conformance/fixtures/invalid/changed_on_incomparable_only.diff.json new file mode 100644 index 0000000..c32d20e --- /dev/null +++ b/conformance/fixtures/invalid/changed_on_incomparable_only.diff.json @@ -0,0 +1,47 @@ +{ + "diff_version": "0.10.0", + "comparison_outcome": "changed", + "current": { + "window": { "start": "2026-01-01T00:01:00Z", "end": "2026-01-01T00:02:00Z" }, + "document_id": "window-2" + }, + "previous": { + "window": { "start": "2026-01-01T00:00:00Z", "end": "2026-01-01T00:01:00Z" }, + "document_id": "window-1" + }, + "kl_divergence": 0, + "js_divergence": 0, + "stability_score": 1, + "template_deltas": [ + { + "template_id": "h:0123456789abcdef0123456789abcdef", + "previous_count": 4, + "current_count": 4, + "delta": 0 + } + ], + "field_histogram_deltas": [], + "new_templates": [], + "vanished_templates": [], + "branching_delta": [], + "tail_delta": { + "previous_tail_template_count": 40, + "current_tail_template_count": 40, + "tail_template_count_delta": 0, + "previous_tail_entropy_bits": 4.0, + "current_tail_entropy_bits": 4.0, + "tail_entropy_bits_delta": 0.0, + "previous_tail_max_rate": 0.001, + "current_tail_max_rate": 0.001, + "tail_max_rate_delta": 0.0 + }, + "cube_diff": { + "axes": [ + { "name": "level", "kind": "categorical" } + ] + }, + "withheld_signals": [], + "incomparable_signals": { + "ngram_delta": { "reason": "ngram_size_differs", "previous_ngram_size": 2, "current_ngram_size": 3 } + } +} diff --git a/conformance/fixtures/invalid/cross_order_ngram_delta_beside_marker.diff.json b/conformance/fixtures/invalid/cross_order_ngram_delta_beside_marker.diff.json new file mode 100644 index 0000000..986f860 --- /dev/null +++ b/conformance/fixtures/invalid/cross_order_ngram_delta_beside_marker.diff.json @@ -0,0 +1,53 @@ +{ + "diff_version": "0.10.0", + "comparison_outcome": "unchanged", + "current": { + "window": { "start": "2026-01-01T00:01:00Z", "end": "2026-01-01T00:02:00Z" }, + "document_id": "window-2" + }, + "previous": { + "window": { "start": "2026-01-01T00:00:00Z", "end": "2026-01-01T00:01:00Z" }, + "document_id": "window-1" + }, + "kl_divergence": 0, + "js_divergence": 0, + "stability_score": 1, + "template_deltas": [ + { + "template_id": "h:0123456789abcdef0123456789abcdef", + "previous_count": 4, + "current_count": 4, + "delta": 0 + } + ], + "field_histogram_deltas": [], + "new_templates": [], + "vanished_templates": [], + "branching_delta": [], + "ngram_delta": { + "ngram_size": 3, + "new_ngrams": [], + "vanished_ngrams": [], + "rate_changed": [] + }, + "tail_delta": { + "previous_tail_template_count": 40, + "current_tail_template_count": 40, + "tail_template_count_delta": 0, + "previous_tail_entropy_bits": 4.0, + "current_tail_entropy_bits": 4.0, + "tail_entropy_bits_delta": 0.0, + "previous_tail_max_rate": 0.001, + "current_tail_max_rate": 0.001, + "tail_max_rate_delta": 0.0 + }, + "cube_diff": { + "axes": [ + { "name": "level", "kind": "categorical" } + ] + }, + "withheld_signals": [], + "incomparable_signals": { + "ngram_delta": { "reason": "ngram_size_differs", "previous_ngram_size": 2, "current_ngram_size": 3 } + } +} diff --git a/conformance/fixtures/manifest.json b/conformance/fixtures/manifest.json index b4ef385..0f9c99f 100644 --- a/conformance/fixtures/manifest.json +++ b/conformance/fixtures/manifest.json @@ -392,6 +392,56 @@ "undescribed": [] } }, + { + "path": "valid/cross_order_ngram_incomparable.diff.json", + "kind": "diff", + "control": "incomparable-signal-is-not-a-witness", + "why": "the diff of an UNSTAMPED pair at two n-gram orders (previous at ngram_size 2, current at 3) whose comparisons found nothing else: the same bytes as valid/unchanged_all_vacuous.diff.json with `ngram_delta` removed and `incomparable_signals.ngram_delta` added, reason `ngram_size_differs`, both orders stated. SPEC §13.1 obliges the producer to omit the delta and say so, and §13.2.3 declares the statement a DESCRIPTOR: a comparison not performed is evidence of neither outcome, so `unchanged` stands beside it and the document is CONFORMANT. Give the member a witness-shaped declaration (`maxProperties: 0`, the way `withheld_signals` declares `maxItems: 0`) and this fixture reds as `unchanged` carrying a witness while its twin invalid/changed_on_incomparable_only.diff.json turns green: a configuration difference would then be a change, which is the total-turnover witness §13.1 exists to stop, reached by a second door.", + "expect": { + "exit": 0, + "documents": 1, + "findings": [], + "undescribed": [] + } + }, + { + "path": "invalid/changed_on_incomparable_only.diff.json", + "kind": "diff", + "why": "the twin of valid/cross_order_ngram_incomparable.diff.json with ONE token changed, `comparison_outcome` from `unchanged` to `changed`. Every signal property it holds is vacuous and `incomparable_signals` is a descriptor, so the document asserts a change it carries no witness for — the shape of a producer that reads `ngram_size_differs` as a finding. SPEC §13.2.3: the member never makes `changed` legal. The finding names no member, as on every `changed`-arm violation: the defect is an absence.", + "expect": { + "exit": 1, + "documents": 1, + "findings": [], + "witness_violations": [ + [ + "changed", + [], + 1 + ] + ], + "undescribed": [] + } + }, + { + "path": "invalid/cross_order_ngram_delta_beside_marker.diff.json", + "kind": "diff", + "why": "the twin of valid/cross_order_ngram_incomparable.diff.json with the omitted delta carried anyway: an `ngram_delta` at `ngram_size` 3 (one side's order) beside `incomparable_signals.ngram_delta`. SPEC §13.2.3 requires every key of the member to be absent from the document, and the schema states it at the root with `dependentSchemas`, so this is a clause-1 finding at `` on the `not` keyword, and the witness rule is withheld from the document. Without that keyword a producer could state the incomparability and serialise a delta computed across it, and a consumer would read both.", + "expect": { + "exit": 1, + "witness_unjudged": 1, + "documents": 1, + "findings": [ + [ + "", + "not", + [], + 1, + 1 + ] + ], + "undescribed": [] + } + }, { "path": "invalid/unchanged_with_witness.diff.json", "kind": "diff", diff --git a/conformance/fixtures/valid/cross_order_ngram_incomparable.diff.json b/conformance/fixtures/valid/cross_order_ngram_incomparable.diff.json new file mode 100644 index 0000000..ec2a4f2 --- /dev/null +++ b/conformance/fixtures/valid/cross_order_ngram_incomparable.diff.json @@ -0,0 +1,47 @@ +{ + "diff_version": "0.10.0", + "comparison_outcome": "unchanged", + "current": { + "window": { "start": "2026-01-01T00:01:00Z", "end": "2026-01-01T00:02:00Z" }, + "document_id": "window-2" + }, + "previous": { + "window": { "start": "2026-01-01T00:00:00Z", "end": "2026-01-01T00:01:00Z" }, + "document_id": "window-1" + }, + "kl_divergence": 0, + "js_divergence": 0, + "stability_score": 1, + "template_deltas": [ + { + "template_id": "h:0123456789abcdef0123456789abcdef", + "previous_count": 4, + "current_count": 4, + "delta": 0 + } + ], + "field_histogram_deltas": [], + "new_templates": [], + "vanished_templates": [], + "branching_delta": [], + "tail_delta": { + "previous_tail_template_count": 40, + "current_tail_template_count": 40, + "tail_template_count_delta": 0, + "previous_tail_entropy_bits": 4.0, + "current_tail_entropy_bits": 4.0, + "tail_entropy_bits_delta": 0.0, + "previous_tail_max_rate": 0.001, + "current_tail_max_rate": 0.001, + "tail_max_rate_delta": 0.0 + }, + "cube_diff": { + "axes": [ + { "name": "level", "kind": "categorical" } + ] + }, + "withheld_signals": [], + "incomparable_signals": { + "ngram_delta": { "reason": "ngram_size_differs", "previous_ngram_size": 2, "current_ngram_size": 3 } + } +} diff --git a/conformance/metalog_validate.py b/conformance/metalog_validate.py index e40848d..449dd20 100644 --- a/conformance/metalog_validate.py +++ b/conformance/metalog_validate.py @@ -1543,6 +1543,10 @@ def census_control() -> None: # and a reader that judged it by presence would # pass the empty array the family's other three # documents all carry + "incomparable-signal-is-not-a-witness", # forecloses the §13.2.3 member read + # as a finding: a comparison NOT performed + # would then witness `changed`, and a config + # difference would read as a change } diff --git a/schema/metalog_diff.v0.schema.json b/schema/metalog_diff.v0.schema.json index 1be7be3..e6717f7 100644 --- a/schema/metalog_diff.v0.schema.json +++ b/schema/metalog_diff.v0.schema.json @@ -379,11 +379,42 @@ "description": "The signal properties in which THIS comparison found a change that this document does not carry (SPEC §13.2.2). It is the witness of last resort: a producer that finds a change only in a property it does not serialise has, without this member, no conformant outcome -- `changed` would carry no witness and `unchanged` would be false. Every member MUST name a property of the witness set (§13.2.1 step 2), MUST NOT name `withheld_signals` itself, and MUST name a property that is NOT a witness in this document; the array MUST be sorted ascending and carries no duplicates. The membership and ordering MUSTs are §13.2.2's: an enum here would be the hand-kept list §13.2 deleted, and sortedness is not a keyword. Added in v0.10.0.", "items": { "type": "string", "minLength": 1 } }, + "incomparable_signals": { + "x-metalog-vacuous": { + "description": "A DESCRIPTOR, never a witness (SPEC 13.2.3): it names a comparison this document did NOT perform, which is evidence neither of a change nor of its absence. It makes no `changed` legal and no `unchanged` false." + }, + "type": "object", + "minProperties": 1, + "description": "The signal properties this document omits because its two inputs cannot be compared on them, each with its reason (SPEC §13.2.3). Keyed by the omitted property's name; the vocabulary of keys and reasons is CLOSED here, and adding one is additive under GOVERNANCE.md §2. Every key MUST be absent from the document and MUST NOT be named in withheld_signals. Omitted when nothing was incomparable. Added in v0.10.0.", + "properties": { + "ngram_delta": { + "type": "object", + "description": "ngram_delta is omitted because both inputs carry behavior at DIFFERENT ngram_size (SPEC §13.1, §13.2.3): an order-m key and an order-n key denote different objects, and no reduction of one order to the other is exact on a truncated top_ngrams table. The two orders MUST equal the inputs' behavior.ngram_size and so differ -- a relation to the inputs no keyword can see.", + "required": ["reason", "previous_ngram_size", "current_ngram_size"], + "properties": { + "reason": { "type": "string", "enum": ["ngram_size_differs"] }, + "previous_ngram_size": { "type": "integer", "minimum": 2 }, + "current_ngram_size": { "type": "integer", "minimum": 2 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "extensions": { "description": "Document-level vendor-specific data on a MetaLogDiff (SPEC §7). This placement was granted in v0.9.0. The root itself is open — its `additionalProperties` declares the openness and constrains nothing — so a bare vendor member written beside this container validates while violating §7: the container is where such data belongs, and at an open root the schema does not tell the two apart.", "$ref": "#/$defs/extensions" } }, + "dependentSchemas": { + "incomparable_signals": { + "description": "A property named in incomparable_signals is absent, and is not named in withheld_signals (SPEC §13.2.3). Written over the member's presence rather than per key because ngram_delta is the one key the vocabulary admits and minProperties is 1, so presence IS that key; a second key restates this per key.", + "not": { "required": ["ngram_delta"] }, + "properties": { + "withheld_signals": { "not": { "contains": { "const": "ngram_delta" } } } + } + } + }, "$defs": { "reservoir_delta_entry": { "type": "object",