GCMD_Keyword_Classifier_MVP / tests /test_validation.py
igerasimov's picture
MVP Milestone 9
57b6fe2
Raw
History Blame Contribute Delete
12.5 kB
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