File size: 12,491 Bytes
57b6fe2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
from __future__ import annotations

from pathlib import Path

from gcmd_classifier.classification import (
    ClassificationCandidate,
    TermBranchSeed,
    VariableTerminalOutcome,
    candidate_from_term_branch,
    validate_candidate,
    validate_candidates,
    validate_terminal_outcomes,
)
from gcmd_classifier.models import ClassificationFinalStatus, SupportType
from gcmd_classifier.vocabulary import load_vocabulary

FIXTURE_PATH = Path("tests/fixtures/gcmd_hierarchy_small.json")
FULL_HIERARCHY_PATH = Path("data/gcmd_hierarchy.json")


def _index():
    return load_vocabulary(FIXTURE_PATH)


def _candidate(uuid: str, *, branch_id: str | None = "branch:one") -> ClassificationCandidate:
    index = _index()
    record = index.get(uuid)
    topic = record if record.level == "Topic" else index.get(index.ancestors_of(uuid)[-1])
    term = None
    if record.level == "Term":
        term = record
    elif record.level.startswith("Variable_Level_"):
        term = next(
            index.get(ancestor)
            for ancestor in index.ancestors_of(uuid)
            if index.get(ancestor).level == "Term"
        )
    return ClassificationCandidate(
        final_uuid=record.UUID,
        final_name=record.name,
        final_level=record.level,
        final_canonical_path=record.canonical_path,
        path_components=record.path_components,
        topic_uuid=topic.UUID,
        topic_name=topic.name,
        term_uuid=None if term is None else term.UUID,
        term_name=None if term is None else term.name,
        branch_id=branch_id,
        evidence="Validated evidence.",
        support_type=SupportType.EXPLICIT,
        confidence=0.81,
        reason="Routing selected the concept.",
        candidate_id="candidate_0001",
    )


def _term_branch() -> TermBranchSeed:
    return TermBranchSeed(
        branch_id="topic:topic_0001/term:term_0001",
        parent_topic_uuid="topic-atmosphere",
        parent_topic_name="ATMOSPHERE",
        term_uuid="term-atmospheric-chemistry",
        term_name="ATMOSPHERIC CHEMISTRY",
        term_level="Term",
        term_canonical_path="ATMOSPHERE > ATMOSPHERIC CHEMISTRY",
        evidence="The article supports the Term.",
        support_type=SupportType.EXPLICIT,
        confidence=0.77,
        reason="Term was selected.",
        candidate_id="term_0001",
        parent_branch_id="topic:topic_0001",
        prompt_version="term-test",
        model_provider="fake",
        model_name="fake-model",
        retry_count=0,
    )


def _terminal(
    uuid: str,
    *,
    branch_id: str = "topic:t/term:x/variable:y",
) -> VariableTerminalOutcome:
    index = _index()
    record = index.get(uuid)
    return VariableTerminalOutcome(
        branch_id=branch_id,
        parent_branch_id="topic:t/term:x",
        topic_uuid="topic-atmosphere",
        topic_name="ATMOSPHERE",
        term_uuid="term-atmospheric-chemistry",
        term_name="ATMOSPHERIC CHEMISTRY",
        final_uuid=record.UUID,
        final_name=record.name,
        final_level=record.level,
        final_canonical_path=record.canonical_path,
        path_components=record.path_components,
        evidence="Final-stage evidence.",
        support_type=SupportType.MIXED,
        confidence=0.69,
        reason="Selected descendant.",
        stop_reason="Deepest supported concept.",
        candidate_id="variable_0001",
        prompt_version="variable-test",
        model_provider="fake",
        model_name="fake-model",
        retry_count=1,
    )


def _error_codes(result) -> set[str]:
    return {error.code for error in result.deterministic_validation.errors}


