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