igerasimov's picture
MVP Milestone 9
57b6fe2
Raw
History Blame Contribute Delete
6.81 kB
"""Redundancy removal for validated GCMD classification records."""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from gcmd_classifier.models import ClassificationRecord, OutputWarning
from gcmd_classifier.vocabulary.index import VocabularyIndex
_STAGE = "redundancy_removal"
class RedundancyResult(BaseModel):
"""Non-redundant classifications plus structured redundancy diagnostics."""
model_config = ConfigDict(extra="forbid", frozen=True)
classifications: tuple[ClassificationRecord, ...] = Field(default_factory=tuple)
removed: tuple[ClassificationRecord, ...] = Field(default_factory=tuple)
warnings: tuple[OutputWarning, ...] = Field(default_factory=tuple)
@property
def retained_count(self) -> int:
"""Number of retained classifications."""
return len(self.classifications)
@property
def removed_count(self) -> int:
"""Number of removed redundant classifications."""
return len(self.removed)
def remove_redundant_classifications(
records: tuple[ClassificationRecord, ...] | list[ClassificationRecord],
vocabulary: VocabularyIndex,
) -> RedundancyResult:
"""Remove exact duplicates and branch-provenance ancestor redundancy."""
retained: list[ClassificationRecord] = []
removed: list[ClassificationRecord] = []
warnings: list[OutputWarning] = []
seen_uuids: dict[str, ClassificationRecord] = {}
seen_paths: dict[str, ClassificationRecord] = {}
for record in records:
if record.UUID in seen_uuids:
removed.append(record)
warnings.append(
_warning(
"DUPLICATE_UUID_REMOVED",
"Duplicate UUID classification was removed.",
record=record,
details={"duplicate_of_branch_id": seen_uuids[record.UUID].branch_id},
)
)
continue
if record.canonical_path in seen_paths:
removed.append(record)
warnings.append(
_warning(
"DUPLICATE_CANONICAL_PATH_REMOVED",
"Duplicate canonical-path classification was removed.",
record=record,
details={"duplicate_of_branch_id": seen_paths[record.canonical_path].branch_id},
)
)
continue
seen_uuids[record.UUID] = record
seen_paths[record.canonical_path] = record
retained.append(record)
retained, ancestry_removed, ancestry_warnings = _remove_branch_ancestors(retained, vocabulary)
removed.extend(ancestry_removed)
warnings.extend(ancestry_warnings)
return RedundancyResult(
classifications=tuple(retained),
removed=tuple(removed),
warnings=tuple(warnings),
)
def _remove_branch_ancestors(
records: list[ClassificationRecord],
vocabulary: VocabularyIndex,
) -> tuple[list[ClassificationRecord], list[ClassificationRecord], list[OutputWarning]]:
to_remove: set[int] = set()
removed: list[ClassificationRecord] = []
warnings: list[OutputWarning] = []
for ancestor_index, ancestor in enumerate(records):
for descendant_index, descendant in enumerate(records):
if ancestor_index == descendant_index:
continue
if not vocabulary.is_ancestor(ancestor.UUID, descendant.UUID):
continue
relation = _branch_relation(ancestor.branch_id, descendant.branch_id)
if relation == "same_lineage":
if ancestor_index not in to_remove:
to_remove.add(ancestor_index)
removed.append(ancestor)
warnings.append(
_warning(
"ANCESTOR_REMOVED_SAME_BRANCH",
(
"Ancestor classification was removed because a deeper descendant "
"exists in the same branch lineage."
),
record=ancestor,
details={
"descendant_uuid": descendant.UUID,
"descendant_branch_id": descendant.branch_id,
},
)
)
elif relation == "independent":
warnings.append(
_warning(
"ANCESTOR_PRESERVED_INDEPENDENT_BRANCH",
(
"Ancestor classification was preserved because branch provenance "
"indicates an independent classification decision."
),
record=ancestor,
details={
"descendant_uuid": descendant.UUID,
"descendant_branch_id": descendant.branch_id,
},
)
)
else:
warnings.append(
_warning(
"ANCESTOR_PRESERVED_INSUFFICIENT_PROVENANCE",
(
"Ancestor classification was preserved because branch provenance "
"was insufficient to prove redundancy."
),
record=ancestor,
details={
"descendant_uuid": descendant.UUID,
"descendant_branch_id": descendant.branch_id,
},
)
)
retained = [record for index, record in enumerate(records) if index not in to_remove]
return retained, removed, warnings
def _branch_relation(ancestor_branch_id: str | None, descendant_branch_id: str | None) -> str:
if not ancestor_branch_id or not descendant_branch_id:
return "insufficient"
if ancestor_branch_id == descendant_branch_id:
return "same_lineage"
if descendant_branch_id.startswith(f"{ancestor_branch_id}/"):
return "same_lineage"
if ancestor_branch_id.startswith(f"{descendant_branch_id}/"):
return "same_lineage"
return "independent"
def _warning(
code: str,
message: str,
*,
record: ClassificationRecord,
details: dict | None = None,
) -> OutputWarning:
warning_details = {
"UUID": record.UUID,
"canonical_path": record.canonical_path,
"branch_id": record.branch_id,
}
if details:
warning_details.update(details)
return OutputWarning(
code=code,
message=message,
stage=_STAGE,
details=warning_details,
)