def test_valid_candidate_accepted_and_populated_from_vocabulary_index() -> None:
    result = validate_candidate(_candidate("vl3-carbon-dioxide-profiles"), _index())

    assert result.valid is True
    assert result.classification is not None
    classification = result.classification
    assert classification.UUID == "vl3-carbon-dioxide-profiles"
    assert classification.name == "CARBON DIOXIDE PROFILES"
    assert classification.level == "Variable_Level_3"
    assert classification.canonical_path.endswith("CARBON DIOXIDE PROFILES")
    assert classification.topic == "ATMOSPHERE"
    assert classification.term == "ATMOSPHERIC CHEMISTRY"
    assert classification.parent_uuid == "vl2-carbon-dioxide"
    assert classification.final_status is ClassificationFinalStatus.ACCEPTED
    assert classification.deterministic_validation.valid is True
    assert classification.confidence is not None
    assert classification.confidence.variable_level_3 == 0.81
    assert classification.confidence.final == 0.81
    assert classification.classifier_evidence == "Validated evidence."
    assert classification.support_type is SupportType.EXPLICIT


def test_nonexistent_uuid_rejected() -> None:
    candidate = ClassificationCandidate(final_uuid="missing-uuid", final_level="Term")

    result = validate_candidate(candidate, _index())

    assert result.valid is False
    assert result.classification is None
    assert _error_codes(result) == {"UNKNOWN_UUID"}


def test_root_category_rejected() -> None:
    candidate = ClassificationCandidate(
        final_uuid="EARTH SCIENCE",
        final_name="EARTH SCIENCE",
        final_level="Category",
    )

    result = validate_candidate(candidate, _index())

    assert result.valid is False
    assert "ROOT_CATEGORY_NOT_ASSIGNABLE" in _error_codes(result)


def test_uuid_name_mismatch_rejected() -> None:
    candidate = _candidate("vl2-carbon-dioxide").model_copy(update={"final_name": "METHANE"})

    result = validate_candidate(candidate, _index())

    assert "UUID_NAME_MISMATCH" in _error_codes(result)
    assert result.classification is None


def test_uuid_level_mismatch_rejected() -> None:
    candidate = _candidate("vl2-carbon-dioxide").model_copy(
        update={"final_level": "Variable_Level_1"}
    )

    result = validate_candidate(candidate, _index())

    assert "UUID_LEVEL_MISMATCH" in _error_codes(result)


def test_uuid_canonical_path_mismatch_rejected() -> None:
    candidate = _candidate("vl2-carbon-dioxide").model_copy(
        update={"final_canonical_path": "ATMOSPHERE > MADE UP"}
    )

    result = validate_candidate(candidate, _index())

    assert "UUID_CANONICAL_PATH_MISMATCH" in _error_codes(result)


def test_invalid_path_components_rejected() -> None:
    candidate = _candidate("vl2-carbon-dioxide").model_copy(
        update={"path_components": ("ATMOSPHERE", "MADE UP")}
    )

    result = validate_candidate(candidate, _index())

    assert "PATH_COMPONENTS_MISMATCH" in _error_codes(result)


def test_invalid_parent_child_chain_rejected() -> None:
    candidate = _candidate("vl2-carbon-dioxide").model_copy(
        update={"expected_parent_uuid": "term-weather-events"}
    )

    result = validate_candidate(candidate, _index())

    assert "INVALID_PARENT_CHILD_CHAIN" in _error_codes(result)


def test_wrong_topic_membership_rejected() -> None:
    candidate = _candidate("vl2-carbon-dioxide").model_copy(
        update={"topic_uuid": "topic-oceans", "topic_name": "OCEANS"}
    )

    result = validate_candidate(candidate, _index())

    assert "WRONG_TOPIC_MEMBERSHIP" in _error_codes(result)
    assert "TOPIC_NAME_MISMATCH" in _error_codes(result)


def test_wrong_term_membership_rejected() -> None:
    candidate = _candidate("vl2-carbon-dioxide").model_copy(
        update={"term_uuid": "term-weather-events", "term_name": "WEATHER EVENTS"}
    )

    result = validate_candidate(candidate, _index())

    assert "WRONG_TERM_MEMBERSHIP" in _error_codes(result)
    assert "TERM_NAME_MISMATCH" in _error_codes(result)


def test_final_concept_under_expected_topic_and_term_succeeds() -> None:
    result = validate_candidate(_candidate("vl2-carbon-dioxide"), _index())

    assert result.valid is True
    assert result.classification is not None
    assert result.classification.UUID == "vl2-carbon-dioxide"


