Files
chemenu/tools/chemenu/corpus_diff.py
T
torben 54d9540c08
CI / verify (push) Successful in 56s
Release / release (push) Successful in 36s
stack: Konfidenz-Mechanismus ersatzlos entfernt, Korpus migriert (schliesst #60, #86)
Files changed:
- .wikitool-kb.json
- AGENTS.md
- CHANGES.md
- INSTALL-MCP.md
- INSTALL.md
- README.md
- VERSION
- instructions/capture-session.md
- instructions/dev/issue-tracking.md
- instructions/german-terminology.md
- instructions/kb-profiles.md
- instructions/migrate-corpus.md
- instructions/migrations/5.0.0-confidence-removal.md
- instructions/private-instance.md
- instructions/setup-instance.md
- instructions/wiki-lint/SKILL.md
- instructions/wiki-manage/SKILL.md
- instructions/wiki-query/SKILL.md
- kb/CONTRACT.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- kb/concepts/architectures/Consolidation Tiers.md
- kb/concepts/architectures/Context Isolation.md
- kb/concepts/architectures/Cross-platform Agent Skills.md
- kb/concepts/architectures/Episodic Memory.md
- kb/concepts/architectures/Hybrid Search.md
- kb/concepts/architectures/Implementation Spectrum.md
- kb/concepts/architectures/Knowledge Graph.md
- kb/concepts/architectures/LLM Wiki Pattern.md
- kb/concepts/architectures/MCP-Leseserver.md
- kb/concepts/architectures/Memory Lifecycle.md
- kb/concepts/architectures/OKF Compatibility.md
- kb/concepts/architectures/Optional Instance Context File.md
- kb/concepts/architectures/Personalization Plane.md
- kb/concepts/architectures/Procedural Memory.md
- kb/concepts/architectures/RAG.md
- kb/concepts/architectures/Scale Ceiling.md
- kb/concepts/architectures/Semantic Memory.md
- kb/concepts/architectures/Three-Layer Architecture.md
- kb/concepts/architectures/Token Economics.md
- kb/concepts/architectures/Working Memory.md
- kb/concepts/decisions/Delete Rather Than Anonymize.md
- kb/concepts/decisions/Denylist over Allowlist.md
- kb/concepts/decisions/Diff-Reviewable Agent Edits.md
- kb/concepts/decisions/Dual Licensing by File Plan.md
- kb/concepts/decisions/Issue Label Scheme.md
- kb/concepts/decisions/KB Stack Versioning.md
- kb/concepts/decisions/Structural Enforcement over Documented Rule.md
- kb/concepts/patterns/Audit Trail.md
- kb/concepts/patterns/BM25.md
- kb/concepts/patterns/Command Round-Trip Integrity.md
- kb/concepts/patterns/Confidence Scoring.md
- kb/concepts/patterns/Contradiction Resolution.md
- kb/concepts/patterns/Entity Extraction.md
- kb/concepts/patterns/Filter on Ingest.md
- kb/concepts/patterns/Forgetting.md
- kb/concepts/patterns/Graph Traversal.md
- kb/concepts/patterns/Mesh Sync.md
- kb/concepts/patterns/Quality Scoring.md
- kb/concepts/patterns/Reciprocal Rank Fusion.md
- kb/concepts/patterns/Self-Healing.md
- kb/concepts/patterns/Shared vs Private.md
- kb/concepts/patterns/Typed Relationships.md
- kb/concepts/patterns/Vector Search.md
- kb/concepts/patterns/Work Coordination.md
- kb/concepts/problems/Ambient Environment Dependency.md
- kb/concepts/problems/Detect-Repair Asymmetry.md
- kb/concepts/problems/Green Suite Blind Spot.md
- kb/concepts/problems/Naming Convention Conflict.md
- kb/concepts/problems/Write-Once Frontmatter Fields.md
- kb/concepts/protocols/CPPC.md
- kb/concepts/protocols/Modbus.md
- kb/concepts/protocols/SSD TRIM.md
- kb/concepts/workflows/Anti-Cramming Heuristic.md
- kb/concepts/workflows/Bulk Operations.md
- kb/concepts/workflows/CI Integration.md
- kb/concepts/workflows/Checkpoint Audit.md
- kb/concepts/workflows/Claude Code Auto Mode.md
- kb/concepts/workflows/Content Quality Control.md
- kb/concepts/workflows/Crystallization.md
- kb/concepts/workflows/Event-Driven Automation.md
- kb/concepts/workflows/Hooks.md
- kb/concepts/workflows/Index Scaling.md
- kb/concepts/workflows/Iteration and Cost Limits.md
- kb/concepts/workflows/KB Migration.md
- kb/concepts/workflows/Knowledge Compounding.md
- kb/concepts/workflows/Lint Workflow.md
- kb/concepts/workflows/Mass-Update Gate.md
- kb/concepts/workflows/Multi-Agent Collaboration.md
- kb/concepts/workflows/Privacy and Governance.md
- kb/concepts/workflows/Publish-Remote Gate.md
- kb/concepts/workflows/Quality and Self-Correction.md
- kb/concepts/workflows/Semantic Lint Automation.md
- kb/concepts/workflows/Session Orientation.md
- kb/concepts/workflows/Split Merge Reclassify.md
- kb/concepts/workflows/Split Threshold.md
- kb/concepts/workflows/Stub Threshold.md
- kb/concepts/workflows/Supersession.md
- kb/concepts/workflows/User Management.md
- kb/concepts/workflows/Workflow Extraction.md
- kb/concepts/workflows/Workflow Orchestration.md
- kb/entities/people/Andrej Karpathy.md
- kb/entities/people/E3DC GmbH.md
- kb/entities/people/Rohit Gupta.md
- kb/entities/people/Vannevar Bush.md
- kb/entities/projects/BCDModule.md
- kb/entities/projects/Chemenu.md
- kb/entities/projects/andybalholm-edl.md
- kb/entities/projects/goresponsiveness.md
- kb/entities/projects/ha-core.md
- kb/entities/projects/hacs-e3dc.md
- kb/entities/projects/hacs-integration-blueprint.md
- kb/entities/projects/llm-wiki-skills.md
- kb/entities/projects/plugnburn-edl.md
- kb/entities/projects/wiki-skills-vanillaflava.md
- kb/entities/projects/wiki-skills.md
- kb/entities/systems/AGENTS.md.md
- kb/entities/systems/CLAUDE.md.md
- kb/entities/systems/E3DC.md
- kb/entities/systems/ENVIRONMENT.md.md
- kb/entities/systems/Memex.md
- kb/entities/systems/Tolkien Gateway.md
- kb/entities/technologies/Arch Linux.md
- kb/entities/technologies/Disk Encryption.md
- kb/entities/technologies/Docker.md
- kb/entities/technologies/GRUB.md
- kb/entities/technologies/Gitea Actions.md
- kb/entities/technologies/Gitea.md
- kb/entities/technologies/Go.md
- kb/entities/technologies/Home Assistant.md
- kb/entities/technologies/Kernel PM Governors.md
- kb/entities/technologies/LVM.md
- kb/entities/technologies/Linux Kernel.md
- kb/entities/technologies/MQTT.md
- kb/entities/technologies/OPC UA.md
- kb/entities/technologies/Python.md
- kb/entities/technologies/Rust.md
- kb/entities/technologies/Wine GE.md
- kb/entities/technologies/Wine-Staging.md
- kb/entities/technologies/acpi-cpufreq.md
- kb/entities/technologies/amd-pstate.md
- kb/entities/technologies/iii Engine.md
- kb/entities/tools/AUR.md
- kb/entities/tools/Act Runner.md
- kb/entities/tools/Agent Memory.md
- kb/entities/tools/Aura.md
- kb/entities/tools/Bottles.md
- kb/entities/tools/ChatGPT.md
- kb/entities/tools/Claude Code.md
- kb/entities/tools/Codex CLI.md
- kb/entities/tools/Dataview.md
- kb/entities/tools/GPG.md
- kb/entities/tools/GitHub Copilot.md
- kb/entities/tools/Gitea MCP Server.md
- kb/entities/tools/Lutris.md
- kb/entities/tools/Marp.md
- kb/entities/tools/Mistral Vibe.md
- kb/entities/tools/NotebookLM.md
- kb/entities/tools/Obsidian Web Clipper.md
- kb/entities/tools/Obsidian.md
- kb/entities/tools/OpenAI Codex.md
- kb/entities/tools/OpenCode.md
- kb/entities/tools/Pi.md
- kb/entities/tools/Proton.md
- kb/entities/tools/Steam.md
- kb/entities/tools/Wine.md
- kb/entities/tools/awesome-llm-wiki.md
- kb/entities/tools/farzaa gist.md
- kb/entities/tools/gdeploy.md
- kb/entities/tools/makepkg.md
- kb/entities/tools/pascalandy schema.md
- kb/entities/tools/qmd.md
- kb/entities/tools/wikitool.md
- kb/index.md
- kb/log.md
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/api.py
- tools/chemenu/cli.py
- tools/chemenu/commands/confidence_decay.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/index_build.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/search.py
- tools/chemenu/commands/touch.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/conventions.py
- tools/chemenu/corpus_diff.py
- tools/chemenu/frontmatter_io.py
- tools/chemenu/lint_core.py
- tools/chemenu/mcp/server.py
- tools/chemenu/page.py
- tools/chemenu/search/base.py
- tools/chemenu/search/filters.py
- tools/chemenu/search/ripgrep.py
- tools/chemenu/search/service.py
- tools/chemenu/search/types.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_api.py
- tools/chemenu/tests/test_confidence_decay.py
- tools/chemenu/tests/test_corpus_diff.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_frontmatter_io.py
- tools/chemenu/tests/test_index_build.py
- tools/chemenu/tests/test_kb_scan.py
- tools/chemenu/tests/test_lint.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_page_ops.py
- tools/chemenu/tests/test_provenance.py
- tools/chemenu/tests/test_raw_cmd.py
- tools/chemenu/tests/test_search.py
- tools/chemenu/tests/test_touch.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/tests/test_xref.py
- tools/chemenu/version.py
- types/concept.md
- types/concept.schema.yaml
- types/entity.md
- types/entity.schema.yaml
- types/instruction.md
- types/type-spec.md
2026-09-10 19:51:48 +02:00

262 lines
10 KiB
Python

"""Compare two revisions of `kb/` on the invariants a content migration must
not change.
**Why this is not `lint`.** `lint` asks whether the corpus is currently
consistent: does every reference resolve, is every page schema-valid. It reads
one revision and cannot, even in principle, notice that something *went
missing* - a page that used to cite a source and no longer does is perfectly
consistent. That is the failure mode of a bulk rewrite, and it needs a
comparison against where the corpus came from.
The check is modelled on the one the German translation ran by hand across 248
pages. It found four defects: a dropped citation that silently unsourced a
claim, a dropped wikilink, an invented one, and a translated H1. **Three of the
four had unchanged link/cite *sets* and only changed counts**, which is why
every multiset here is a `Counter` and never a `set` - and why
`kb_scan.extract_wikilinks` (a set, correct for `lint`) must not be used.
What is deliberately *not* compared: the prose. A migration is expected to
rewrite bodies; flagging that would make the tool useless. Only the structural
skeleton is held fixed - plus one bit in the opposite direction, `body_changed`,
so a unit that silently did nothing is visible too.
"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
from typing import Any, Optional
from chemenu import blocks, kb_scan, provenance
from chemenu.page import Page
from chemenu.type_resolver import resolver
# Frontmatter fields compared by value on every page. Page-reference arrays are
# added per page from the type-spec's own `page_ref_fields:`, so a new type
# needs no change here.
#
# `modified:` and `summary:` are deliberately absent: a migration is supposed to
# bump the one and rewrite the other. `date:` is present because it is the raw
# material's publication date, which nothing may move (see `touch`).
STRUCTURAL_FIELDS = (
"type",
"created",
"date",
"provenance",
"source_type",
"source_language",
"raw_files",
)
@dataclass(frozen=True)
class PageShape:
"""Everything about a page that a content migration must preserve."""
title: str
path: str
h1: Optional[str]
wikilinks: Counter
cite_refs: Counter
cite_defs: dict[str, str]
fields: dict[str, Any]
markers: dict[str, int]
body: str
@classmethod
def of(cls, page: Page) -> "PageShape":
# Cite references are counted on the body *without* the footnote block:
# a definition line contains its own `[^id]`, so counting the raw body
# would double every citation and mask a dropped one. This mirrors what
# every other caller of CITE_REF_RE does (see provenance.py).
head, definitions = provenance.split_cite_block(page.body)
fields = {name: page.frontmatter.get(name) for name in STRUCTURAL_FIELDS}
for name in _page_ref_fields(page):
fields[name] = page.frontmatter.get(name)
subtype_field = _subtype_field(page)
if subtype_field:
fields[subtype_field] = page.frontmatter.get(subtype_field)
return cls(
title=page.title,
path=str(page.path),
h1=page.h1_title,
wikilinks=kb_scan.count_wikilinks(head),
cite_refs=Counter(m.group(1) for m in provenance.CITE_REF_RE.finditer(head)),
cite_defs={cite_id: source for cite_id, (source, _) in definitions.items()},
fields=fields,
markers=blocks.marker_pairs(page.body),
body=page.body,
)
def _page_ref_fields(page: Page) -> list[str]:
raw = page.frontmatter.get("type")
if not raw:
return []
try:
return resolver.get_page_ref_fields(raw, page.path)
except (ValueError, KeyError):
return []
def _subtype_field(page: Page) -> Optional[str]:
raw = page.frontmatter.get("type")
if not raw:
return None
try:
return resolver.get_subtype_field(raw, page.path)
except (ValueError, KeyError):
return None
@dataclass
class PageFinding:
path: str # the page's title (`compare()`'s key), kept named `path` for JSON stability
kind: str # "h1" | "wikilinks" | "cite-refs" | "cite-defs" | "frontmatter" | "unchanged"
detail: str
def __str__(self) -> str: # noqa: D105 - report line
return f"{self.path}: {self.kind} - {self.detail}"
@dataclass
class CorpusDiff:
findings: list[PageFinding] = field(default_factory=list)
added: list[str] = field(default_factory=list)
removed: list[str] = field(default_factory=list)
moved: list[tuple[str, str, str]] = field(default_factory=list)
compared: int = 0
@property
def ok(self) -> bool:
"""Added and removed pages are reported but are not failures: creating
or retiring a page is a legitimate thing for a migration to do, and
`lint` already checks that nothing dangles afterwards. A changed
invariant on a page that exists in both revisions is the failure."""
return not self.findings
def _counter_delta(before: Counter, after: Counter) -> str:
"""A readable description of how two multisets differ, counts included."""
parts = []
for key in sorted(set(before) | set(after)):
was, now = before.get(key, 0), after.get(key, 0)
if was != now:
parts.append(f"{key!r} {was}->{now}")
return ", ".join(parts)
def compare_page(title: str, before: PageShape, after: PageShape) -> list[PageFinding]:
findings: list[PageFinding] = []
if before.h1 != after.h1:
findings.append(
PageFinding(title, "h1", f"{before.h1!r} -> {after.h1!r} (the title is the page's only identifier)")
)
if before.wikilinks != after.wikilinks:
findings.append(PageFinding(title, "wikilinks", _counter_delta(before.wikilinks, after.wikilinks)))
if before.cite_refs != after.cite_refs:
findings.append(PageFinding(title, "cite-refs", _counter_delta(before.cite_refs, after.cite_refs)))
if before.cite_defs != after.cite_defs:
changed = []
for cite_id in sorted(set(before.cite_defs) | set(after.cite_defs)):
was, now = before.cite_defs.get(cite_id), after.cite_defs.get(cite_id)
if was != now:
changed.append(f"[^{cite_id}] {was!r} -> {now!r}")
findings.append(PageFinding(title, "cite-defs", ", ".join(changed)))
# A generated region that lost or gained a marker is the failure mode the
# delimiters were introduced against, and it is silent: a lost opening
# marker turns the region into ordinary prose, and the next write appends a
# second region beside it. An agent rewriting prose at the boundary is
# exactly how that happens, which is what makes it a migration invariant
# rather than a lint nicety.
#
# Counts, not presence - the same reasoning as the wikilink counter. A page
# that goes from one links region to two has the same *set* of region names.
if before.markers != after.markers:
changed_regions = []
for name in sorted(set(before.markers) | set(after.markers)):
was, now = before.markers.get(name, 0), after.markers.get(name, 0)
if was != now:
changed_regions.append(f"{name}: {was} -> {now}")
findings.append(PageFinding(title, "markers", ", ".join(changed_regions)))
changed_fields = []
for name in sorted(set(before.fields) | set(after.fields)):
was, now = before.fields.get(name), after.fields.get(name)
if was != now:
changed_fields.append(f"{name}: {was!r} -> {now!r}")
if changed_fields:
findings.append(PageFinding(title, "frontmatter", "; ".join(changed_fields)))
return findings
def compare(
before: dict[str, PageShape],
after: dict[str, PageShape],
expect_body_change: bool = False,
) -> CorpusDiff:
"""Compare two revisions' page shapes, keyed by page title (the wiki's
only identity for a page - see AGENTS.md invariant 2), not by path. A page
that only changed directory therefore compares as itself rather than as a
removed-and-added pair; its path change is reported separately, in
`moved`, and is never a finding on its own - a migration is allowed to
relocate a page, only not to change what it says.
`expect_body_change` turns the opposite question on: report a page whose
body is byte-identical. A migration unit that reports no such page did
something to every page it claimed to touch.
"""
diff = CorpusDiff()
diff.added = sorted(set(after) - set(before))
diff.removed = sorted(set(before) - set(after))
for title in sorted(set(before) & set(after)):
diff.compared += 1
diff.findings.extend(compare_page(title, before[title], after[title]))
if before[title].path != after[title].path:
diff.moved.append((title, before[title].path, after[title].path))
if expect_body_change and before[title].body == after[title].body:
diff.findings.append(
PageFinding(title, "unchanged", "body is byte-identical, but this unit claimed to rewrite it")
)
return diff
def render_report(diff: CorpusDiff, from_rev: str) -> str:
lines = [f"# Corpus diff against {from_rev}", ""]
lines.append(
f"{diff.compared} page(s) compared, {len(diff.added)} added, "
f"{len(diff.removed)} removed, {len(diff.moved)} moved, {len(diff.findings)} finding(s)."
)
lines.append("")
if diff.findings:
lines.append("## Invariant violations")
lines.append("")
lines += [f"- {finding}" for finding in diff.findings]
lines.append("")
else:
lines.append("No invariant changed on any page present in both revisions.")
lines.append("")
for label, paths in (("Added pages", diff.added), ("Removed pages", diff.removed)):
if paths:
lines.append(f"## {label}")
lines.append("")
lines += [f"- {path}" for path in paths]
lines.append("")
if diff.moved:
lines.append("## Moved pages")
lines.append("")
lines += [f"- {title}: `{old}` -> `{new}`" for title, old, new in diff.moved]
lines.append("")
return "\n".join(lines)