Coverage for narrative_harm_classifier/classifier/tracking/tracker.py: 98%
59 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/tracking/tracker.py — Escalation-chain scoring.
4Computes trend and risk level from simple, explainable arithmetic over a
5rolling window of observations (first-half vs second-half average severity
6delta), rather than a learned model — consistent with the rest of this
7project's rationale-driven design.
8"""
10from narrative_harm_classifier.core.models import ClassifyRequest
11from narrative_harm_classifier.classifier.rules.engine import ClassificationEngine
12from narrative_harm_classifier.classifier.provenance import GENESIS_HASH, record_hash as compute_record_hash
13from narrative_harm_classifier.classifier.tracking.models import (
14 ChainVerification,
15 Observation,
16 SourceProfile,
17 SeverityLevel,
18 severity_for_mechanism,
19 risk_level_for,
20)
21from narrative_harm_classifier.classifier.tracking.store import TrackingStore
23# Minimum average-severity delta (second half vs first half) to call a trend
24# escalating/de-escalating rather than stable. Chosen to require at least
25# roughly one severity-level shift on average across the window.
26TREND_THRESHOLD = 0.25
28DEFAULT_WINDOW = 20
31class EscalationTracker:
32 def __init__(self, engine: ClassificationEngine, store: TrackingStore):
33 self.engine = engine
34 self.store = store
36 def observe(self, source_id: str, request: ClassifyRequest) -> Observation:
37 result = self.engine.classify(request)
38 severity = severity_for_mechanism(result.harm_mechanism)
39 observed_at = result.classified_at
41 prev_hash = self.store.last_record_hash(source_id)
42 this_record_hash = compute_record_hash(
43 prev_hash=prev_hash,
44 source_id=source_id,
45 text_excerpt=result.text_excerpt,
46 is_harmful=result.is_harmful,
47 harm_mechanism=result.harm_mechanism,
48 confidence=result.confidence,
49 observed_at=observed_at,
50 content_hash_value=result.content_hash,
51 )
53 obs = Observation(
54 source_id=source_id,
55 text_excerpt=result.text_excerpt,
56 is_harmful=result.is_harmful,
57 harm_category=result.harm_category,
58 harm_mechanism=result.harm_mechanism,
59 confidence=result.confidence,
60 severity=severity,
61 observed_at=observed_at,
62 content_hash=result.content_hash,
63 prev_hash=prev_hash,
64 record_hash=this_record_hash,
65 )
66 return self.store.add_observation(obs)
68 def verify_chain(self, source_id: str) -> ChainVerification:
69 """
70 Recompute the hash chain for a source's full observation history and
71 confirm every record's stored record_hash matches what it should be
72 given the previous record's hash — detects tampering with any
73 historical record, though it does not prevent it.
74 """
75 history = self.store.history(source_id)
76 if not history:
77 return ChainVerification(source_id=source_id, observation_count=0, intact=True)
79 expected_prev = GENESIS_HASH
80 for obs in history:
81 expected_hash = compute_record_hash(
82 prev_hash=expected_prev,
83 source_id=obs.source_id,
84 text_excerpt=obs.text_excerpt,
85 is_harmful=obs.is_harmful,
86 harm_mechanism=obs.harm_mechanism,
87 confidence=obs.confidence,
88 observed_at=obs.observed_at,
89 content_hash_value=obs.content_hash,
90 )
91 if obs.prev_hash != expected_prev or obs.record_hash != expected_hash:
92 return ChainVerification(
93 source_id=source_id,
94 observation_count=len(history),
95 intact=False,
96 first_broken_id=obs.id,
97 )
98 expected_prev = obs.record_hash
100 return ChainVerification(source_id=source_id, observation_count=len(history), intact=True)
102 def profile(self, source_id: str, window: int = DEFAULT_WINDOW) -> SourceProfile:
103 history = self.store.history(source_id, limit=window)
105 if not history:
106 return SourceProfile(
107 source_id=source_id,
108 observation_count=0,
109 current_severity=SeverityLevel.NONE,
110 rolling_avg_severity=0.0,
111 trend="insufficient_data",
112 risk_level="low",
113 history=[],
114 )
116 severities = [int(o.severity) for o in history]
117 current_severity = history[-1].severity
118 rolling_avg = sum(severities) / len(severities)
120 trend = self._compute_trend(severities)
121 risk_level = risk_level_for(current_severity, trend)
123 return SourceProfile(
124 source_id=source_id,
125 observation_count=len(history),
126 current_severity=current_severity,
127 rolling_avg_severity=round(rolling_avg, 3),
128 trend=trend,
129 risk_level=risk_level,
130 history=history,
131 )
133 def list_profiles(self, window: int = DEFAULT_WINDOW) -> list[SourceProfile]:
134 profiles = [self.profile(sid, window=window) for sid in self.store.list_source_ids()]
135 profiles.sort(key=lambda p: (p.current_severity, p.rolling_avg_severity), reverse=True)
136 return profiles
138 @staticmethod
139 def _compute_trend(severities: list[int]) -> str:
140 if len(severities) < 2:
141 return "insufficient_data"
143 midpoint = len(severities) // 2
144 first_half = severities[:midpoint] if midpoint > 0 else severities[:1]
145 second_half = severities[midpoint:]
147 first_avg = sum(first_half) / len(first_half)
148 second_avg = sum(second_half) / len(second_half)
149 delta = second_avg - first_avg
151 if delta >= TREND_THRESHOLD:
152 return "escalating"
153 if delta <= -TREND_THRESHOLD:
154 return "de-escalating"
155 return "stable"