Legal-i commited on
Commit
f1ea47c
·
verified ·
1 Parent(s): 64969ff

Stage 187: α acceleration signal

Browse files
core/config.py CHANGED
@@ -38,16 +38,21 @@ class MetricConfig:
38
 
39
  ``direction`` — which way is bad (drives the directional Δ).
40
  ``weight`` — relative weight inside the Δ aggregate.
41
- ``feeds_stability`` — included in the ψ (stability) computation.
42
- ``feeds_anomaly`` — included in the ξ (anomaly) computation.
43
- ``latency_target`` if set, the metric contributes to γ against this SLA.
44
- ``coherence_target``— if set, distance from this target contributes to κ.
 
 
 
 
45
  """
46
  name: str
47
  direction: str
48
  weight: float = 1.0
49
  feeds_stability: bool = False
50
  feeds_anomaly: bool = True
 
51
  latency_target: Optional[float] = None
52
  coherence_target: Optional[float] = None
53
 
@@ -69,6 +74,7 @@ class MetricConfig:
69
  weight=float(d.get("weight", 1.0)),
70
  feeds_stability=bool(d.get("feeds_stability", False)),
71
  feeds_anomaly=bool(d.get("feeds_anomaly", True)),
 
72
  latency_target=_opt_float(d.get("latency_target")),
73
  coherence_target=_opt_float(d.get("coherence_target")),
74
  )
@@ -79,6 +85,7 @@ class MetricConfig:
79
  "weight": self.weight,
80
  "feeds_stability": self.feeds_stability,
81
  "feeds_anomaly": self.feeds_anomaly,
 
82
  "latency_target": self.latency_target,
83
  "coherence_target": self.coherence_target,
84
  }
 
38
 
39
  ``direction`` — which way is bad (drives the directional Δ).
40
  ``weight`` — relative weight inside the Δ aggregate.
41
+ ``feeds_stability`` — included in the ψ (stability) computation.
42
+ ``feeds_anomaly`` — included in the ξ (anomaly) computation.
43
+ ``feeds_acceleration``— included in the α (acceleration / second
44
+ derivative) computation. Off by default
45
+ so legacy configs are unaffected by
46
+ Stage 187.
47
+ ``latency_target`` — if set, the metric contributes to γ against this SLA.
48
+ ``coherence_target`` — if set, distance from this target contributes to κ.
49
  """
50
  name: str
51
  direction: str
52
  weight: float = 1.0
53
  feeds_stability: bool = False
54
  feeds_anomaly: bool = True
55
+ feeds_acceleration: bool = False
56
  latency_target: Optional[float] = None
57
  coherence_target: Optional[float] = None
58
 
 
74
  weight=float(d.get("weight", 1.0)),
75
  feeds_stability=bool(d.get("feeds_stability", False)),
76
  feeds_anomaly=bool(d.get("feeds_anomaly", True)),
77
+ feeds_acceleration=bool(d.get("feeds_acceleration", False)),
78
  latency_target=_opt_float(d.get("latency_target")),
79
  coherence_target=_opt_float(d.get("coherence_target")),
80
  )
 
85
  "weight": self.weight,
86
  "feeds_stability": self.feeds_stability,
87
  "feeds_anomaly": self.feeds_anomaly,
88
+ "feeds_acceleration": self.feeds_acceleration,
89
  "latency_target": self.latency_target,
90
  "coherence_target": self.coherence_target,
91
  }
core/drift.py CHANGED
@@ -25,6 +25,11 @@ DEFAULT_DRIFT_WEIGHTS = {
25
  "xi": 0.25,
26
  "gamma": 0.15,
27
  "kappa": 0.15,
 
 
 
 
 
28
  }
