Source code for aeat.domain.calculations.registry._validate_semantic_role_typos

"""Semantic-role typo warning helpers."""

from __future__ import annotations

import warnings
from collections import defaultdict
from collections.abc import Iterable, Mapping, Sequence
from difflib import SequenceMatcher
from functools import lru_cache
from typing import NamedTuple, Protocol

from ._ids import CasillaId
from ._validate_semantic_role_axes import (
    semantic_roles_are_axis_siblings,
    semantic_roles_are_tax_domain_siblings,
)


class _RoleObservationLike(Protocol):
    modelo_id: str
    revision_id: str
    casilla_id: CasillaId
    semantic_role_cardinality: str


[docs] def emit_grouped_semantic_role_typo_twin_warnings( grouped: Mapping[str, Sequence[_RoleObservationLike]], ) -> None: """Warn when a ``semantic_role`` value appears on exactly one casilla.""" for failure in grouped_semantic_role_typo_twin_failures(grouped): warnings.warn(failure, stacklevel=2)
[docs] def grouped_semantic_role_typo_twin_failures( grouped: Mapping[str, Sequence[_RoleObservationLike]], ) -> tuple[str, ...]: """Return failures for singleton ``semantic_role`` values that look like typos.""" typo_index = _build_semantic_role_typo_index(grouped.keys()) failures: list[str] = [] for role, observations in grouped.items(): if len(observations) != 1: continue obs = observations[0] if obs.semantic_role_cardinality == "intentional_singleton": continue if not _semantic_role_looks_like_typo(role, typo_index): continue failures.append(_format_semantic_role_typo_twin_failure(role, obs)) return tuple(failures)
def _format_semantic_role_typo_twin_failure(role: str, obs: _RoleObservationLike) -> str: return ( f"semantic_role {role!r} appears on exactly one casilla " f"({obs.modelo_id}.{obs.revision_id}.{obs.casilla_id}); " "likely typo or missing role declarations on sibling casillas" ) class _SemanticRoleTypoIndex(NamedTuple): normalised: Mapping[str, tuple[str, ...]] by_length: Mapping[int, tuple[str, ...]] lengths: tuple[int, ...] sets: Mapping[str, set[str]] _SEMANTIC_ROLE_TYPO_RATIO = 0.92 def _build_semantic_role_typo_index(known_roles: Iterable[str]) -> _SemanticRoleTypoIndex: normalised: dict[str, list[str]] = defaultdict(list) by_length: dict[int, list[str]] = defaultdict(list) sets: dict[str, set[str]] = {} for known in known_roles: normalised[known.replace("-", "_")].append(known) by_length[len(known)].append(known) sets[known] = set(known) return _SemanticRoleTypoIndex( normalised={key: tuple(values) for key, values in normalised.items()}, by_length={key: tuple(values) for key, values in by_length.items()}, lengths=tuple(sorted(by_length)), sets=sets, ) def _fast_similarity_check(s1: str, s2: str, max_diff: int) -> bool: i = 0 min_len = min(len(s1), len(s2)) while i < min_len and s1[i] == s2[i]: i += 1 j = 0 remaining1 = len(s1) - i remaining2 = len(s2) - i min_remaining = min(remaining1, remaining2) while j < min_remaining and s1[-1 - j] == s2[-1 - j]: j += 1 return (i + j) >= min_len - max_diff @lru_cache(maxsize=8192) def _get_ratio(role1: str, role2: str) -> float: matcher = SequenceMatcher(None, role1, role2) if matcher.real_quick_ratio() < _SEMANTIC_ROLE_TYPO_RATIO: return 0.0 if matcher.quick_ratio() < _SEMANTIC_ROLE_TYPO_RATIO: return 0.0 return matcher.ratio() def _semantic_role_looks_like_typo(role: str, index: _SemanticRoleTypoIndex) -> bool: if "-" in role: return True normalised = role.replace("-", "_") for known in index.normalised.get(normalised, ()): if known == role: continue return True return _scan_length_buckets_for_typo_twin(role, index) def _scan_length_buckets_for_typo_twin(role: str, index: _SemanticRoleTypoIndex) -> bool: """Scan the length-bucketed known roles for a near-duplicate non-sibling twin.""" role_length = len(role) role_set = set(role) for known_length in index.lengths: if _max_sequence_match_ratio(role_length, known_length) < _SEMANTIC_ROLE_TYPO_RATIO: continue max_diff = int(0.08 * (role_length + known_length)) for known in index.by_length[known_length]: if _candidate_is_typo_twin(role, role_set, role_length, known, known_length, max_diff, index): return True return False def _candidate_is_typo_twin( role: str, role_set: set[str], role_length: int, known: str, known_length: int, max_diff: int, index: _SemanticRoleTypoIndex, ) -> bool: """Return whether ``known`` is a near-duplicate typo twin of ``role``. Applies, in order, the cheap-to-expensive filter chain: identity skip, the fast O(N) prefix/suffix check, the unique-character-set filter, the SequenceMatcher ratio, then the tax-domain and axis sibling exemptions. """ if known == role: return False # Run the fast O(N) prefix/suffix check first if not _fast_similarity_check(role, known, max_diff): return False # Unique character set check as a secondary filter known_set = index.sets[known] max_edits = 0.08 * (role_length + known_length) if len(role_set & known_set) < max(len(role_set), len(known_set)) - max_edits: return False if _get_ratio(role, known) < _SEMANTIC_ROLE_TYPO_RATIO: return False # Only run sibling checks on potential typo matches! if semantic_roles_are_tax_domain_siblings(role, known): return False return not semantic_roles_are_axis_siblings(role, known) def _max_sequence_match_ratio(left_length: int, right_length: int) -> float: if left_length == 0 and right_length == 0: return 1.0 return (2 * min(left_length, right_length)) / (left_length + right_length)