def test_topic_level_final_classification_succeeds() -> None:
    result = validate_candidate(_candidate("topic-atmosphere"), _index())

    assert result.valid is True
    assert result.classification is not None
    assert result.classification.level == "Topic"
    assert result.classification.term is None


def test_term_level_final_classification_succeeds_from_term_branch() -> None:
    result = validate_candidate(candidate_from_term_branch(_term_branch()), _index())

    assert result.valid is True
    assert result.classification is not None
    assert result.classification.level == "Term"
    assert result.classification.UUID == "term-atmospheric-chemistry"


def test_variable_level_1_final_classification_succeeds() -> None:
    assert validate_candidate(_candidate("vl1-atmosphere-carbon"), _index()).valid is True


def test_variable_level_2_final_classification_succeeds() -> None:
    assert validate_candidate(_candidate("vl2-carbon-dioxide"), _index()).valid is True


def test_variable_level_3_final_classification_succeeds() -> None:
    assert validate_candidate(_candidate("vl3-carbon-dioxide-profiles"), _index()).valid is True


def test_rejected_invalid_candidates_do_not_appear_in_accepted_classifications() -> None:
    batch = validate_candidates(
        [
            _candidate("vl2-carbon-dioxide"),
            ClassificationCandidate(final_uuid="missing-uuid", final_level="Term"),
        ],
        _index(),
    )

    assert [record.UUID for record in batch.accepted] == ["vl2-carbon-dioxide"]
    assert batch.rejected_count == 1
    assert batch.rejected[0].deterministic_validation.errors[0].details["field"] == "final_uuid"


def test_stop_at_term_terminal_outcome_can_become_valid_term_classification() -> None:
    terminal = _terminal("term-atmospheric-chemistry", branch_id="topic:t/term:x")

    result = validate_terminal_outcomes([terminal], _index())

    assert result.accepted_count == 1
    assert result.accepted[0].level == "Term"


def test_stop_at_variable_level_1_terminal_outcome_can_become_valid_classification() -> None:
    result = validate_terminal_outcomes([_terminal("vl1-atmosphere-carbon")], _index())

    assert result.accepted[0].level == "Variable_Level_1"


def test_selected_variable_level_3_terminal_outcome_can_become_valid_classification() -> None:
    result = validate_terminal_outcomes([_terminal("vl3-carbon-dioxide-profiles")], _index())

    assert result.accepted[0].level == "Variable_Level_3"
    assert result.accepted[0].reason_for_stopping == "Deepest supported concept."


def test_multiple_terminal_outcomes_validate_independently() -> None:
    result = validate_terminal_outcomes(
        [
            _terminal("vl2-carbon-dioxide", branch_id="topic:t/term:x/variable:a"),
            _terminal("vl2-methane", branch_id="topic:t/term:x/variable:b"),
        ],
        _index(),
    )

    assert [record.UUID for record in result.accepted] == ["vl2-carbon-dioxide", "vl2-methane"]


def test_validation_does_not_require_model_call() -> None:
    result = validate_candidate(_candidate("vl2-methane"), _index())

    assert result.valid is True


def test_full_data_representative_records_pass_validation() -> None:
    index = load_vocabulary(FULL_HIERARCHY_PATH)
    representatives = []
    for level in ["Topic", "Term", "Variable_Level_1", "Variable_Level_2", "Variable_Level_3"]:
        representatives.append(next(record for record in index.records if record.level == level))

    for record in representatives:
        topic = (
            record if record.level == "Topic" else index.get(index.ancestors_of(record.UUID)[-1])
        )
        term = None
        if record.level == "Term":
            term = record
        elif record.level.startswith("Variable_Level_"):
            term = next(
                index.get(ancestor)
                for ancestor in index.ancestors_of(record.UUID)
                if index.get(ancestor).level == "Term"
            )
        candidate = ClassificationCandidate(
            final_uuid=record.UUID,
            final_name=record.name,
            final_level=record.level,
            final_canonical_path=record.canonical_path,
            path_components=record.path_components,
            topic_uuid=topic.UUID,
            topic_name=topic.name,
            term_uuid=None if term is None else term.UUID,
            term_name=None if term is None else term.name,
        )
        assert validate_candidate(candidate, index).valid is True