29
  DEFAULT_SEVERITY_THRESHOLDS = {
30
  "critical": 0.75,
@@ -47,12 +52,14 @@ def drift_score(signals: Dict[str, float], weights: Dict[str, float] = None) ->
47
  xi = signals.get("xi", 0.0)
48
  gamma = signals.get("gamma", 0.0)
49
  kappa = signals.get("kappa", 1.0)
 
50
  return (
51
  w.get("delta", DEFAULT_DRIFT_WEIGHTS["delta"]) * delta +
52
  w.get("psi", DEFAULT_DRIFT_WEIGHTS["psi"]) * (1.0 - psi) +
53
  w.get("xi", DEFAULT_DRIFT_WEIGHTS["xi"]) * xi +
54
  w.get("gamma", DEFAULT_DRIFT_WEIGHTS["gamma"]) * gamma +
55
- w.get("kappa", DEFAULT_DRIFT_WEIGHTS["kappa"]) * (1.0 - kappa)
 
56
  )
57
 
58
 
 
25
  "xi": 0.25,
26
  "gamma": 0.15,
27
  "kappa": 0.15,
28
+ # Stage 187 — α (acceleration / second derivative). Default
29
+ # weight 0 so the signal is fully opt-in via entity_type
30
+ # config — adding the signal must not silently shift drift
31
+ # scores on any pre-existing tenant.
32
+ "alpha": 0.0,
33
  }
