Download source/src/deg/normalize.py from gcoderw/DEG-5B-E2B-1018: direct link, hf CLI and curl.
- Browser
- Download file 13.6 kB
-
https://huggingface.co/gcoderw/DEG-5B-E2B-1018/resolve/main/source/src/deg/normalize.py
- Command line
-
hf download hf://gcoderw/DEG-5B-E2B-1018/source/src/deg/normalize.py
-
curl -L -o normalize.py https://huggingface.co/gcoderw/DEG-5B-E2B-1018/resolve/main/source/src/deg/normalize.py
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" | |