Coverage for narrative_harm_classifier/classifier/rules/dogwhistles.py: 96%
27 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-20 13:25 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-20 13:25 +0000
1"""
2classifier/rules/dogwhistles.py — Curated coded-language ("dog-whistle") lexicon.
4A dog-whistle match is treated as an additional signal source in the same
5scoring pipeline as a taxonomy row — see engine.py — not a separate decision
6path. It still requires an identity anchor to be present in the text (the
7same require_target_present gate applies), which limits false-positive risk
8on the more ambiguous terms in the seed list.
10See data/dogwhistles.yaml for the actual entries and their public sourcing.
11"""
13import re
14from dataclasses import dataclass
15from functools import lru_cache
17from narrative_harm_classifier.core.yaml_loader import load_yaml_file
19# identity_axis -> TargetType value, mirrors the mapping taxonomy rows use.
20AXIS_TO_TARGET_TYPE = {
21 "race_ethnicity": "ethnic_group",
22 "religion": "religious_group",
23 "gender": "gender_group",
24 "national_origin": "national_origin_group",
25 "political_affiliation": "political_group",
26}
29@dataclass(frozen=True)
30class DogwhistleEntry:
31 term: str
32 harm_mechanism: str
33 identity_axis: str
34 signal_weight: float
35 decision_threshold: float
36 source_note: str
38 @property
39 def target_type(self) -> str:
40 return AXIS_TO_TARGET_TYPE.get(self.identity_axis, "unknown")
43class DogwhistleLexicon:
44 def __init__(self, entries: list[DogwhistleEntry]):
45 self.entries = entries
46 self._pattern_to_entry: list[tuple[re.Pattern, DogwhistleEntry]] = [
47 (re.compile(r"\b" + re.escape(e.term) + r"\b", re.IGNORECASE), e) for e in entries
48 ]
50 def detect(self, text: str) -> list[DogwhistleEntry]:
51 return [entry for pattern, entry in self._pattern_to_entry if pattern.search(text)]
54@lru_cache(maxsize=4)
55def load_dogwhistles(path: str) -> DogwhistleLexicon:
56 raw = load_yaml_file(path)
57 entries = [
58 DogwhistleEntry(
59 term=e["term"],
60 harm_mechanism=e["harm_mechanism"],
61 identity_axis=e["identity_axis"],
62 signal_weight=e["signal_weight"],
63 decision_threshold=e.get("decision_threshold", e["signal_weight"]),
64 source_note=e["source_note"].strip(),
65 )
66 for e in raw.get("entries", [])
67 ]
68 return DogwhistleLexicon(entries)