34
  DEFAULT_SEVERITY_THRESHOLDS = {
35
  "critical": 0.75,
 
52
  xi = signals.get("xi", 0.0)
53
  gamma = signals.get("gamma", 0.0)
54
  kappa = signals.get("kappa", 1.0)
55
+ alpha = signals.get("alpha", 0.0) # Stage 187
56
  return (
57
  w.get("delta", DEFAULT_DRIFT_WEIGHTS["delta"]) * delta +
58
  w.get("psi", DEFAULT_DRIFT_WEIGHTS["psi"]) * (1.0 - psi) +
59
  w.get("xi", DEFAULT_DRIFT_WEIGHTS["xi"]) * xi +
60
  w.get("gamma", DEFAULT_DRIFT_WEIGHTS["gamma"]) * gamma +
61
+ w.get("kappa", DEFAULT_DRIFT_WEIGHTS["kappa"]) * (1.0 - kappa) +
62
+ w.get("alpha", DEFAULT_DRIFT_WEIGHTS["alpha"]) * alpha
63
  )
64
 
65
 
core/pipeline.py CHANGED
@@ -36,6 +36,7 @@ from .config import HIGHER_IS_WORSE, EntityTypeConfig
36
  from .decisions import DecisionItem, recommendation_for_issue
37
  from .drift import DriftIssue, drift_score, severity_from_score
38
  from .signals import (
 
39
  anomaly_xi,
40
  health_omega,
41
  latency_gamma,
@@ -124,6 +125,10 @@ def _entity_states(entity_id: str, obs: List[Observation],
124
  psi_candidates: List[float] = []
125
  gamma_candidates: List[float] = []
126
  coherence_violations: List[float] = []
 
 
 
 
127
 
128
  for m in config.metrics:
129
  series = history[m.name]
@@ -145,6 +150,11 @@ def _entity_states(entity_id: str, obs: List[Observation],
145
  xi_candidates.append(anomaly_xi(value, hist))
146
  if m.feeds_stability and len(recent) >= 2:
147
  psi_candidates.append(stability_psi(recent))
 
 
 
 
 
148
  if m.latency_target is not None:
149
  gamma_candidates.append(latency_gamma(value, m.latency_target))
150
  if m.coherence_target is not None:
@@ -159,10 +169,15 @@ def _entity_states(entity_id: str, obs: List[Observation],
159
  gamma = max(gamma_candidates) if gamma_candidates else 0.0
160
  kappa = (1.0 - safe_mean(coherence_violations)) if coherence_violations else 1.0
161
  kappa = max(0.0, min(1.0, kappa))
 
 
 
 
162
  omega = health_omega(delta, psi, xi, gamma, kappa)
163
 
164
  signals = {"delta": delta, "psi": psi, "xi": xi,
165
- "gamma": gamma, "kappa": kappa, "omega": omega}
 
166
  score = drift_score(signals, weights=config.signal_weights)
167
 
168
  # confidence grounded in how much history we have (Stage 1 improvement)
@@ -215,6 +230,22 @@ def _evidence_for_state(state: Dict, config: EntityTypeConfig) -> List[Dict]:
215
  "truth_type": "soft_truth",
216
  "confidence": state["confidence"],
217
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  return evidence
219
 
220
 
@@ -267,7 +298,8 @@ def run_pipeline(observations: List[Observation],
267
  f"health Ω={state['signals']['omega']}. Signals — "
268
  f"Δ={state['signals']['delta']}, ψ={state['signals']['psi']}, "
269
  f"ξ={state['signals']['xi']}, γ={state['signals']['gamma']}, "
270
- f"κ={state['signals']['kappa']}."
 
271
  ),
272
  evidence=evidence,
273
  )
 
36
  from .decisions import DecisionItem, recommendation_for_issue
37
  from .drift import DriftIssue, drift_score, severity_from_score
38
  from .signals import (
39
+ acceleration_alpha,
40
  anomaly_xi,
41
  health_omega,
42
  latency_gamma,
 
125
  psi_candidates: List[float] = []
126
  gamma_candidates: List[float] = []
127
  coherence_violations: List[float] = []
128
+ # Stage 187 — α (acceleration) candidates. Stays empty for
129
+ # legacy configs (no metric has feeds_acceleration=True), so
130
+ # alpha is 0.0 and nothing about existing behaviour shifts.
131
+ alpha_candidates: List[float] = []
132
 
133
  for m in config.metrics:
134
  series = history[m.name]
 
150
  xi_candidates.append(anomaly_xi(value, hist))
151
  if m.feeds_stability and len(recent) >= 2:
152
  psi_candidates.append(stability_psi(recent))
153
+ if getattr(m, "feeds_acceleration", False):
154
+ baseline_window = hist[:cut]
155
+ if len(baseline_window) >= 3 and len(recent) >= 3:
156
+ alpha_candidates.append(acceleration_alpha(
157
+ baseline_window, recent, m.direction))
158
  if m.latency_target is not None:
159
  gamma_candidates.append(latency_gamma(value, m.latency_target))
160
  if m.coherence_target is not None:
 
169
  gamma = max(gamma_candidates) if gamma_candidates else 0.0
170
  kappa = (1.0 - safe_mean(coherence_violations)) if coherence_violations else 1.0
171
  kappa = max(0.0, min(1.0, kappa))
172
+ # Stage 187 — α (acceleration). Max across all opted-in
173
+ # metrics: a single accelerating metric is enough to fire,
174
+ # mirroring how ξ (anomaly) and γ (latency) aggregate.
175
+ alpha = max(alpha_candidates) if alpha_candidates else 0.0
176
  omega = health_omega(delta, psi, xi, gamma, kappa)
177
 
178
  signals = {"delta": delta, "psi": psi, "xi": xi,
179
+ "gamma": gamma, "kappa": kappa, "alpha": alpha,
180
+ "omega": omega}
181
  score = drift_score(signals, weights=config.signal_weights)
182
 
183
  # confidence grounded in how much history we have (Stage 1 improvement)
 
230
  "truth_type": "soft_truth",
231
  "confidence": state["confidence"],
232
  })
233
+ # Stage 187 — α (acceleration). Only surfaces when at least one
234
+ # metric is opted in AND it's actually accelerating. The 0.5
235
+ # threshold means the recent slope is ≥ 2× baseline slope before
236
+ # it shows up as evidence — that's a meaningful "death-spiral"
237
+ # signal, not noise on a near-flat baseline.
238
+ if sig.get("alpha", 0.0) >= 0.5:
239
+ evidence.append({
240
+ "label": "acceleration",
241
+ "summary": (
242
+ f"Acceleration α={sig['alpha']} — the rate of "
243
+ "degradation is itself increasing (recent slope > "
244
+ "baseline slope in the adverse direction)."
245
+ ),
246
+ "truth_type": "hard_truth",
247
+ "confidence": state["confidence"],
248
+ })
249
  return evidence
250
 
251
 
 
298
  f"health Ω={state['signals']['omega']}. Signals — "
299
  f"Δ={state['signals']['delta']}, ψ={state['signals']['psi']}, "
300
  f"ξ={state['signals']['xi']}, γ={state['signals']['gamma']}, "
301
+ f"κ={state['signals']['kappa']}, "
302
+ f"α={state['signals'].get('alpha', 0.0)}."
303
  ),
304
  evidence=evidence,
305
  )
core/signals.py CHANGED
@@ -81,3 +81,78 @@ def health_omega(delta: float, psi: float, xi: float, gamma: float, kappa: float
81
  "coherence": 1.0,
82
  }
83
  return weighted_geometric_mean(values, weights)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  "coherence": 1.0,
82
  }
