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