diff --git a/src/allotropy/allotrope/converter.py b/src/allotropy/allotrope/converter.py index 60d15fe5a..3bff3f625 100644 --- a/src/allotropy/allotrope/converter.py +++ b/src/allotropy/allotrope/converter.py @@ -208,6 +208,15 @@ def unstructure(obj: Any) -> Any: return result if isinstance(obj, list): + # Fast-path: lists of primitives (e.g., data cube float arrays with millions + # of elements) pass through unchanged — return directly to avoid per-element + # function call overhead. + if obj: + first = obj[0] + if ( + first is None or isinstance(first, int | float | str | bool) + ) and not isinstance(first, Enum): + return obj return [unstructure(item) for item in obj] if isinstance(obj, dict): diff --git a/src/allotropy/allotrope/schemas.py b/src/allotropy/allotrope/schemas.py index e7f81fd6d..83e441d3e 100644 --- a/src/allotropy/allotrope/schemas.py +++ b/src/allotropy/allotrope/schemas.py @@ -5,6 +5,7 @@ from typing import Any import jsonschema +from jsonschema import ValidationError from allotropy.allotrope.path_util import ( get_full_schema_path, @@ -23,6 +24,98 @@ FORMAT_CHECKER.checkers.pop("uri-reference", None) +# --------------------------------------------------------------------------- +# Optimized array items validator +# --------------------------------------------------------------------------- +# Data cube arrays can contain millions of floats. The default jsonschema +# items validator calls validator.descend() per element — extremely slow for +# simple type-only schemas like {"type": "number"} or +# {"anyOf": [{"type": "number"}, {"type": "null"}]}. +# We detect these patterns and do a single bulk isinstance pass instead. + +_SIMPLE_TYPE_MAP: dict[str, tuple[tuple[type, ...], bool]] = { + "number": ((int, float), True), + "string": ((str,), False), + "boolean": ((bool,), False), + "integer": ((int,), True), + "null": ((type(None),), False), +} + + +def _extract_accepted_types( + items_schema: Any, +) -> tuple[tuple[type, ...], bool] | None: + """If items_schema is a simple type-only check, return (accepted_types, exclude_bool).""" + if not isinstance(items_schema, dict): + return None + + keys = set(items_schema.keys()) + + if keys == {"type"} and isinstance(items_schema["type"], str): + return _SIMPLE_TYPE_MAP.get(items_schema["type"]) + + if keys == {"anyOf"} and isinstance(items_schema["anyOf"], list): + combined: set[type] = set() + exclude_bool = False + for sub in items_schema["anyOf"]: + if not ( + isinstance(sub, dict) + and set(sub.keys()) == {"type"} + and isinstance(sub["type"], str) + ): + return None + entry = _SIMPLE_TYPE_MAP.get(sub["type"]) + if entry is None: + return None + combined.update(entry[0]) + exclude_bool = exclude_bool or entry[1] + return (tuple(combined), exclude_bool) + + return None + + +_original_items = jsonschema.validators.Draft202012Validator.VALIDATORS["items"] + + +def _fast_items_validator( + validator: Any, items_schema: Any, instance: Any, schema: Any +) -> Any: + """items validator that bulk-checks homogeneous primitive arrays.""" + if not validator.is_type(instance, "array"): + return + + if schema.get("prefixItems") or not instance: + yield from _original_items(validator, items_schema, instance, schema) + return + + if items_schema is False: + yield ValidationError(f"Expected at most 0 items, but found {len(instance)}") + return + + type_info = _extract_accepted_types(items_schema) + if type_info is None: + yield from _original_items(validator, items_schema, instance, schema) + return + + accepted_types, exclude_bool = type_info + if exclude_bool: + for i, v in enumerate(instance): + if isinstance(v, bool) or not isinstance(v, accepted_types): + yield from validator.descend(instance[i], items_schema, path=i) + return + else: + for i, v in enumerate(instance): + if not isinstance(v, accepted_types): + yield from validator.descend(instance[i], items_schema, path=i) + return + + +FastDraft202012Validator = jsonschema.validators.extend( # type: ignore[no-untyped-call] + jsonschema.validators.Draft202012Validator, + validators={"items": _fast_items_validator}, +) + + def get_schema(schema_path: Path) -> dict[str, Any]: with open(get_full_schema_path(schema_path), encoding=DEFAULT_ENCODING) as f: return json.load(f) # type: ignore[no-any-return] @@ -87,7 +180,7 @@ def validate_asm_schema(asm_dict: dict[str, Any]) -> None: referrer=schema, store=store, ) - validator = jsonschema.validators.Draft202012Validator( + validator = FastDraft202012Validator( schema, resolver=resolver, format_checker=FORMAT_CHECKER ) validator.validate(asm_dict)