83
  return weighted_geometric_mean(values, weights)
84
+
85
+
86
+ # --- α (alpha) — trend acceleration -----------------------------------
87
+ #
88
+ # Stage 187. The other 5 signals catch: "X% worse than baseline" (δ),
89
+ # "today spiked" (ξ), "missed SLA" (γ), "stability dropped" (ψ),
90
+ # "coherence dropped" (κ). None of them catch the second derivative —
91
+ # "the RATE of degradation is itself accelerating." A customer dropping
92
+ # 2%/wk is bad; one dropping 2 → 5 → 9%/wk is on a death spiral and
93
+ # needs a different intervention than the steady decliner.
94
+ #
95
+ # Implementation:
96
+ # 1. Fit a least-squares linear slope to the baseline window.
97
+ # 2. Fit a least-squares linear slope to the recent window.
98
+ # 3. If recent slope is adverse AND its magnitude exceeds the
99
+ # baseline slope, output the magnitude ratio normalised into
100
+ # [0, 1] via 1 - 1/(1+excess).
101
+ #
102
+ # Returns 0.0 (no acceleration) when:
103
+ # - either window has < 3 points (slope undefined),
104
+ # - the recent slope direction is NOT adverse,
105
+ # - the recent slope magnitude doesn't exceed baseline.
106
+
107
+
108
+ def _linear_slope(values: List[float]) -> float:
109
+ """Least-squares slope on uniformly-spaced x. Returns 0 if the
110
+ series is too short or has zero spread on x."""
111
+ n = len(values)
112
+ if n < 2:
113
+ return 0.0
114
+ xs = list(range(n))
115
+ x_mean = (n - 1) / 2.0
116
+ y_mean = sum(values) / n
117
+ num = sum((xs[i] - x_mean) * (values[i] - y_mean) for i in range(n))
118
+ den = sum((xs[i] - x_mean) ** 2 for i in range(n))
119
+ if den < 1e-12:
120
+ return 0.0
121
+ return num / den
122
+
123
+
124
+ def acceleration_alpha(baseline: List[float],
125
+ recent: List[float],
126
+ direction: str) -> float:
127
+ """The α signal. ``direction`` is "higher_is_worse" or
128
+ "lower_is_worse" — fires only when the recent slope is in the
129
+ adverse direction AND steeper than the baseline slope.
130
+
131
+ Output ∈ [0, 1]:
132
+ excess = max(0, |recent| - |baseline|) / max(|baseline|, eps)
133
+ alpha = 1 - 1/(1 + excess)
134
+ excess=0 → 0; excess=1 (recent is 2× baseline) → 0.5;
135
+ excess→∞ → 1. Saturates softly so one-off outliers don't max out.
136
+ """
137
+ if len(baseline) < 3 or len(recent) < 3:
138
+ return 0.0
139
+ b_clean = [v for v in baseline if v is not None]
140
+ r_clean = [v for v in recent if v is not None]
141
+ if len(b_clean) < 3 or len(r_clean) < 3:
142
+ return 0.0
143
+ s_base = _linear_slope(b_clean)
144
+ s_rec = _linear_slope(r_clean)
145
+ if direction == "higher_is_worse":
146
+ adverse_recent = s_rec > 0
147
+ elif direction == "lower_is_worse":
148
+ adverse_recent = s_rec < 0
149
+ else:
150
+ return 0.0
151
+ if not adverse_recent:
152
+ return 0.0
153
+ abs_base = abs(s_base)
154
+ abs_rec = abs(s_rec)
155
+ if abs_rec <= abs_base:
156
+ return 0.0
157
+ excess = (abs_rec - abs_base) / max(abs_base, 1e-6)
158
+ return 1.0 - 1.0 / (1.0 + excess)
verticals/pharma/config.yaml CHANGED
@@ -18,13 +18,22 @@ entity_types:
18
  baseline_window: 14
