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

1""" 

2classifier/rules/dogwhistles.py — Curated coded-language ("dog-whistle") lexicon. 

3 

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. 

9 

10See data/dogwhistles.yaml for the actual entries and their public sourcing. 

11""" 

12 

13import re 

14from dataclasses import dataclass 

15from functools import lru_cache 

16 

17from narrative_harm_classifier.core.yaml_loader import load_yaml_file 

18 

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} 

27 

28 

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 

37 

38 @property 

39 def target_type(self) -> str: 

40 return AXIS_TO_TARGET_TYPE.get(self.identity_axis, "unknown") 

41 

42 

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 ] 

49 

50 def detect(self, text: str) -> list[DogwhistleEntry]: 

51 return [entry for pattern, entry in self._pattern_to_entry if pattern.search(text)] 

52 

53 

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)