Commit ·
0f2ecac
1
Parent(s): f70d732
MVP Milestone 2
Browse files- MVP_PROGRESS.md +70 -0
- src/gcmd_classifier/errors.py +29 -0
- src/gcmd_classifier/models.py +42 -0
- src/gcmd_classifier/vocabulary/__init__.py +15 -0
- src/gcmd_classifier/vocabulary/index.py +115 -0
- src/gcmd_classifier/vocabulary/loader.py +304 -0
- tests/fixtures/gcmd_hierarchy_small.json +72 -0
- tests/test_vocabulary.py +259 -0
MVP_PROGRESS.md
CHANGED
|
@@ -30,3 +30,73 @@ Source-file status:
|
|
| 30 |
- `data/gcmd_hierarchy.json` unchanged by this milestone.
|
| 31 |
- `data/articles.json` unchanged by this milestone.
|
| 32 |
- `prototype/app_hf_poc.py` unchanged by this milestone.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
- `data/gcmd_hierarchy.json` unchanged by this milestone.
|
| 31 |
- `data/articles.json` unchanged by this milestone.
|
| 32 |
- `prototype/app_hf_poc.py` unchanged by this milestone.
|
| 33 |
+
|
| 34 |
+
## Milestone 2: Hierarchy Loader and Vocabulary Index
|
| 35 |
+
|
| 36 |
+
Status: completed
|
| 37 |
+
|
| 38 |
+
Scope completed:
|
| 39 |
+
- Deterministic loader for `data/gcmd_hierarchy.json`
|
| 40 |
+
- Recursive traversal of the GCMD hierarchy through `Category`, `Topic`, `Term`, `Variable_Level_1`, `Variable_Level_2`, and `Variable_Level_3`
|
| 41 |
+
- Canonical UUID-bearing concept records
|
| 42 |
+
- Vocabulary file SHA-256 version hash
|
| 43 |
+
- Canonical paths excluding the root `Category`
|
| 44 |
+
- UUID, canonical path, parent, direct-child, Topic, Term, Variable, ancestor, and descendant lookups
|
| 45 |
+
- Typed vocabulary errors for malformed hierarchy content
|
| 46 |
+
- Focused small hierarchy fixture and full-data smoke tests
|
| 47 |
+
|
| 48 |
+
Implemented models and lookup structures:
|
| 49 |
+
- `CanonicalConceptRecord` in `src/gcmd_classifier/models.py`
|
| 50 |
+
- `VocabularyIndex` in `src/gcmd_classifier/vocabulary/index.py`
|
| 51 |
+
- `load_vocabulary`, `build_vocabulary_index`, and `calculate_file_hash` in `src/gcmd_classifier/vocabulary/loader.py`
|
| 52 |
+
|
| 53 |
+
Hierarchy assumptions discovered from the current real file:
|
| 54 |
+
- The root node is the non-UUID `Category` named `EARTH SCIENCE`.
|
| 55 |
+
- The current file contains only the `EARTH SCIENCE` Category represented by that root.
|
| 56 |
+
- Every current Topic and Term has a UUID.
|
| 57 |
+
- The only UUID-less node in the current file is the root Category.
|
| 58 |
+
- Current UUID-bearing record counts are: 14 Topics, 144 Terms, 1,368 `Variable_Level_1`, 1,456 `Variable_Level_2`, 553 `Variable_Level_3`, and 3,535 total UUID-bearing records.
|
| 59 |
+
- Current observed transitions are adjacent only: `Category -> Topic -> Term -> Variable_Level_1 -> Variable_Level_2 -> Variable_Level_3`.
|
| 60 |
+
|
| 61 |
+
Error cases implemented:
|
| 62 |
+
- Invalid JSON
|
| 63 |
+
- Non-object root or child nodes
|
| 64 |
+
- Missing or empty required `level` and `name`
|
| 65 |
+
- Unsupported hierarchy levels
|
| 66 |
+
- UUID-bearing root Category
|
| 67 |
+
- UUID-less non-root nodes
|
| 68 |
+
- Invalid parent-child level transitions
|
| 69 |
+
- Children under `Variable_Level_3`
|
| 70 |
+
- Non-list `children`
|
| 71 |
+
- Non-string `definition`
|
| 72 |
+
- Duplicate UUIDs
|
| 73 |
+
- Same canonical path assigned to different UUIDs
|
| 74 |
+
- Lookup requests for unknown UUIDs or invalid lookup scopes
|
| 75 |
+
|
| 76 |
+
Verification:
|
| 77 |
+
- `python -m ruff check .` passed: `All checks passed!`
|
| 78 |
+
- `python -m ruff format --check .` passed: `8 files already formatted`
|
| 79 |
+
- `python -m pytest` passed: `23 passed in 0.14s`
|
| 80 |
+
|
| 81 |
+
Out of scope, not started:
|
| 82 |
+
- Article loading
|
| 83 |
+
- Model integration
|
| 84 |
+
- Topic routing
|
| 85 |
+
- Term routing
|
| 86 |
+
- Variable-level classification
|
| 87 |
+
- Deterministic final classification validation beyond vocabulary lookup integrity
|
| 88 |
+
- Redundancy removal
|
| 89 |
+
- Persistence, caching, and output generation
|
| 90 |
+
- UI and root `app.py`
|
| 91 |
+
|
| 92 |
+
Source-file status:
|
| 93 |
+
- `data/gcmd_hierarchy.json` unchanged by this milestone.
|
| 94 |
+
- `data/articles.json` unchanged by this milestone.
|
| 95 |
+
- `prototype/app_hf_poc.py` unchanged by this milestone.
|
| 96 |
+
|
| 97 |
+
Deviations from approved plan:
|
| 98 |
+
- None.
|
| 99 |
+
|
| 100 |
+
Remaining risks or assumptions:
|
| 101 |
+
- The loader remains fully dynamic and does not hard-code vocabulary names, UUIDs, paths, branches, or record counts, but full-data smoke tests intentionally assert current-file counts.
|
| 102 |
+
- Future approved vocabulary rules would be needed before accepting any UUID-less non-root hierarchy node.
|
src/gcmd_classifier/errors.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Typed exceptions for GCMD classifier deterministic components."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class GCMDClassifierError(Exception):
|
| 5 |
+
"""Base exception for GCMD classifier errors."""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class VocabularyError(GCMDClassifierError):
|
| 9 |
+
"""Base exception for vocabulary loading and indexing errors."""
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MalformedHierarchyError(VocabularyError):
|
| 13 |
+
"""Raised when a hierarchy node is missing required structure or fields."""
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class DuplicateUUIDError(VocabularyError):
|
| 17 |
+
"""Raised when the hierarchy contains the same UUID more than once."""
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class DuplicateCanonicalPathError(VocabularyError):
|
| 21 |
+
"""Raised when one canonical path is assigned to multiple UUIDs."""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class InvalidHierarchyTransitionError(VocabularyError):
|
| 25 |
+
"""Raised when a node appears under an invalid hierarchy parent level."""
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class VocabularyLookupError(VocabularyError):
|
| 29 |
+
"""Raised when a requested vocabulary concept or relationship is missing."""
|
src/gcmd_classifier/models.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared typed models for the GCMD classifier MVP."""
|
| 2 |
+
|
| 3 |
+
from typing import Literal
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 6 |
+
|
| 7 |
+
HierarchyLevel = Literal[
|
| 8 |
+
"Topic",
|
| 9 |
+
"Term",
|
| 10 |
+
"Variable_Level_1",
|
| 11 |
+
"Variable_Level_2",
|
| 12 |
+
"Variable_Level_3",
|
| 13 |
+
]
|
| 14 |
+
SourceHierarchyLevel = Literal[
|
| 15 |
+
"Category",
|
| 16 |
+
"Topic",
|
| 17 |
+
"Term",
|
| 18 |
+
"Variable_Level_1",
|
| 19 |
+
"Variable_Level_2",
|
| 20 |
+
"Variable_Level_3",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class CanonicalConceptRecord(BaseModel):
|
| 25 |
+
"""Canonical UUID-bearing GCMD concept record derived from the hierarchy."""
|
| 26 |
+
|
| 27 |
+
model_config = ConfigDict(frozen=True)
|
| 28 |
+
|
| 29 |
+
UUID: str
|
| 30 |
+
name: str
|
| 31 |
+
level: HierarchyLevel
|
| 32 |
+
topic: str
|
| 33 |
+
term: str | None = None
|
| 34 |
+
path_components: tuple[str, ...]
|
| 35 |
+
canonical_path: str
|
| 36 |
+
parent_uuid: str | None = None
|
| 37 |
+
parent_name: str | None = None
|
| 38 |
+
child_uuids: tuple[str, ...] = Field(default_factory=tuple)
|
| 39 |
+
has_children: bool
|
| 40 |
+
assignable: bool
|
| 41 |
+
definition: str | None = None
|
| 42 |
+
vocabulary_version: str
|
src/gcmd_classifier/vocabulary/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vocabulary loading and indexing for GCMD Science Keywords."""
|
| 2 |
+
|
| 3 |
+
from gcmd_classifier.vocabulary.index import VocabularyIndex
|
| 4 |
+
from gcmd_classifier.vocabulary.loader import (
|
| 5 |
+
build_vocabulary_index,
|
| 6 |
+
calculate_file_hash,
|
| 7 |
+
load_vocabulary,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"VocabularyIndex",
|
| 12 |
+
"build_vocabulary_index",
|
| 13 |
+
"calculate_file_hash",
|
| 14 |
+
"load_vocabulary",
|
| 15 |
+
]
|
src/gcmd_classifier/vocabulary/index.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-memory canonical vocabulary index for GCMD Science Keywords."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections import Counter
|
| 6 |
+
from collections.abc import Mapping
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from types import MappingProxyType
|
| 9 |
+
|
| 10 |
+
from gcmd_classifier.errors import VocabularyLookupError
|
| 11 |
+
from gcmd_classifier.models import CanonicalConceptRecord, HierarchyLevel
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class VocabularyIndex:
|
| 16 |
+
"""Lookup structures derived from the canonical GCMD hierarchy."""
|
| 17 |
+
|
| 18 |
+
records_by_uuid: Mapping[str, CanonicalConceptRecord]
|
| 19 |
+
records_by_path: Mapping[str, CanonicalConceptRecord]
|
| 20 |
+
parent_by_uuid: Mapping[str, str | None]
|
| 21 |
+
children_by_uuid: Mapping[str | None, tuple[str, ...]]
|
| 22 |
+
topic_uuids: tuple[str, ...]
|
| 23 |
+
terms_by_topic_uuid: Mapping[str, tuple[str, ...]]
|
| 24 |
+
variables_by_parent_uuid: Mapping[str, tuple[str, ...]]
|
| 25 |
+
vocabulary_version: str
|
| 26 |
+
root_level: str
|
| 27 |
+
root_name: str
|
| 28 |
+
|
| 29 |
+
def __post_init__(self) -> None:
|
| 30 |
+
object.__setattr__(self, "records_by_uuid", MappingProxyType(dict(self.records_by_uuid)))
|
| 31 |
+
object.__setattr__(self, "records_by_path", MappingProxyType(dict(self.records_by_path)))
|
| 32 |
+
object.__setattr__(self, "parent_by_uuid", MappingProxyType(dict(self.parent_by_uuid)))
|
| 33 |
+
object.__setattr__(self, "children_by_uuid", MappingProxyType(dict(self.children_by_uuid)))
|
| 34 |
+
object.__setattr__(
|
| 35 |
+
self, "terms_by_topic_uuid", MappingProxyType(dict(self.terms_by_topic_uuid))
|
| 36 |
+
)
|
| 37 |
+
object.__setattr__(
|
| 38 |
+
self, "variables_by_parent_uuid", MappingProxyType(dict(self.variables_by_parent_uuid))
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def records(self) -> tuple[CanonicalConceptRecord, ...]:
|
| 43 |
+
"""All UUID-bearing canonical records in deterministic path order."""
|
| 44 |
+
return tuple(self.records_by_path[path] for path in sorted(self.records_by_path))
|
| 45 |
+
|
| 46 |
+
def __len__(self) -> int:
|
| 47 |
+
return len(self.records_by_uuid)
|
| 48 |
+
|
| 49 |
+
def get(self, uuid: str) -> CanonicalConceptRecord:
|
| 50 |
+
"""Return a concept by UUID or raise a typed lookup error."""
|
| 51 |
+
try:
|
| 52 |
+
return self.records_by_uuid[uuid]
|
| 53 |
+
except KeyError as exc:
|
| 54 |
+
raise VocabularyLookupError(f"Unknown GCMD UUID: {uuid}") from exc
|
| 55 |
+
|
| 56 |
+
def get_by_path(self, canonical_path: str) -> CanonicalConceptRecord:
|
| 57 |
+
"""Return a concept by canonical path or raise a typed lookup error."""
|
| 58 |
+
try:
|
| 59 |
+
return self.records_by_path[canonical_path]
|
| 60 |
+
except KeyError as exc:
|
| 61 |
+
raise VocabularyLookupError(f"Unknown GCMD canonical path: {canonical_path}") from exc
|
| 62 |
+
|
| 63 |
+
def parent_of(self, uuid: str) -> str | None:
|
| 64 |
+
"""Return the parent UUID for a concept, or None for Topic records."""
|
| 65 |
+
self.get(uuid)
|
| 66 |
+
return self.parent_by_uuid[uuid]
|
| 67 |
+
|
| 68 |
+
def children_of(self, parent_uuid: str | None) -> tuple[str, ...]:
|
| 69 |
+
"""Return direct child UUIDs for a parent UUID; None returns Topic UUIDs."""
|
| 70 |
+
if parent_uuid is not None:
|
| 71 |
+
self.get(parent_uuid)
|
| 72 |
+
return self.children_by_uuid.get(parent_uuid, ())
|
| 73 |
+
|
| 74 |
+
def topics(self) -> tuple[CanonicalConceptRecord, ...]:
|
| 75 |
+
"""Return all Topic records in source order."""
|
| 76 |
+
return tuple(self.get(uuid) for uuid in self.topic_uuids)
|
| 77 |
+
|
| 78 |
+
def terms_for_topic(self, topic_uuid: str) -> tuple[CanonicalConceptRecord, ...]:
|
| 79 |
+
"""Return direct Term records under a Topic UUID."""
|
| 80 |
+
topic = self.get(topic_uuid)
|
| 81 |
+
if topic.level != "Topic":
|
| 82 |
+
raise VocabularyLookupError(f"UUID is not a Topic: {topic_uuid}")
|
| 83 |
+
return tuple(self.get(uuid) for uuid in self.terms_by_topic_uuid.get(topic_uuid, ()))
|
| 84 |
+
|
| 85 |
+
def variables_for_parent(self, parent_uuid: str) -> tuple[CanonicalConceptRecord, ...]:
|
| 86 |
+
"""Return direct Variable records under a Term or Variable parent."""
|
| 87 |
+
parent = self.get(parent_uuid)
|
| 88 |
+
if parent.level == "Topic":
|
| 89 |
+
raise VocabularyLookupError(
|
| 90 |
+
f"Topic UUID does not have variable children: {parent_uuid}"
|
| 91 |
+
)
|
| 92 |
+
return tuple(self.get(uuid) for uuid in self.variables_by_parent_uuid.get(parent_uuid, ()))
|
| 93 |
+
|
| 94 |
+
def ancestors_of(self, uuid: str) -> tuple[str, ...]:
|
| 95 |
+
"""Return ancestor UUIDs from nearest parent to highest UUID-bearing ancestor."""
|
| 96 |
+
self.get(uuid)
|
| 97 |
+
ancestors: list[str] = []
|
| 98 |
+
current = self.parent_by_uuid[uuid]
|
| 99 |
+
while current is not None:
|
| 100 |
+
ancestors.append(current)
|
| 101 |
+
current = self.parent_by_uuid[current]
|
| 102 |
+
return tuple(ancestors)
|
| 103 |
+
|
| 104 |
+
def is_ancestor(self, ancestor_uuid: str, descendant_uuid: str) -> bool:
|
| 105 |
+
"""Return whether the first UUID is an ancestor of the second UUID."""
|
| 106 |
+
self.get(ancestor_uuid)
|
| 107 |
+
return ancestor_uuid in self.ancestors_of(descendant_uuid)
|
| 108 |
+
|
| 109 |
+
def is_descendant(self, descendant_uuid: str, ancestor_uuid: str) -> bool:
|
| 110 |
+
"""Return whether the first UUID is a descendant of the second UUID."""
|
| 111 |
+
return self.is_ancestor(ancestor_uuid, descendant_uuid)
|
| 112 |
+
|
| 113 |
+
def count_by_level(self) -> Counter[HierarchyLevel]:
|
| 114 |
+
"""Return UUID-bearing record counts by hierarchy level."""
|
| 115 |
+
return Counter(record.level for record in self.records_by_uuid.values())
|
src/gcmd_classifier/vocabulary/loader.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic loader for the GCMD Science Keyword hierarchy."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from gcmd_classifier.errors import (
|
| 11 |
+
DuplicateCanonicalPathError,
|
| 12 |
+
DuplicateUUIDError,
|
| 13 |
+
InvalidHierarchyTransitionError,
|
| 14 |
+
MalformedHierarchyError,
|
| 15 |
+
)
|
| 16 |
+
from gcmd_classifier.models import CanonicalConceptRecord
|
| 17 |
+
from gcmd_classifier.vocabulary.index import VocabularyIndex
|
| 18 |
+
|
| 19 |
+
ROOT_LEVEL = "Category"
|
| 20 |
+
ROOT_NAME = "EARTH SCIENCE"
|
| 21 |
+
CANONICAL_PATH_SEPARATOR = " > "
|
| 22 |
+
|
| 23 |
+
_ALLOWED_LEVELS = {
|
| 24 |
+
ROOT_LEVEL,
|
| 25 |
+
"Topic",
|
| 26 |
+
"Term",
|
| 27 |
+
"Variable_Level_1",
|
| 28 |
+
"Variable_Level_2",
|
| 29 |
+
"Variable_Level_3",
|
| 30 |
+
}
|
| 31 |
+
_VALID_CHILD_LEVELS = {
|
| 32 |
+
ROOT_LEVEL: "Topic",
|
| 33 |
+
"Topic": "Term",
|
| 34 |
+
"Term": "Variable_Level_1",
|
| 35 |
+
"Variable_Level_1": "Variable_Level_2",
|
| 36 |
+
"Variable_Level_2": "Variable_Level_3",
|
| 37 |
+
"Variable_Level_3": None,
|
| 38 |
+
}
|
| 39 |
+
_ASSIGNABLE_LEVELS = {
|
| 40 |
+
"Topic",
|
| 41 |
+
"Term",
|
| 42 |
+
"Variable_Level_1",
|
| 43 |
+
"Variable_Level_2",
|
| 44 |
+
"Variable_Level_3",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def calculate_file_hash(path: str | Path) -> str:
|
| 49 |
+
"""Return a reproducible SHA-256 hash of a vocabulary file's raw bytes."""
|
| 50 |
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def load_vocabulary(path: str | Path) -> VocabularyIndex:
|
| 54 |
+
"""Load a GCMD hierarchy JSON file and return a canonical vocabulary index."""
|
| 55 |
+
hierarchy_path = Path(path)
|
| 56 |
+
raw_bytes = hierarchy_path.read_bytes()
|
| 57 |
+
try:
|
| 58 |
+
data = json.loads(raw_bytes)
|
| 59 |
+
except json.JSONDecodeError as exc:
|
| 60 |
+
raise MalformedHierarchyError(f"Invalid JSON in vocabulary file: {hierarchy_path}") from exc
|
| 61 |
+
vocabulary_version = hashlib.sha256(raw_bytes).hexdigest()
|
| 62 |
+
return build_vocabulary_index(data, vocabulary_version=vocabulary_version)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def build_vocabulary_index(data: Any, vocabulary_version: str) -> VocabularyIndex:
|
| 66 |
+
"""Build a canonical vocabulary index from an already-loaded hierarchy object."""
|
| 67 |
+
builder = _VocabularyIndexBuilder(vocabulary_version=vocabulary_version)
|
| 68 |
+
return builder.build(data)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class _VocabularyIndexBuilder:
|
| 72 |
+
def __init__(self, vocabulary_version: str) -> None:
|
| 73 |
+
self.vocabulary_version = vocabulary_version
|
| 74 |
+
self.records_by_uuid: dict[str, CanonicalConceptRecord] = {}
|
| 75 |
+
self.records_by_path: dict[str, CanonicalConceptRecord] = {}
|
| 76 |
+
self.parent_by_uuid: dict[str, str | None] = {}
|
| 77 |
+
self.children_by_uuid: dict[str | None, tuple[str, ...]] = {}
|
| 78 |
+
self.topic_uuids: list[str] = []
|
| 79 |
+
self.terms_by_topic_uuid: dict[str, tuple[str, ...]] = {}
|
| 80 |
+
self.variables_by_parent_uuid: dict[str, tuple[str, ...]] = {}
|
| 81 |
+
|
| 82 |
+
def build(self, data: Any) -> VocabularyIndex:
|
| 83 |
+
if not isinstance(data, dict):
|
| 84 |
+
raise MalformedHierarchyError("Hierarchy root must be a JSON object.")
|
| 85 |
+
root_level = self._required_string(data, "level", path="<root>")
|
| 86 |
+
root_name = self._required_string(data, "name", path="<root>")
|
| 87 |
+
if root_level != ROOT_LEVEL or root_name != ROOT_NAME:
|
| 88 |
+
raise MalformedHierarchyError(
|
| 89 |
+
f"Hierarchy root must be {ROOT_LEVEL!r} named {ROOT_NAME!r}; "
|
| 90 |
+
f"found level={root_level!r}, name={root_name!r}."
|
| 91 |
+
)
|
| 92 |
+
if data.get("UUID"):
|
| 93 |
+
raise MalformedHierarchyError("The known root Category must not define a UUID.")
|
| 94 |
+
|
| 95 |
+
root_children = self._children(data, path=ROOT_NAME)
|
| 96 |
+
self._validate_children_transition(root_level, root_children, path=ROOT_NAME)
|
| 97 |
+
topic_uuids = self._walk_children(
|
| 98 |
+
root_children,
|
| 99 |
+
parent_level=root_level,
|
| 100 |
+
parent_uuid=None,
|
| 101 |
+
parent_name=root_name,
|
| 102 |
+
path_components=(),
|
| 103 |
+
topic=None,
|
| 104 |
+
term=None,
|
| 105 |
+
source_path=root_name,
|
| 106 |
+
)
|
| 107 |
+
self.topic_uuids = list(topic_uuids)
|
| 108 |
+
self.children_by_uuid[None] = topic_uuids
|
| 109 |
+
|
| 110 |
+
return VocabularyIndex(
|
| 111 |
+
records_by_uuid=self.records_by_uuid,
|
| 112 |
+
records_by_path=self.records_by_path,
|
| 113 |
+
parent_by_uuid=self.parent_by_uuid,
|
| 114 |
+
children_by_uuid=self.children_by_uuid,
|
| 115 |
+
topic_uuids=tuple(self.topic_uuids),
|
| 116 |
+
terms_by_topic_uuid=self.terms_by_topic_uuid,
|
| 117 |
+
variables_by_parent_uuid=self.variables_by_parent_uuid,
|
| 118 |
+
vocabulary_version=self.vocabulary_version,
|
| 119 |
+
root_level=root_level,
|
| 120 |
+
root_name=root_name,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
def _walk_children(
|
| 124 |
+
self,
|
| 125 |
+
children: list[Any],
|
| 126 |
+
*,
|
| 127 |
+
parent_level: str,
|
| 128 |
+
parent_uuid: str | None,
|
| 129 |
+
parent_name: str,
|
| 130 |
+
path_components: tuple[str, ...],
|
| 131 |
+
topic: str | None,
|
| 132 |
+
term: str | None,
|
| 133 |
+
source_path: str,
|
| 134 |
+
) -> tuple[str, ...]:
|
| 135 |
+
child_uuids: list[str] = []
|
| 136 |
+
for child in children:
|
| 137 |
+
child_uuid = self._walk_node(
|
| 138 |
+
child,
|
| 139 |
+
parent_level=parent_level,
|
| 140 |
+
parent_uuid=parent_uuid,
|
| 141 |
+
parent_name=parent_name,
|
| 142 |
+
path_components=path_components,
|
| 143 |
+
topic=topic,
|
| 144 |
+
term=term,
|
| 145 |
+
source_path=source_path,
|
| 146 |
+
)
|
| 147 |
+
child_uuids.append(child_uuid)
|
| 148 |
+
return tuple(child_uuids)
|
| 149 |
+
|
| 150 |
+
def _walk_node(
|
| 151 |
+
self,
|
| 152 |
+
node: Any,
|
| 153 |
+
*,
|
| 154 |
+
parent_level: str,
|
| 155 |
+
parent_uuid: str | None,
|
| 156 |
+
parent_name: str,
|
| 157 |
+
path_components: tuple[str, ...],
|
| 158 |
+
topic: str | None,
|
| 159 |
+
term: str | None,
|
| 160 |
+
source_path: str,
|
| 161 |
+
) -> str:
|
| 162 |
+
if not isinstance(node, dict):
|
| 163 |
+
raise MalformedHierarchyError(
|
| 164 |
+
f"Hierarchy node under {source_path!r} must be an object."
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
level = self._required_string(node, "level", path=source_path)
|
| 168 |
+
name = self._required_string(node, "name", path=source_path)
|
| 169 |
+
node_path = f"{source_path} / {name}"
|
| 170 |
+
self._validate_level(level, path=node_path)
|
| 171 |
+
self._validate_transition(parent_level, level, path=node_path)
|
| 172 |
+
|
| 173 |
+
uuid = self._required_uuid(node, level=level, path=node_path)
|
| 174 |
+
definition = self._optional_string(node, "definition", path=node_path)
|
| 175 |
+
children = self._children(node, path=node_path)
|
| 176 |
+
self._validate_children_transition(level, children, path=node_path)
|
| 177 |
+
|
| 178 |
+
next_path_components = (*path_components, name)
|
| 179 |
+
canonical_path = CANONICAL_PATH_SEPARATOR.join(next_path_components)
|
| 180 |
+
next_topic = name if level == "Topic" else topic
|
| 181 |
+
next_term = name if level == "Term" else term
|
| 182 |
+
|
| 183 |
+
direct_child_uuids = self._walk_children(
|
| 184 |
+
children,
|
| 185 |
+
parent_level=level,
|
| 186 |
+
parent_uuid=uuid,
|
| 187 |
+
parent_name=name,
|
| 188 |
+
path_components=next_path_components,
|
| 189 |
+
topic=next_topic,
|
| 190 |
+
term=next_term,
|
| 191 |
+
source_path=node_path,
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
if uuid in self.records_by_uuid:
|
| 195 |
+
existing = self.records_by_uuid[uuid]
|
| 196 |
+
raise DuplicateUUIDError(
|
| 197 |
+
f"Duplicate UUID {uuid!r} at {canonical_path!r}; "
|
| 198 |
+
f"already used by {existing.canonical_path!r}."
|
| 199 |
+
)
|
| 200 |
+
existing_path = self.records_by_path.get(canonical_path)
|
| 201 |
+
if existing_path is not None and existing_path.UUID != uuid:
|
| 202 |
+
raise DuplicateCanonicalPathError(
|
| 203 |
+
f"Canonical path {canonical_path!r} is assigned to both "
|
| 204 |
+
f"{existing_path.UUID!r} and {uuid!r}."
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
record = CanonicalConceptRecord(
|
| 208 |
+
UUID=uuid,
|
| 209 |
+
name=name,
|
| 210 |
+
level=level, # type: ignore[arg-type]
|
| 211 |
+
topic=next_topic or name,
|
| 212 |
+
term=next_term,
|
| 213 |
+
path_components=next_path_components,
|
| 214 |
+
canonical_path=canonical_path,
|
| 215 |
+
parent_uuid=parent_uuid,
|
| 216 |
+
parent_name=parent_name,
|
| 217 |
+
child_uuids=direct_child_uuids,
|
| 218 |
+
has_children=bool(children),
|
| 219 |
+
assignable=True,
|
| 220 |
+
definition=definition,
|
| 221 |
+
vocabulary_version=self.vocabulary_version,
|
| 222 |
+
)
|
| 223 |
+
self.records_by_uuid[uuid] = record
|
| 224 |
+
self.records_by_path[canonical_path] = record
|
| 225 |
+
self.parent_by_uuid[uuid] = parent_uuid
|
| 226 |
+
self.children_by_uuid[uuid] = direct_child_uuids
|
| 227 |
+
if level == "Topic":
|
| 228 |
+
self.terms_by_topic_uuid[uuid] = direct_child_uuids
|
| 229 |
+
elif level != "Variable_Level_3":
|
| 230 |
+
self.variables_by_parent_uuid[uuid] = direct_child_uuids
|
| 231 |
+
return uuid
|
| 232 |
+
|
| 233 |
+
def _required_string(self, node: dict[str, Any], field: str, *, path: str) -> str:
|
| 234 |
+
value = node.get(field)
|
| 235 |
+
if not isinstance(value, str) or not value.strip():
|
| 236 |
+
raise MalformedHierarchyError(f"Node at {path!r} must contain non-empty {field!r}.")
|
| 237 |
+
return value
|
| 238 |
+
|
| 239 |
+
def _optional_string(self, node: dict[str, Any], field: str, *, path: str) -> str | None:
|
| 240 |
+
if field not in node:
|
| 241 |
+
return None
|
| 242 |
+
value = node[field]
|
| 243 |
+
if value is None:
|
| 244 |
+
return None
|
| 245 |
+
if not isinstance(value, str):
|
| 246 |
+
raise MalformedHierarchyError(f"Optional field {field!r} at {path!r} must be a string.")
|
| 247 |
+
return value
|
| 248 |
+
|
| 249 |
+
def _required_uuid(self, node: dict[str, Any], *, level: str, path: str) -> str:
|
| 250 |
+
value = node.get("UUID")
|
| 251 |
+
if not isinstance(value, str) or not value.strip():
|
| 252 |
+
raise MalformedHierarchyError(
|
| 253 |
+
f"UUID-less hierarchy node at {path!r} is not allowed for level {level!r}."
|
| 254 |
+
)
|
| 255 |
+
if level in {"Topic", "Term"} and not value:
|
| 256 |
+
raise MalformedHierarchyError(f"{level} at {path!r} must contain a UUID.")
|
| 257 |
+
return value
|
| 258 |
+
|
| 259 |
+
def _children(self, node: dict[str, Any], *, path: str) -> list[Any]:
|
| 260 |
+
children = node.get("children", [])
|
| 261 |
+
if children is None:
|
| 262 |
+
return []
|
| 263 |
+
if not isinstance(children, list):
|
| 264 |
+
raise MalformedHierarchyError(f"Children for node at {path!r} must be a list.")
|
| 265 |
+
return children
|
| 266 |
+
|
| 267 |
+
def _validate_level(self, level: str, *, path: str) -> None:
|
| 268 |
+
if level not in _ALLOWED_LEVELS:
|
| 269 |
+
raise MalformedHierarchyError(f"Unsupported hierarchy level {level!r} at {path!r}.")
|
| 270 |
+
if level == ROOT_LEVEL:
|
| 271 |
+
raise MalformedHierarchyError("Category is only allowed as the known hierarchy root.")
|
| 272 |
+
|
| 273 |
+
def _validate_transition(self, parent_level: str, child_level: str, *, path: str) -> None:
|
| 274 |
+
expected = _VALID_CHILD_LEVELS.get(parent_level)
|
| 275 |
+
if child_level != expected:
|
| 276 |
+
raise InvalidHierarchyTransitionError(
|
| 277 |
+
f"Invalid hierarchy transition at {path!r}: {parent_level!r} -> {child_level!r}; "
|
| 278 |
+
f"expected child level {expected!r}."
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
def _validate_children_transition(self, level: str, children: list[Any], *, path: str) -> None:
|
| 282 |
+
expected = _VALID_CHILD_LEVELS[level]
|
| 283 |
+
if expected is None and children:
|
| 284 |
+
raise InvalidHierarchyTransitionError(
|
| 285 |
+
f"Node at {path!r} has children, but {level!r} cannot have child concepts."
|
| 286 |
+
)
|
| 287 |
+
for child in children:
|
| 288 |
+
if not isinstance(child, dict):
|
| 289 |
+
raise MalformedHierarchyError(f"Child under {path!r} must be an object.")
|
| 290 |
+
child_level = child.get("level")
|
| 291 |
+
if not isinstance(child_level, str) or not child_level.strip():
|
| 292 |
+
raise MalformedHierarchyError(
|
| 293 |
+
f"Child under {path!r} must contain non-empty 'level'."
|
| 294 |
+
)
|
| 295 |
+
if child_level != expected:
|
| 296 |
+
raise InvalidHierarchyTransitionError(
|
| 297 |
+
f"Invalid hierarchy transition under {path!r}: {level!r} -> {child_level!r}; "
|
| 298 |
+
f"expected child level {expected!r}."
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def is_assignable_level(level: str) -> bool:
|
| 303 |
+
"""Return whether a source hierarchy level is assignable when UUID-bearing."""
|
| 304 |
+
return level in _ASSIGNABLE_LEVELS
|
tests/fixtures/gcmd_hierarchy_small.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"level": "Category",
|
| 3 |
+
"name": "EARTH SCIENCE",
|
| 4 |
+
"children": [
|
| 5 |
+
{
|
| 6 |
+
"level": "Topic",
|
| 7 |
+
"name": "ATMOSPHERE",
|
| 8 |
+
"UUID": "topic-atmosphere",
|
| 9 |
+
"definition": "Atmospheric science.",
|
| 10 |
+
"children": [
|
| 11 |
+
{
|
| 12 |
+
"level": "Term",
|
| 13 |
+
"name": "ATMOSPHERIC CHEMISTRY",
|
| 14 |
+
"UUID": "term-atmospheric-chemistry",
|
| 15 |
+
"definition": "Chemistry of the atmosphere.",
|
| 16 |
+
"children": [
|
| 17 |
+
{
|
| 18 |
+
"level": "Variable_Level_1",
|
| 19 |
+
"name": "CARBON",
|
| 20 |
+
"UUID": "vl1-atmosphere-carbon",
|
| 21 |
+
"definition": "Atmospheric carbon variables.",
|
| 22 |
+
"children": [
|
| 23 |
+
{
|
| 24 |
+
"level": "Variable_Level_2",
|
| 25 |
+
"name": "CARBON DIOXIDE",
|
| 26 |
+
"UUID": "vl2-carbon-dioxide",
|
| 27 |
+
"children": [
|
| 28 |
+
{
|
| 29 |
+
"level": "Variable_Level_3",
|
| 30 |
+
"name": "CARBON DIOXIDE PROFILES",
|
| 31 |
+
"UUID": "vl3-carbon-dioxide-profiles",
|
| 32 |
+
"definition": "Vertical carbon dioxide profiles."
|
| 33 |
+
}
|
| 34 |
+
]
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"level": "Variable_Level_2",
|
| 38 |
+
"name": "METHANE",
|
| 39 |
+
"UUID": "vl2-methane"
|
| 40 |
+
}
|
| 41 |
+
]
|
| 42 |
+
}
|
| 43 |
+
]
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"level": "Term",
|
| 47 |
+
"name": "WEATHER EVENTS",
|
| 48 |
+
"UUID": "term-weather-events"
|
| 49 |
+
}
|
| 50 |
+
]
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"level": "Topic",
|
| 54 |
+
"name": "OCEANS",
|
| 55 |
+
"UUID": "topic-oceans",
|
| 56 |
+
"children": [
|
| 57 |
+
{
|
| 58 |
+
"level": "Term",
|
| 59 |
+
"name": "OCEAN CHEMISTRY",
|
| 60 |
+
"UUID": "term-ocean-chemistry",
|
| 61 |
+
"children": [
|
| 62 |
+
{
|
| 63 |
+
"level": "Variable_Level_1",
|
| 64 |
+
"name": "CARBON",
|
| 65 |
+
"UUID": "vl1-ocean-carbon"
|
| 66 |
+
}
|
| 67 |
+
]
|
| 68 |
+
}
|
| 69 |
+
]
|
| 70 |
+
}
|
| 71 |
+
]
|
| 72 |
+
}
|
tests/test_vocabulary.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import copy
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from gcmd_classifier.errors import (
|
| 10 |
+
DuplicateCanonicalPathError,
|
| 11 |
+
DuplicateUUIDError,
|
| 12 |
+
InvalidHierarchyTransitionError,
|
| 13 |
+
MalformedHierarchyError,
|
| 14 |
+
VocabularyLookupError,
|
| 15 |
+
)
|
| 16 |
+
from gcmd_classifier.vocabulary import build_vocabulary_index, calculate_file_hash, load_vocabulary
|
| 17 |
+
|
| 18 |
+
FIXTURE_PATH = Path("tests/fixtures/gcmd_hierarchy_small.json")
|
| 19 |
+
FULL_HIERARCHY_PATH = Path("data/gcmd_hierarchy.json")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def load_fixture_data() -> dict:
|
| 23 |
+
return json.loads(FIXTURE_PATH.read_text())
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def build_fixture_index():
|
| 27 |
+
return load_vocabulary(FIXTURE_PATH)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_recursive_traversal_and_variable_depth_branches() -> None:
|
| 31 |
+
index = build_fixture_index()
|
| 32 |
+
|
| 33 |
+
assert len(index) == 10
|
| 34 |
+
assert index.root_level == "Category"
|
| 35 |
+
assert index.root_name == "EARTH SCIENCE"
|
| 36 |
+
assert index.children_of(None) == ("topic-atmosphere", "topic-oceans")
|
| 37 |
+
assert index.children_of("term-weather-events") == ()
|
| 38 |
+
assert index.get("vl3-carbon-dioxide-profiles").level == "Variable_Level_3"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_uuid_preservation_and_record_fields() -> None:
|
| 42 |
+
index = build_fixture_index()
|
| 43 |
+
record = index.get("vl3-carbon-dioxide-profiles")
|
| 44 |
+
|
| 45 |
+
assert record.UUID == "vl3-carbon-dioxide-profiles"
|
| 46 |
+
assert record.name == "CARBON DIOXIDE PROFILES"
|
| 47 |
+
assert record.level == "Variable_Level_3"
|
| 48 |
+
assert record.topic == "ATMOSPHERE"
|
| 49 |
+
assert record.term == "ATMOSPHERIC CHEMISTRY"
|
| 50 |
+
assert record.path_components == (
|
| 51 |
+
"ATMOSPHERE",
|
| 52 |
+
"ATMOSPHERIC CHEMISTRY",
|
| 53 |
+
"CARBON",
|
| 54 |
+
"CARBON DIOXIDE",
|
| 55 |
+
"CARBON DIOXIDE PROFILES",
|
| 56 |
+
)
|
| 57 |
+
assert (
|
| 58 |
+
record.canonical_path == "ATMOSPHERE > ATMOSPHERIC CHEMISTRY > CARBON > CARBON DIOXIDE > "
|
| 59 |
+
"CARBON DIOXIDE PROFILES"
|
| 60 |
+
)
|
| 61 |
+
assert record.parent_uuid == "vl2-carbon-dioxide"
|
| 62 |
+
assert record.parent_name == "CARBON DIOXIDE"
|
| 63 |
+
assert record.child_uuids == ()
|
| 64 |
+
assert record.has_children is False
|
| 65 |
+
assert record.assignable is True
|
| 66 |
+
assert record.definition == "Vertical carbon dioxide profiles."
|
| 67 |
+
assert record.vocabulary_version == calculate_file_hash(FIXTURE_PATH)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_topic_term_and_variable_records_are_assignable() -> None:
|
| 71 |
+
index = build_fixture_index()
|
| 72 |
+
|
| 73 |
+
topic = index.get("topic-atmosphere")
|
| 74 |
+
term = index.get("term-atmospheric-chemistry")
|
| 75 |
+
internal_variable = index.get("vl1-atmosphere-carbon")
|
| 76 |
+
leaf_variable = index.get("vl2-methane")
|
| 77 |
+
|
| 78 |
+
assert topic.level == "Topic"
|
| 79 |
+
assert term.level == "Term"
|
| 80 |
+
assert internal_variable.level == "Variable_Level_1"
|
| 81 |
+
assert leaf_variable.level == "Variable_Level_2"
|
| 82 |
+
assert topic.assignable is True
|
| 83 |
+
assert term.assignable is True
|
| 84 |
+
assert internal_variable.assignable is True
|
| 85 |
+
assert leaf_variable.assignable is True
|
| 86 |
+
assert internal_variable.has_children is True
|
| 87 |
+
assert leaf_variable.has_children is False
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_root_category_is_non_assignable_context() -> None:
|
| 91 |
+
index = build_fixture_index()
|
| 92 |
+
|
| 93 |
+
assert "EARTH SCIENCE" not in index.records_by_path
|
| 94 |
+
assert all(record.level != "Category" for record in index.records_by_uuid.values())
|
| 95 |
+
assert index.parent_of("topic-atmosphere") is None
|
| 96 |
+
assert index.get("topic-atmosphere").parent_name == "EARTH SCIENCE"
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_optional_and_missing_definitions() -> None:
|
| 100 |
+
index = build_fixture_index()
|
| 101 |
+
|
| 102 |
+
assert index.get("topic-atmosphere").definition == "Atmospheric science."
|
| 103 |
+
assert index.get("vl2-methane").definition is None
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_canonical_path_excludes_root_category() -> None:
|
| 107 |
+
index = build_fixture_index()
|
| 108 |
+
|
| 109 |
+
assert index.get("topic-atmosphere").canonical_path == "ATMOSPHERE"
|
| 110 |
+
assert index.get("term-weather-events").canonical_path == "ATMOSPHERE > WEATHER EVENTS"
|
| 111 |
+
assert not index.get("topic-atmosphere").canonical_path.startswith("EARTH SCIENCE")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_identical_names_in_different_branches_are_allowed_when_paths_differ() -> None:
|
| 115 |
+
index = build_fixture_index()
|
| 116 |
+
|
| 117 |
+
atmosphere_carbon = index.get("vl1-atmosphere-carbon")
|
| 118 |
+
ocean_carbon = index.get("vl1-ocean-carbon")
|
| 119 |
+
|
| 120 |
+
assert atmosphere_carbon.name == ocean_carbon.name == "CARBON"
|
| 121 |
+
assert atmosphere_carbon.canonical_path != ocean_carbon.canonical_path
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_duplicate_uuid_detection() -> None:
|
| 125 |
+
data = load_fixture_data()
|
| 126 |
+
data["children"][1]["UUID"] = "topic-atmosphere"
|
| 127 |
+
|
| 128 |
+
with pytest.raises(DuplicateUUIDError):
|
| 129 |
+
build_vocabulary_index(data, vocabulary_version="test")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_duplicate_canonical_path_detection() -> None:
|
| 133 |
+
data = load_fixture_data()
|
| 134 |
+
duplicate = copy.deepcopy(data["children"][0]["children"][1])
|
| 135 |
+
duplicate["UUID"] = "term-weather-events-duplicate"
|
| 136 |
+
data["children"][0]["children"].append(duplicate)
|
| 137 |
+
|
| 138 |
+
with pytest.raises(DuplicateCanonicalPathError):
|
| 139 |
+
build_vocabulary_index(data, vocabulary_version="test")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@pytest.mark.parametrize("field", ["level", "name"])
|
| 143 |
+
def test_missing_required_fields(field: str) -> None:
|
| 144 |
+
data = load_fixture_data()
|
| 145 |
+
del data["children"][0][field]
|
| 146 |
+
|
| 147 |
+
with pytest.raises(MalformedHierarchyError):
|
| 148 |
+
build_vocabulary_index(data, vocabulary_version="test")
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_unexpected_uuidless_non_root_node() -> None:
|
| 152 |
+
data = load_fixture_data()
|
| 153 |
+
del data["children"][0]["UUID"]
|
| 154 |
+
|
| 155 |
+
with pytest.raises(MalformedHierarchyError):
|
| 156 |
+
build_vocabulary_index(data, vocabulary_version="test")
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def test_valid_hierarchy_level_transitions() -> None:
|
| 160 |
+
index = build_fixture_index()
|
| 161 |
+
|
| 162 |
+
assert index.get("topic-atmosphere").child_uuids == (
|
| 163 |
+
"term-atmospheric-chemistry",
|
| 164 |
+
"term-weather-events",
|
| 165 |
+
)
|
| 166 |
+
assert index.get("term-atmospheric-chemistry").child_uuids == ("vl1-atmosphere-carbon",)
|
| 167 |
+
assert index.get("vl1-atmosphere-carbon").child_uuids == (
|
| 168 |
+
"vl2-carbon-dioxide",
|
| 169 |
+
"vl2-methane",
|
| 170 |
+
)
|
| 171 |
+
assert index.get("vl2-carbon-dioxide").child_uuids == ("vl3-carbon-dioxide-profiles",)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def test_invalid_hierarchy_level_transition() -> None:
|
| 175 |
+
data = load_fixture_data()
|
| 176 |
+
data["children"][0]["children"][0]["level"] = "Variable_Level_1"
|
| 177 |
+
|
| 178 |
+
with pytest.raises(InvalidHierarchyTransitionError):
|
| 179 |
+
build_vocabulary_index(data, vocabulary_version="test")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_parent_and_direct_child_lookup() -> None:
|
| 183 |
+
index = build_fixture_index()
|
| 184 |
+
|
| 185 |
+
assert index.parent_of("vl2-carbon-dioxide") == "vl1-atmosphere-carbon"
|
| 186 |
+
assert index.children_of("vl1-atmosphere-carbon") == (
|
| 187 |
+
"vl2-carbon-dioxide",
|
| 188 |
+
"vl2-methane",
|
| 189 |
+
)
|
| 190 |
+
with pytest.raises(VocabularyLookupError):
|
| 191 |
+
index.parent_of("missing-uuid")
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_topic_to_term_lookup() -> None:
|
| 195 |
+
index = build_fixture_index()
|
| 196 |
+
|
| 197 |
+
terms = index.terms_for_topic("topic-atmosphere")
|
| 198 |
+
assert tuple(term.UUID for term in terms) == (
|
| 199 |
+
"term-atmospheric-chemistry",
|
| 200 |
+
"term-weather-events",
|
| 201 |
+
)
|
| 202 |
+
with pytest.raises(VocabularyLookupError):
|
| 203 |
+
index.terms_for_topic("term-weather-events")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def test_term_to_variable_lookup() -> None:
|
| 207 |
+
index = build_fixture_index()
|
| 208 |
+
|
| 209 |
+
variables = index.variables_for_parent("term-atmospheric-chemistry")
|
| 210 |
+
assert tuple(variable.UUID for variable in variables) == ("vl1-atmosphere-carbon",)
|
| 211 |
+
assert index.variables_for_parent("vl2-methane") == ()
|
| 212 |
+
with pytest.raises(VocabularyLookupError):
|
| 213 |
+
index.variables_for_parent("topic-atmosphere")
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def test_ancestor_and_descendant_checks() -> None:
|
| 217 |
+
index = build_fixture_index()
|
| 218 |
+
|
| 219 |
+
assert index.is_ancestor("topic-atmosphere", "vl3-carbon-dioxide-profiles") is True
|
| 220 |
+
assert index.is_descendant("vl3-carbon-dioxide-profiles", "topic-atmosphere") is True
|
| 221 |
+
assert index.is_ancestor("term-weather-events", "vl3-carbon-dioxide-profiles") is False
|
| 222 |
+
assert index.ancestors_of("vl3-carbon-dioxide-profiles") == (
|
| 223 |
+
"vl2-carbon-dioxide",
|
| 224 |
+
"vl1-atmosphere-carbon",
|
| 225 |
+
"term-atmospheric-chemistry",
|
| 226 |
+
"topic-atmosphere",
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def test_vocabulary_file_hash_stability() -> None:
|
| 231 |
+
assert calculate_file_hash(FIXTURE_PATH) == calculate_file_hash(FIXTURE_PATH)
|
| 232 |
+
assert build_fixture_index().vocabulary_version == calculate_file_hash(FIXTURE_PATH)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def test_source_file_immutability() -> None:
|
| 236 |
+
before = FULL_HIERARCHY_PATH.read_bytes()
|
| 237 |
+
load_vocabulary(FULL_HIERARCHY_PATH)
|
| 238 |
+
after = FULL_HIERARCHY_PATH.read_bytes()
|
| 239 |
+
|
| 240 |
+
assert after == before
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def test_every_current_topic_and_term_has_uuid() -> None:
|
| 244 |
+
index = load_vocabulary(FULL_HIERARCHY_PATH)
|
| 245 |
+
|
| 246 |
+
assert all(topic.UUID for topic in index.topics())
|
| 247 |
+
assert all(term.UUID for topic in index.topics() for term in index.terms_for_topic(topic.UUID))
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def test_full_data_smoke_counts_for_current_vocabulary() -> None:
|
| 251 |
+
index = load_vocabulary(FULL_HIERARCHY_PATH)
|
| 252 |
+
counts = index.count_by_level()
|
| 253 |
+
|
| 254 |
+
assert counts["Topic"] == 14
|
| 255 |
+
assert counts["Term"] == 144
|
| 256 |
+
assert counts["Variable_Level_1"] == 1368
|
| 257 |
+
assert counts["Variable_Level_2"] == 1456
|
| 258 |
+
assert counts["Variable_Level_3"] == 553
|
| 259 |
+
assert len(index) == 3535
|