19
  baseline_lag: 7
20
  recent_window: 14
 
 
 
 
 
21
  metrics:
22
  # δ — drop in prescription fill (the pharmacy's revenue + duty
23
  # of care signal).
 
 
 
24
  prescription_fill_count:
25
  direction: lower_is_worse
26
  weight: 0.25
27
  feeds_anomaly: true
 
28
  # ψ — spike in rejected scripts (insurance / formulary issues
29
  # or a system fault).
30
  rejection_rate:
 
18
  baseline_window: 14
19
  baseline_lag: 7
20
  recent_window: 14
21
+ # Stage 187 — α (acceleration) weight 0.10 for pharmacies. A
22
+ # pharmacy losing prescription fills faster every week is at
23
+ # acute risk; weighted 10% of the drift score.
24
+ signal_weights:
25
+ alpha: 0.10
26
  metrics:
27
  # δ — drop in prescription fill (the pharmacy's revenue + duty
28
  # of care signal).
29
+ # α — Stage 187: prescription fill accelerating downward is a
30
+ # death-spiral signal — customers are leaving for a competitor
31
+ # pharmacy faster every week, not just slowly drifting.
32
  prescription_fill_count:
33
  direction: lower_is_worse
34
  weight: 0.25
35
  feeds_anomaly: true
36
+ feeds_acceleration: true
37
  # ψ — spike in rejected scripts (insurance / formulary issues
38
  # or a system fault).
39
  rejection_rate:
verticals/retail/config.yaml CHANGED
@@ -21,6 +21,12 @@ entity_types:
21
  baseline_window: 14
22
  baseline_lag: 7
23
  recent_window: 14
 
 
 
 
 
 
24
  metrics:
25
  # δ — sales vs baseline. The headline retail signal.
26
  sales:
@@ -47,11 +53,15 @@ entity_types:
47
  feeds_stability: true
48
  latency_target: 4.0 # SLA: 4 min avg checkout
49
  # κ — conversion volatility (footfall → buyer ratio).
 
 
 
50
  conversion:
51
  direction: lower_is_worse
52
  weight: 0.20
53
  feeds_anomaly: true
54
  feeds_stability: true
 
55
  coherence_target: 0.18 # expected conversion 18%
56
 
57
  distribution:
 
21
  baseline_window: 14
22
  baseline_lag: 7
23
  recent_window: 14
24
+ # Stage 187 — α (acceleration) weight 0.10 for retail stores.
25
+ # Conversion accelerating downward is a real death-spiral signal
26
+ # in this vertical, so the 6th signal contributes ~10% of the
27
+ # drift score. Default 0.0 stays untouched on legacy verticals.
28
+ signal_weights:
29
+ alpha: 0.10
30
  metrics:
31
  # δ — sales vs baseline. The headline retail signal.
32
  sales:
 
53
  feeds_stability: true
54
  latency_target: 4.0 # SLA: 4 min avg checkout
55
  # κ — conversion volatility (footfall → buyer ratio).
56
+ # α — Stage 187: conversion accelerating downward = acute
57
+ # death-spiral signal. A chain dropping conversion 1pp/wk
58
+ # is bad; one dropping 1 → 3 → 6pp/wk needs intervention now.
59
  conversion:
60
  direction: lower_is_worse
61
  weight: 0.20
62
  feeds_anomaly: true
63
  feeds_stability: true
64
+ feeds_acceleration: true
65
  coherence_target: 0.18 # expected conversion 18%
66
 
67
  distribution: