igerasimov commited on
Commit
0f2ecac
·
1 Parent(s): f70d732

MVP Milestone 2

Browse files
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