igerasimov's picture
MVP Milestone 2
0f2ecac
Raw
History Blame Contribute Delete
11.9 kB
"""Deterministic loader for the GCMD Science Keyword hierarchy."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
from gcmd_classifier.errors import (
DuplicateCanonicalPathError,
DuplicateUUIDError,
InvalidHierarchyTransitionError,
MalformedHierarchyError,
)
from gcmd_classifier.models import CanonicalConceptRecord
from gcmd_classifier.vocabulary.index import VocabularyIndex
ROOT_LEVEL = "Category"
ROOT_NAME = "EARTH SCIENCE"
CANONICAL_PATH_SEPARATOR = " > "
_ALLOWED_LEVELS = {
ROOT_LEVEL,
"Topic",
"Term",
"Variable_Level_1",
"Variable_Level_2",
"Variable_Level_3",
}
_VALID_CHILD_LEVELS = {
ROOT_LEVEL: "Topic",
"Topic": "Term",
"Term": "Variable_Level_1",
"Variable_Level_1": "Variable_Level_2",
"Variable_Level_2": "Variable_Level_3",
"Variable_Level_3": None,
}
_ASSIGNABLE_LEVELS = {
"Topic",
"Term",
"Variable_Level_1",
"Variable_Level_2",
"Variable_Level_3",
}
def calculate_file_hash(path: str | Path) -> str:
"""Return a reproducible SHA-256 hash of a vocabulary file's raw bytes."""
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def load_vocabulary(path: str | Path) -> VocabularyIndex:
"""Load a GCMD hierarchy JSON file and return a canonical vocabulary index."""
hierarchy_path = Path(path)
raw_bytes = hierarchy_path.read_bytes()
try:
data = json.loads(raw_bytes)
except json.JSONDecodeError as exc:
raise MalformedHierarchyError(f"Invalid JSON in vocabulary file: {hierarchy_path}") from exc
vocabulary_version = hashlib.sha256(raw_bytes).hexdigest()
return build_vocabulary_index(data, vocabulary_version=vocabulary_version)
def build_vocabulary_index(data: Any, vocabulary_version: str) -> VocabularyIndex:
"""Build a canonical vocabulary index from an already-loaded hierarchy object."""
builder = _VocabularyIndexBuilder(vocabulary_version=vocabulary_version)
return builder.build(data)
class _VocabularyIndexBuilder:
def __init__(self, vocabulary_version: str) -> None:
self.vocabulary_version = vocabulary_version
self.records_by_uuid: dict[str, CanonicalConceptRecord] = {}
self.records_by_path: dict[str, CanonicalConceptRecord] = {}
self.parent_by_uuid: dict[str, str | None] = {}
self.children_by_uuid: dict[str | None, tuple[str, ...]] = {}
self.topic_uuids: list[str] = []
self.terms_by_topic_uuid: dict[str, tuple[str, ...]] = {}
self.variables_by_parent_uuid: dict[str, tuple[str, ...]] = {}
def build(self, data: Any) -> VocabularyIndex:
if not isinstance(data, dict):
raise MalformedHierarchyError("Hierarchy root must be a JSON object.")
root_level = self._required_string(data, "level", path="<root>")
root_name = self._required_string(data, "name", path="<root>")
if root_level != ROOT_LEVEL or root_name != ROOT_NAME:
raise MalformedHierarchyError(
f"Hierarchy root must be {ROOT_LEVEL!r} named {ROOT_NAME!r}; "
f"found level={root_level!r}, name={root_name!r}."
)
if data.get("UUID"):
raise MalformedHierarchyError("The known root Category must not define a UUID.")
root_children = self._children(data, path=ROOT_NAME)
self._validate_children_transition(root_level, root_children, path=ROOT_NAME)
topic_uuids = self._walk_children(
root_children,
parent_level=root_level,
parent_uuid=None,
parent_name=root_name,
path_components=(),
topic=None,
term=None,
source_path=root_name,
)
self.topic_uuids = list(topic_uuids)
self.children_by_uuid[None] = topic_uuids
return VocabularyIndex(
records_by_uuid=self.records_by_uuid,
records_by_path=self.records_by_path,
parent_by_uuid=self.parent_by_uuid,
children_by_uuid=self.children_by_uuid,
topic_uuids=tuple(self.topic_uuids),
terms_by_topic_uuid=self.terms_by_topic_uuid,
variables_by_parent_uuid=self.variables_by_parent_uuid,
vocabulary_version=self.vocabulary_version,
root_level=root_level,
root_name=root_name,
)
def _walk_children(
self,
children: list[Any],
*,
parent_level: str,
parent_uuid: str | None,
parent_name: str,
path_components: tuple[str, ...],
topic: str | None,
term: str | None,
source_path: str,
) -> tuple[str, ...]:
child_uuids: list[str] = []
for child in children:
child_uuid = self._walk_node(
child,
parent_level=parent_level,
parent_uuid=parent_uuid,
parent_name=parent_name,
path_components=path_components,
topic=topic,
term=term,
source_path=source_path,
)
child_uuids.append(child_uuid)
return tuple(child_uuids)
def _walk_node(
self,
node: Any,
*,
parent_level: str,
parent_uuid: str | None,
parent_name: str,
path_components: tuple[str, ...],
topic: str | None,
term: str | None,
source_path: str,
) -> str:
if not isinstance(node, dict):
raise MalformedHierarchyError(
f"Hierarchy node under {source_path!r} must be an object."
)
level = self._required_string(node, "level", path=source_path)
name = self._required_string(node, "name", path=source_path)
node_path = f"{source_path} / {name}"
self._validate_level(level, path=node_path)
self._validate_transition(parent_level, level, path=node_path)
uuid = self._required_uuid(node, level=level, path=node_path)
definition = self._optional_string(node, "definition", path=node_path)
children = self._children(node, path=node_path)
self._validate_children_transition(level, children, path=node_path)
next_path_components = (*path_components, name)
canonical_path = CANONICAL_PATH_SEPARATOR.join(next_path_components)
next_topic = name if level == "Topic" else topic
next_term = name if level == "Term" else term
direct_child_uuids = self._walk_children(
children,
parent_level=level,
parent_uuid=uuid,
parent_name=name,
path_components=next_path_components,
topic=next_topic,
term=next_term,
source_path=node_path,
)
if uuid in self.records_by_uuid:
existing = self.records_by_uuid[uuid]
raise DuplicateUUIDError(
f"Duplicate UUID {uuid!r} at {canonical_path!r}; "
f"already used by {existing.canonical_path!r}."
)
existing_path = self.records_by_path.get(canonical_path)
if existing_path is not None and existing_path.UUID != uuid:
raise DuplicateCanonicalPathError(
f"Canonical path {canonical_path!r} is assigned to both "
f"{existing_path.UUID!r} and {uuid!r}."
)
record = CanonicalConceptRecord(
UUID=uuid,
name=name,
level=level, # type: ignore[arg-type]
topic=next_topic or name,
term=next_term,
path_components=next_path_components,
canonical_path=canonical_path,
parent_uuid=parent_uuid,
parent_name=parent_name,
child_uuids=direct_child_uuids,
has_children=bool(children),
assignable=True,
definition=definition,
vocabulary_version=self.vocabulary_version,
)
self.records_by_uuid[uuid] = record
self.records_by_path[canonical_path] = record
self.parent_by_uuid[uuid] = parent_uuid
self.children_by_uuid[uuid] = direct_child_uuids
if level == "Topic":
self.terms_by_topic_uuid[uuid] = direct_child_uuids
elif level != "Variable_Level_3":
self.variables_by_parent_uuid[uuid] = direct_child_uuids
return uuid
def _required_string(self, node: dict[str, Any], field: str, *, path: str) -> str:
value = node.get(field)
if not isinstance(value, str) or not value.strip():
raise MalformedHierarchyError(f"Node at {path!r} must contain non-empty {field!r}.")
return value
def _optional_string(self, node: dict[str, Any], field: str, *, path: str) -> str | None:
if field not in node:
return None
value = node[field]
if value is None:
return None
if not isinstance(value, str):
raise MalformedHierarchyError(f"Optional field {field!r} at {path!r} must be a string.")
return value
def _required_uuid(self, node: dict[str, Any], *, level: str, path: str) -> str:
value = node.get("UUID")
if not isinstance(value, str) or not value.strip():
raise MalformedHierarchyError(
f"UUID-less hierarchy node at {path!r} is not allowed for level {level!r}."
)
if level in {"Topic", "Term"} and not value:
raise MalformedHierarchyError(f"{level} at {path!r} must contain a UUID.")
return value
def _children(self, node: dict[str, Any], *, path: str) -> list[Any]:
children = node.get("children", [])
if children is None:
return []
if not isinstance(children, list):
raise MalformedHierarchyError(f"Children for node at {path!r} must be a list.")
return children
def _validate_level(self, level: str, *, path: str) -> None:
if level not in _ALLOWED_LEVELS:
raise MalformedHierarchyError(f"Unsupported hierarchy level {level!r} at {path!r}.")
if level == ROOT_LEVEL:
raise MalformedHierarchyError("Category is only allowed as the known hierarchy root.")
def _validate_transition(self, parent_level: str, child_level: str, *, path: str) -> None:
expected = _VALID_CHILD_LEVELS.get(parent_level)
if child_level != expected:
raise InvalidHierarchyTransitionError(
f"Invalid hierarchy transition at {path!r}: {parent_level!r} -> {child_level!r}; "
f"expected child level {expected!r}."
)
def _validate_children_transition(self, level: str, children: list[Any], *, path: str) -> None:
expected = _VALID_CHILD_LEVELS[level]
if expected is None and children:
raise InvalidHierarchyTransitionError(
f"Node at {path!r} has children, but {level!r} cannot have child concepts."
)
for child in children:
if not isinstance(child, dict):
raise MalformedHierarchyError(f"Child under {path!r} must be an object.")
child_level = child.get("level")
if not isinstance(child_level, str) or not child_level.strip():
raise MalformedHierarchyError(
f"Child under {path!r} must contain non-empty 'level'."
)
if child_level != expected:
raise InvalidHierarchyTransitionError(
f"Invalid hierarchy transition under {path!r}: {level!r} -> {child_level!r}; "
f"expected child level {expected!r}."
)
def is_assignable_level(level: str) -> bool:
"""Return whether a source hierarchy level is assignable when UUID-bearing."""
return level in _ASSIGNABLE_LEVELS