trading-system-docs/notes/trading/system-docs/d1d2-schema-contract/tools/canonical_schema.py

174 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""
D2 — canonical_schema.py
Deterministischer Schema-Kanonisierer + Fingerprint für das Historical V2 Subsystem.
Liefert EINE autoritative Kanonisierungsfunktion, die aus einem Captured-Schema-Modell
(JSON, wie von capture_schema.py erzeugt) eine deterministische semantische
Serialisierung baut und daraus den SHA-256-Fingerprint berechnet.
Kanonisierungsregeln (siehe Mission §7):
- Schemas/Tabellen alphabetisch
- Spalten nach ordinal_position
- Constraints deterministisch nach (type, name, content)
- Indexe deterministisch
- Whitespace/Identifier-Quoting normalisiert, aber keine semantischen Unterschiede weggewaschen
Fingerprint-Coverage (§8): Tabellenexistenz, Spaltenname, ordinal_position,
Datentyp, Nullability, Default, Primary Key, UNIQUE, FK, CHECK, Index-Definitionen.
"""
import hashlib
import json
def _norm_whitespace(s):
"""Normalisiere Whitespace; reduziere Wiederholungen, aber erhalte Wörter/Zeichen."""
if s is None:
return None
return " ".join(str(s).split())
def sorted_cols(columns):
"""Spalten einer Tabelle, sortiert nach ordinal_position."""
return sorted(columns, key=lambda c: (c.get("ordinal", 0), c.get("column", "")))
def canonical_columns(columns):
"""Spalten → deterministische Liste von Tuplen."""
out = []
for c in sorted_cols(columns):
out.append((
c["column"],
int(c.get("ordinal", 0)),
_norm_whitespace(c.get("type")),
_norm_whitespace(c.get("udt")),
bool(c.get("nullable")),
_norm_whitespace(c.get("default")),
))
return out
def canonical_constraints(constraints, kind):
"""Constraints eines Typs (PRIMARY KEY, UNIQUE) → sortierte Liste."""
out = []
for con in constraints:
cols = tuple(c for c in con.get("columns") or [])
out.append((
con.get("table", ""),
con.get("constraint", ""),
cols,
))
return sorted(out, key=lambda x: (x[0], x[1], x[2]))
def canonical_fks(fks):
"""FK → sortierte Liste inkl. ref_table und ref_columns."""
out = []
for fk in fks:
out.append((
fk.get("table", ""),
fk.get("constraint", ""),
tuple(fk.get("columns") or []),
fk.get("ref_table", ""),
tuple(fk.get("ref_columns") or []),
))
return sorted(out, key=lambda x: (x[0], x[1]))
def canonical_checks(checks):
"""CHECK-Constraints → sortierte Liste mit normierter Definition.
Auto-generierte PostgreSQL 'NOT NULL'-Checks (CONSTRAINT-Name endet auf
'_not_null', von PostgreSQL automatisch pro NOT-NULL-Spalte erzeugt) werden
AUSGESCHLOSSEN: sie sind redundant zu 'nullable' und ihre Definition
unterscheidet sich zwischen DBs (Production 'x IS NOT NULL' vs. Checker
'((x IS NOT NULL))') → semantisches Rauschen. Zuverlässige Erkennung NUR
über den Namenssuffix '_not_null'. Explizit benannte User-Checks
(chk_* etc.) bleiben erhalten.
"""
out = []
for chk in checks:
name = chk.get("constraint", "")
if name.endswith("_not_null"):
continue
out.append((
chk.get("table", ""),
name,
_norm_whitespace(chk.get("definition")) or "",
))
return sorted(out, key=lambda x: (x[0], x[1], x[2]))
def canonical_indexes(indexes):
"""Index-Definitionen → sortierte Liste mit normierter Definition."""
out = []
for idx in indexes:
out.append((
idx.get("table", ""),
idx.get("index", ""),
_norm_whitespace(idx.get("definition")),
))
return sorted(out, key=lambda x: (x[0], x[1], x[2]))
def canonical_sequences(sequences):
"""Sequenzen → sortierte Liste (nur Existenz/Name)."""
return sorted(sequences or [])
def canonical_document(model):
"""Baue die vollständige deterministische semantische Serialisierung."""
tables = sorted(model.get("tables") or [])
cols_by_table = {}
for c in model.get("columns") or []:
cols_by_table.setdefault(c["table"], []).append(c)
pks = canonical_constraints(model.get("primary_keys") or [], "PRIMARY KEY")
uniques = canonical_constraints(model.get("unique_constraints") or [], "UNIQUE")
fks = canonical_fks(model.get("foreign_keys") or [])
checks = canonical_checks(model.get("check_constraints") or [])
indexes = canonical_indexes(model.get("indexes") or [])
sequences = canonical_sequences(model.get("sequences") or [])
doc = []
doc.append("schema=public")
doc.append("sequences=" + "|".join(sequences))
doc.append("tables=" + "|".join(tables))
for t in tables:
doc.append(f"table:{t}:cols=" + ";".join(
f"{name}|{ordp}|{typ}|{udt}|{'n' if nul else 'y'}|{dflt}"
for (name, ordp, typ, udt, nul, dflt) in canonical_columns(cols_by_table.get(t, []))
))
doc.append("pk=" + ";".join(
f"{t}|{n}|{','.join(c)}"
for (t, n, c) in pks
))
doc.append("unique=" + ";".join(
f"{t}|{n}|{','.join(c)}"
for (t, n, c) in uniques
))
doc.append("fk=" + ";".join(
f"{t}|{n}|{','.join(c)}->{rt}|{','.join(rc)}"
for (t, n, c, rt, rc) in fks
))
doc.append("check=" + ";".join(
f"{t}|{n}|{d}"
for (t, n, d) in checks
))
doc.append("index=" + ";".join(
f"{t}|{n}|{d}"
for (t, n, d) in indexes
))
return "\n".join(doc)
def fingerprint(model):
"""SHA-256 über die kanonische Serialisierung."""
doc = canonical_document(model)
return hashlib.sha256(doc.encode("utf-8")).hexdigest(), doc
def load_model(path):
"""Lade ein Captured-Schema-Modell (JSON)."""
with open(path) as f:
return json.load(f)