Description
Owns internal/inventory/diff.go and the corpus schema fields the rule needs. Removes the three verified false-positive classes recorded on the parent without weakening the comparator anywhere a real divergence lives.
Three rules to implement, each with the exception preserved:
Unobserved instrument type. A corpus metric whose instrument_types is exactly the unknown sentinel carries no evidence about instrument shape. Comparing it must yield a coverage gap and a PENDING, never a contradiction. A corpus entry that DOES record a real instrument type must still contradict when synth disagrees — this rule must not become a blanket instrument-comparison bypass, which would silently hide the single defect class SKT-0006 was created to catch.
Read-path enrichment labels. asserts_env, asserts_site, service and __aggregation__ appear on gcx read-back entries because Grafana Cloud adds them after ingest. They are properties of the read path, not of collector egress, so they must not count as labels synthkit is missing. Declare them in the corpus source block with provenance (which producer sees them and why) rather than hard-coding a list in the comparator, so a future producer that does not add them is not silently governed by another producers quirk. The reverse direction is untouched: a label key present in synth and absent from reality is still a contradiction, because that is the never-invent-a-name rule.
Label-value subset. A capture from one account in one region observes a subset of the value space synthkit deliberately models. synth ⊇ reality is correct and must be silent. A contradiction is only reality ⊄ synth — reality carrying a value synthkit cannot produce. Where the corpus already marks a label values_elided: true there is no value evidence at all and no value comparison should run.
The distinction between the Contradictions and Coverage-gaps sections of the report is the load-bearing output: after this task the Contradictions section must contain only findings a maintainer would act on.
Acceptance Criteria
- #1 A corpus instrument_types of exactly the unknown sentinel yields a coverage gap and a PENDING stub, never a contradiction
- #2 A corpus entry recording a real instrument type still produces a contradiction when synth disagrees, proven by a test
- #3 Enrichment label keys are declared per corpus source with provenance, not hard-coded in the comparator
- #4 A label key present in synth and absent from reality is still reported as a contradiction
- #5 Label values compare as a subset: synth covering more values than reality observed is silent; reality carrying a value synth cannot emit is a contradiction
- #6 A label marked values_elided runs no value comparison at all
- #7 Running make signal-fidelity afterwards leaves a Contradictions section containing only findings a maintainer would act on, and that count is recorded in the task notes
- #8 docs/reality-corpus.md states the evidence rules so a future corpus producer knows what its output means
Definition of Done
- #1 make gate (build vet test race rw-proto-check spdx-check forbidden-words)
- #2 make blueprint-schema (only if a blueprint field or construct/workload config struct changed)
- #3 DRY_RUN=true go run ./cmd/synthkit -once -dump — inventory diffed against signals/
Implementation Notes
DELIVERED 2026-08-27 (lane L1). internal/inventory/{diff.go,corpus.go} + tests, docs/reality-corpus.md, and enrichment-label declarations in the two eks-live-readback corpus source blocks.
Results, measured A/B against one fixed synth inventory (the first baseline was contaminated by lane L3 landing mid-run)
TOTAL 1845 -> 1049. Contradictions 866 -> 301. Coverage gaps 979 -> 748.
- Contradictions/instrument_mismatch 298 -> 0
- Contradictions/label_value_contradiction 293 -> 26
- Contradictions/unexpected_label_key 275 -> 275 (untouched by design; see below)
- Coverage gaps/instrument_mismatch 298, each carrying its PENDING stub — exactly what SKT-0010.02 turns into real verdicts.
The three rules, and the exceptions that make them safe
- Unobserved instrument type: gated on BOTH sides carrying evidence, and only when the set is EXACTLY the unknown sentinel. A set mixing a real type with the sentinel still contradicts. Deliberately symmetric, so a future synth-side sentinel is covered by the same rule.
- Enrichment labels: declared per corpus source with provenance, stripped from the REALITY side only. The synth-to-reality direction is untouched, so a key synthkit emits that reality lacks is still a contradiction — that is the never-invent-a-name rule and it stays sharp.
- Label values: synth superset silent, reality-not-subset contradicts, and a values_elided or empty set runs no comparison at all.
The merge property matters more than it looks: CanonicalMerge unions declarations by key with existing provenance winning, so a producer re-run that omits the block never drops it. That is what let lane L2 regenerate the corpus concurrently without needing L1 files.
What the lane correctly refused to decide
Two classes, 223 of the 301 remaining contradictions, both outside the three rules it was given. It returned the questions rather than inventing answers, which was the right call.
- 215 rows whose only synth-only key is
blueprint. cwinfra is registered ScopeBlueprint on purpose so every CW series carries it and reality never can. ROOT DECISION: exempt the selector in the comparator. It is synthkit own routing label documented in ARCHITECTURE, not an invented vendor name, so the never-invent rule does not apply. Re-scoping cwinfra to ScopeSubstrate was rejected as an architecture change in the wrong direction. - 8 rows carrying the literal value
<aggregated>on kubeproxy_sync_proxy_rules_*. Same Adaptive Metrics mechanism as the aggregation KEY, expressed as a VALUE, so the key-stripping declaration does not reach it. ROOT DECISION: extend the declaration to cover values with the same provenance discipline.
Both routed to lane L12.
Genuine findings surfaced that were not the task
- THE
leFLOAT RENDERING DIVERGENCE, 7 contradictions and a real defect either way. Reality renders integral bucket bounds as le=“1.0”/“10.0”/“0.0”; synth emits le=“1”/“10”/“0”. Non-integral bounds match exactly, so this is an OpenMetrics rendering convention, not a naive formatter. Anle="1"selector silently matches nothing against real data. Unresolved whether the emitter or the capture receiver is wrong — routed to L12 to settle with spec evidence. - The synth inventory export carries no provenance.substrate, so CompareCorpus substrate filter never engages and EKS-modelled synth is compared against k3s reality. That is what turns the kubernetes_build_info version skew into 5 contradictions. Routed to L12.
- An EMPTY instrument_types array is equally absent evidence but still contradicts; the lane deliberately restricted the rule to exactly the sentinel as specified. No producer emits it today.
Of the 26 surviving label_value_contradictions, 11 are genuinely actionable fixture gaps (reality runs Graviton instance types synth does not model; created_by_kind AutoscalingListener/EphemeralRunner; host_network=true; kubelet operation_type values) and 7 are the le rendering class.
CONTINUATION DELIVERED 2026-08-27 (lane L12): the two root decisions applied, plus the le question resolved and a cross-lane breakage fixed.
Contradictions 304 -> 89 on L12 measured baseline, and -> 81 once the enrichment-values JSON landed (applied by the wiring pass). Note L1 reported 1049/301 but that is NOT reproducible: lane L2 was regenerating the corpus and lane L3 had emitter edits in the tree, so both sides moved. L12 measured its own before/after on one tree minutes apart, which is the honest comparison.
ITEM 1 — blueprint selector exempted, declaration-driven. Provenance gains SelectorLabels, stamped by a new cmd/synthkit/inventory.go from the RUNNER CONSTANT rather than the literal string, so a rename cannot silently reopen it. internal/inventory cannot import internal/runner (import cycle via the otlp sink), so the composition root is the only correct home. Exactly -215 rows, all blueprint-only. Every row carrying a real invented dimension survives, now with blueprint stripped from the reported synth set so the line reads cleanly. A test proves it is declaration-driven, not a hard-coded key.
ITEM 2 — enrichment VALUES, and the semantics are deliberately not a blanket widening. No values means key-scoped (whole key dropped, L1 behaviour). WITH values means value-scoped: the key STAYS and still compares as a key, and only the listed values are removed before value comparison. So declaring ip_family cannot silently stop the comparator noticing that synthkit never emits ip_family. Merge unions values but a key-scoped declaration on either side stays key-scoped, since that is the broader claim and merging must never narrow it. -8 exactly.
ITEM 3 — RETURNED AS A QUESTION rather than shipped, with numbers, and the root accepted the lane position. Declaring provenance.substrate = eks WOULD remove 38 contradictions, but the filter is document-level so it drops both k3d documents whole: 31 of the 38 are REAL — 24 invented-dimension rows on coredns, the 7 le rows — and the entire signals/k8s-addons.md area goes dark along with the two OTLP-logs coverage-gap findings. Net-negative. The synth export now carries provenance with Substrate deliberately EMPTY, documented and pinned by a test so nobody sets it by accident. Correct diagnosis: the five kubernetes_build_info rows are capture-instance identity, not a substrate problem, and the gcx producer ALREADY elides those values while the k3d lab does not. Tracked as SKT-0010.12.
ITEM 4 — RESOLVED, and synthkit is the wrong side. Prometheus v3 normalises le to a float-like representation on ingestion; Alloy 1.x embeds v3 scrape, so everything it scrapes reaches Mimir dot-zero. Corroborated from the corpus: 154 le values, ZERO bare integers; non-integral bounds match exactly while only integral ones diverge; bucket_bounds produce zero mismatches, so only the label STRING differs. Tracked as SKT-0010.11 — roughly 120 LEBare call sites across 21 files, so the 7 findings are only the visible subset.
CROSS-LANE BREAKAGE FIXED: make signal-fidelity was exiting 1 on the tree because LoadCorpusDir recursively parsed every .json under the corpus root as a corpus document, and SKT-0010.04 had just written reality-corpus/verdicts/. The walk now prunes subdirectories that are not signals areas. That location is load-safe — do not “fix” it by moving the verdict file.
Report readability: every finding line now leads with the one-sided difference that produced it, then both full sets. Without it a maintainer diffs two 15-element sets by eye on every labels row.
Each new rule was mutation-tested back to prove its tests fail for the right reason.
Final Summary
The fidelity report is readable. 1806 findings and 866 contradictions became 659 and 81, and every surviving contradiction is one a maintainer would act on — enumerated group by group in the notes with a verdict each.
Three evidence rules, each with its exception deliberately preserved. An instrument_types of exactly the unknown sentinel is absent evidence and becomes a coverage gap with a PENDING, while an entry recording a real type still contradicts. Read-path enrichment labels are declared per corpus source with provenance rather than hard-coded, stripped from the reality side only — a key synthkit emits that reality lacks still contradicts, which is the never-invent-a-name rule. Label values compare as a subset, so a single-account capture no longer fires 293 false positives.
Two further decisions were returned by the lane rather than invented, and taken by the root. synthkit own blueprint selector is exempted, sourced from the runner constant so a rename cannot silently reopen it — 215 rows, all of which reality can never carry by design. The Adaptive Metrics
A third was measured and REFUSED: declaring the synth substrate would have removed 38 contradictions but 31 of them are real, taking the whole k8s-addons area dark. The synth export now carries provenance with substrate deliberately empty, pinned by a test.
The le question resolved against synthkit: Prometheus v3 normalises le on ingestion, the corpus carries 154 le values and zero bare integers, and an le=“1” selector matches nothing against real data. Tracked as SKT-0010.11 — roughly 120 call sites, so the 7 findings were the visible subset.
Also fixed a live breakage: the corpus loader parsed every .json under the root as a corpus document, so a sibling lane new verdicts directory broke make signal-fidelity outright. Each new rule was mutation-tested back to prove its tests fail for the right reason. make gate green.