DEG-5B-E2B-1018 / source /src /deg /normalize.py
gcoderw's picture
Rename package to deg (DEG_ env vars, deg.json); note upcoming modalities
16d760d verified
Raw History Blame Contribute Delete
13.6 kB
"""Light, safe normalization of real-world JSON Schemas into deg-compilable ones.
Used for external schema sources (JSONSchemaBench for datagen, ExtractBench for eval). The
result is a self-contained schema that :func:`deg.schema.compile_schema` accepts, or
``None`` with a reason. Only lossless-or-annotation edits and *dropping whole fields* are
done; a field's meaning is never rewritten:
- local ``$ref`` is inlined (recursive refs drop the referring field), ``$defs`` removed;
- annotation-like and unsupported validation keywords are dropped (``examples``, vendor
``x-*``/custom keys, ``minProperties``, ``dependencies``, ``contentEncoding``, ...);
- ``format`` values deg does not parse are dropped (the field becomes a plain span);
- ``allOf`` with non-conflicting branches is merged; nullable objects/arrays lose ``null``;
- a plain ``anyOf`` of string and number types becomes a string span (nullable if listed);
- a missing ``type`` is inferred from ``properties``/``items``/``enum`` when unambiguous;
- fields that still cannot compile (``const``, free-form maps, tuple arrays, multi-type
unions, non-enum ``anyOf``) are dropped, from ``required`` too;
- the tree is capped at ``max_leaves`` leaves and ``max_depth`` nesting (later properties
are dropped first).
The compiler is the judge: after the rewrite, every field named in a compile error is
dropped and the schema recompiled until it compiles or nothing fillable is left.
"""
from __future__ import annotations
import copy
from typing import Any
from deg.schema import ANNOTATIONS, TYPE_KEYWORDS, SchemaError, compile_schema
FORMATS = {"date", "date-time", "email", "uri", "uuid"}
KEEP_ANNOTATIONS = {"title", "description"}
_VALIDATION = frozenset().union(*TYPE_KEYWORDS.values()) | {
"enum", "const", "anyOf", "oneOf", "allOf", "not", "$ref"}
class Skip(ValueError):
"""The schema (or a subtree) cannot be normalized; ``reason`` is machine-readable."""
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
def _resolve_ref(root: Any, ref: str) -> Any:
if not isinstance(ref, str) or not (ref == "#" or ref.startswith("#/")):
raise Skip("nonlocal_ref")
target = root
for raw in ref[2:].split("/") if ref != "#" else []:
part = raw.replace("~1", "/").replace("~0", "~")
if isinstance(target, dict) and part in target:
target = target[part]
elif isinstance(target, list) and part.isdigit() and int(part) < len(target):
target = target[int(part)]
else:
raise Skip("unresolvable_ref")
if not isinstance(target, dict):
raise Skip("bad_ref")
return target
class _Normalizer:
def __init__(self, root: dict[str, Any], max_depth: int) -> None:
self.root = root
self.max_depth = max_depth
def inline(self, s: Any, refs: tuple[str, ...]) -> tuple[dict[str, Any], tuple[str, ...]]:
while isinstance(s, dict) and "$ref" in s:
ref = s["$ref"]
if ref in refs:
raise Skip("recursive_ref")
target = _resolve_ref(self.root, ref)
s = {**target, **{k: v for k, v in s.items() if k != "$ref"}}
refs = (*refs, ref)
if not isinstance(s, dict):
raise Skip("boolean_schema")
return s, refs
def merge_all_of(self, s: dict[str, Any], refs: tuple[str, ...]) -> dict[str, Any]:
out = {k: v for k, v in s.items() if k != "allOf"}
for branch in s["allOf"] if isinstance(s["allOf"], list) else []:
try:
b, _ = self.inline(branch, refs)
except Skip as exc:
if exc.reason == "nonlocal_ref": # an external base type: keep the rest
continue
raise
if "allOf" in b:
b = self.merge_all_of(b, refs)
for k, v in b.items():
if k == "properties" and isinstance(v, dict):
out["properties"] = {**out.get("properties", {}), **v}
elif k == "required" and isinstance(v, list):
out["required"] = list(dict.fromkeys([*out.get("required", []), *v]))
elif k in out and out[k] != v and k not in ANNOTATIONS:
raise Skip("allOf_conflict")
else:
out.setdefault(k, v)
return out
def node(self, raw: Any, refs: tuple[str, ...], depth: int) -> dict[str, Any]:
s, refs = self.inline(raw, refs)
if "allOf" in s:
s = self.merge_all_of(s, refs)
if "const" in s:
raise Skip("const")
for key in ("anyOf", "oneOf"):
if key in s:
return self.union(s, key, refs, depth)
t = s.get("type")
if t is None:
if "properties" in s:
t = "object"
elif "items" in s:
t = "array"
elif isinstance(s.get("enum"), list) and s["enum"]:
kinds = {_kind(v) for v in s["enum"] if v is not None}
if len(kinds) != 1:
raise Skip("untyped_enum")
t = kinds.pop()
else:
raise Skip("untyped")
nullable = False
if isinstance(t, list):
non_null = [x for x in t if x != "null"]
if len(non_null) != 1:
raise Skip("multi_type")
nullable = "null" in t
t = non_null[0]
if t not in TYPE_KEYWORDS:
raise Skip(f"type_{t}")
out: dict[str, Any] = {k: v for k, v in s.items()
if k in KEEP_ANNOTATIONS and isinstance(v, str)}
if "enum" in s:
if not isinstance(s["enum"], list):
raise Skip("bad_enum")
values = list(dict.fromkeys(_freeze(v) for v in s["enum"]))
values = [_thaw(v) for v in values]
if len([v for v in values if v is not None]) < 2 or len(values) > 255:
raise Skip("enum_size")
out["enum"] = values
for k in TYPE_KEYWORDS[t]:
if k in s and k not in ("properties", "items", "required", "additionalProperties"):
if "enum" in out and t in ("string", "integer", "number"):
continue # the enum already pins the value; bounds/lengths are redundant
out[k] = s[k]
if t == "string" and out.get("format") not in FORMATS:
out.pop("format", None)
if t == "object":
if depth >= self.max_depth:
raise Skip("too_deep")
props = s.get("properties")
if not isinstance(props, dict) or not props:
raise Skip("free_form_object")
req = s.get("required") if isinstance(s.get("required"), list) else []
kept: dict[str, Any] = {}
for name, sub in props.items():
try:
kept[name] = self.node(sub, refs, depth + 1)
except Skip:
continue
if not kept:
raise Skip("no_fillable_properties")
out["properties"] = kept
required = [r for r in req if r in kept]
if required:
out["required"] = required
out["additionalProperties"] = False
out["type"] = "object" # nullable objects are not supported: drop null
return out
if t == "array":
items = s.get("items")
if not isinstance(items, dict):
raise Skip("tuple_or_untyped_array")
out["items"] = self.node(items, refs, depth + 1)
if out["items"].get("type") == "array":
raise Skip("nested_array")
out["type"] = "array"
return out
if nullable and "enum" in out and None not in out["enum"]:
out["enum"] = [*out["enum"], None]
nullable = nullable or None in out.get("enum", [])
out["type"] = [t, "null"] if nullable else t
return out
def union(self, s: dict[str, Any], key: str, refs: tuple[str, ...],
depth: int) -> dict[str, Any]:
branches = s[key] if isinstance(s[key], list) else []
resolved = [self.inline(b, refs)[0] for b in branches]
nulls = [b for b in resolved if b.get("type") == "null"]
others = [b for b in resolved if b.get("type") != "null"]
ann = {k: v for k, v in s.items() if k in KEEP_ANNOTATIONS and isinstance(v, str)}
if len(others) == 1: # nullable form (or a 1-branch union)
inner = self.node({**others[0], **ann}, refs, depth)
if nulls and inner.get("type") not in ("object", "array"):
if "enum" in inner and None not in inner["enum"]:
inner["enum"] = [*inner["enum"], None]
if isinstance(inner.get("type"), str):
inner["type"] = [inner["type"], "null"]
return inner
types = {b.get("type") if isinstance(b.get("type"), str) else "?" for b in resolved}
plain = not any(set(b) & _VALIDATION for b in resolved) # only type + annotations
if plain and "string" in types and types <= {"string", "integer", "number", "null"}:
# "a string or a number": the value is copied from text either way -> a span
return self.node({**ann, "type": ["string", "null"] if nulls else "string"},
refs, depth)
values: list[Any] = []
for b in resolved:
if "const" in b:
values.append(b["const"])
elif isinstance(b.get("enum"), list):
values.extend(b["enum"])
elif b.get("type") == "null":
values.append(None)
else:
raise Skip("union")
return self.node({**ann, "enum": values}, refs, depth)
def _kind(v: Any) -> str:
if isinstance(v, bool):
return "boolean"
if isinstance(v, int):
return "integer"
if isinstance(v, float):
return "number"
if isinstance(v, str):
return "string"
return "object"
def _freeze(v: Any) -> Any:
return ("__json__", repr(v)) if isinstance(v, (dict, list)) else (type(v).__name__, v)
def _thaw(v: Any) -> Any:
if v[0] == "__json__":
raise Skip("structured_enum")
return v[1]
def count_leaves(s: dict[str, Any]) -> int:
if s.get("type") == "object":
return sum(count_leaves(p) for p in s["properties"].values())
if s.get("type") == "array":
return count_leaves(s["items"])
return 1
def cap_leaves(s: dict[str, Any], budget: int) -> int:
"""Keep properties in order while they fit ``budget`` leaves; returns leaves kept."""
if s.get("type") == "array":
return cap_leaves(s["items"], budget)
if s.get("type") != "object":
return 1
used = 0
kept: dict[str, Any] = {}
for name, sub in s["properties"].items():
if used >= budget:
break
n = count_leaves(sub)
if n > budget - used:
if sub.get("type") in ("object", "array") and budget - used >= 2:
n = cap_leaves(sub, budget - used)
else:
continue
kept[name] = sub
used += n
s["properties"] = kept
if "required" in s:
s["required"] = [r for r in s["required"] if r in kept]
if not s["required"]:
del s["required"]
return used
def _drop_at(schema: dict[str, Any], loc: list[Any]) -> bool:
"""Drop the deepest property named in an error ``loc``; False if none can be dropped."""
if loc and loc[0] == "schema": # compiler locs are rooted at "schema"
loc = loc[1:]
cuts = [i for i in range(len(loc) - 1) if loc[i] == "properties"]
for i in reversed(cuts):
parent: Any = schema
try:
for part in loc[:i]:
parent = parent[part]
except (KeyError, IndexError, TypeError):
continue
name = loc[i + 1]
props = parent.get("properties") if isinstance(parent, dict) else None
if isinstance(props, dict) and name in props and len(props) > 1:
del props[name]
if name in parent.get("required", []):
parent["required"] = [r for r in parent["required"] if r != name]
if not parent["required"]:
del parent["required"]
return True
return False
def normalize_schema(schema: Any, max_leaves: int = 30, max_depth: int = 5,
min_leaves: int = 2) -> tuple[dict[str, Any] | None, str]:
"""(deg-compilable schema, "ok") or (None, skip reason)."""
if not isinstance(schema, dict):
return None, "not_object"
root = copy.deepcopy(schema)
try:
out = _Normalizer(root, max_depth).node(root, ("#",), 0)
except Skip as exc:
return None, exc.reason
except RecursionError:
return None, "recursion"
if out.get("type") != "object":
return None, "root_not_object"
cap_leaves(out, max_leaves)
for _ in range(64):
try:
compile_schema(out)
break
except SchemaError as exc:
if not any(_drop_at(out, e["loc"]) for e in exc.errors[:1]):
return None, "compile:" + exc.errors[0]["msg"][:60]
else:
return None, "compile_loop"
if count_leaves(out) < min_leaves:
return None, "too_few_leaves"
return out, "ok"