arbiter-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. arbiter_engine/__init__.py +31 -0
  2. arbiter_engine/api.py +473 -0
  3. arbiter_engine/axiom_thresholds.py +118 -0
  4. arbiter_engine/envelope.py +270 -0
  5. arbiter_engine/examples/water_tank.yaml +112 -0
  6. arbiter_engine/fire_frequency.py +252 -0
  7. arbiter_engine/history/__init__.py +12 -0
  8. arbiter_engine/history/observation.py +596 -0
  9. arbiter_engine/history/observation_production.py +335 -0
  10. arbiter_engine/history/observation_source_wiring.py +120 -0
  11. arbiter_engine/history/readiness.py +288 -0
  12. arbiter_engine/interfaces.py +1353 -0
  13. arbiter_engine/mcp/__init__.py +1 -0
  14. arbiter_engine/mcp/server.py +221 -0
  15. arbiter_engine/ontology/__init__.py +18 -0
  16. arbiter_engine/ontology/axiom_verdicts_production.py +332 -0
  17. arbiter_engine/ontology/axioms/__init__.py +26 -0
  18. arbiter_engine/ontology/axioms/boundedness.py +340 -0
  19. arbiter_engine/ontology/axioms/connectivity.py +396 -0
  20. arbiter_engine/ontology/axioms/conservation.py +266 -0
  21. arbiter_engine/ontology/axioms/consistency.py +422 -0
  22. arbiter_engine/ontology/axioms/extensions.py +106 -0
  23. arbiter_engine/ontology/axioms/homeostasis.py +783 -0
  24. arbiter_engine/ontology/axioms/monotonicity.py +444 -0
  25. arbiter_engine/ontology/axioms/responsiveness.py +752 -0
  26. arbiter_engine/ontology/axioms/roles.py +207 -0
  27. arbiter_engine/ontology/axioms/stability.py +327 -0
  28. arbiter_engine/ontology/domain_loader.py +485 -0
  29. arbiter_engine/ontology/loader.py +802 -0
  30. arbiter_engine/ontology/reasoner.py +1069 -0
  31. arbiter_engine/propagation/__init__.py +1 -0
  32. arbiter_engine/propagation/impact_estimator.py +305 -0
  33. arbiter_engine/propagation/lp_confidence.py +123 -0
  34. arbiter_engine/propagation/mcts_root_cause.py +266 -0
  35. arbiter_engine/propagation/root_cause.py +559 -0
  36. arbiter_engine/propagation/weight_learner.py +216 -0
  37. arbiter_engine/rca/__init__.py +4 -0
  38. arbiter_engine/rca/greedy_set_cover.py +291 -0
  39. arbiter_engine/residual/__init__.py +1 -0
  40. arbiter_engine/residual/predict_vs_mirror.py +542 -0
  41. arbiter_engine/temporal/__init__.py +1 -0
  42. arbiter_engine/temporal/temporal_edge.py +584 -0
  43. arbiter_engine/temporal/trend_projection.py +430 -0
  44. arbiter_engine/twin/__init__.py +23 -0
  45. arbiter_engine/twin/action_clears_problem.py +217 -0
  46. arbiter_engine/twin/builder.py +479 -0
  47. arbiter_engine/twin/gap.py +132 -0
  48. arbiter_engine/twin/hypothesis_generator.py +530 -0
  49. arbiter_engine/twin/hypothesis_production.py +236 -0
  50. arbiter_engine/twin/kernel_pipeline_executor.py +571 -0
  51. arbiter_engine/twin/monte_carlo_predictor.py +1001 -0
  52. arbiter_engine/twin/optimization_production.py +233 -0
  53. arbiter_engine/twin/pipeline_production.py +176 -0
  54. arbiter_engine/twin/topology.py +450 -0
  55. arbiter_engine/twin/topology_optimizer.py +412 -0
  56. arbiter_engine/twin/traverser.py +1260 -0
  57. arbiter_engine/twin/traverser_production.py +283 -0
  58. arbiter_engine/types.py +427 -0
  59. arbiter_engine-0.1.0.dist-info/METADATA +278 -0
  60. arbiter_engine-0.1.0.dist-info/RECORD +63 -0
  61. arbiter_engine-0.1.0.dist-info/WHEEL +4 -0
  62. arbiter_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
  63. arbiter_engine-0.1.0.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,270 @@
1
+ """The three-part response envelope.
2
+
3
+ Every other agent tool in the ecosystem returns findings and stops. This one
4
+ returns what it checked, what it could not see, and what it needs to know
5
+ next. That sentence is the whole differentiator, and this module is it made
6
+ mechanical rather than aspirational.
7
+
8
+ Three legs, each with a distinct source:
9
+
10
+ ``checked``
11
+ How many (axiom, entity, indicator) evaluations the pass attempted, and
12
+ over how many entities. Comes from ``DetectionResult.evaluations_attempted``
13
+ which had to be added — findings and declines were countable,
14
+ but their sum is *not* the total, because an evaluation that ran and found
15
+ nothing appears in neither.
16
+
17
+ ``not_checked``
18
+ What was declined and why, from the ``NotEvaluated`` records. Empty
19
+ is a real answer: it means every declared axiom was actually evaluated.
20
+
21
+ ``questions``
22
+ What the model is missing, from the DISCOVER-mode gap surface. Optional —
23
+ a caller with no topology supplies none, and the leg is then empty rather
24
+ than absent, because "no questions" and "questions not gathered" are
25
+ different and the ``meta.source`` field says which.
26
+
27
+ **Envelope vocabulary is inherited, not invented.** ``source`` takes the same
28
+ three values the established pattern established across ~50 endpoint modules —
29
+ ``live`` / ``warming_up`` / ``unavailable`` — with ``reason`` populated
30
+ whenever it is not ``live``. Diverging here would be the vocabulary-drift
31
+ An internal ruling decided against, in the newest public surface.
32
+
33
+ **What this module does NOT do**: it does not decide whether a finding is
34
+ important, rank findings, or summarise them in prose. It reports what the
35
+ engine did.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from dataclasses import dataclass, field
41
+ from typing import Any, Dict, Iterable, List, Optional, Sequence
42
+
43
+ from .interfaces import DetectionResult, Problem
44
+ from .types import NotEvaluated
45
+
46
+ #: the established pattern envelope vocabulary, reused verbatim.
47
+ SOURCE_LIVE = "live"
48
+ SOURCE_WARMING_UP = "warming_up"
49
+ SOURCE_UNAVAILABLE = "unavailable"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class CheckedSummary:
54
+ """The denominator. ``invariants`` counts evaluations **attempted**, which
55
+ is the only figure that makes ``not_checked`` interpretable — 8 declined
56
+ out of 47 attempted is a different statement from 8 out of 9.
57
+
58
+ that sentence was true of one of the five construction sites.
59
+ The others reported, under the same name: the count of *declared* axioms
60
+ (``model_describe``), the number of traversal *steps* (``traverse``), and
61
+ the number of *matched findings* from a lookup (``attest``). So a
62
+ traversal that evaluated nothing answered "checked 3 invariants", and
63
+ ``model_describe`` answered with declarations — the exact
64
+ declared-versus-evaluated conflation raised to P1, in the one
65
+ field whose entire job is to be an honest denominator.
66
+
67
+ ``invariants`` now means evaluations attempted, everywhere, and nothing
68
+ else. Work that is not an evaluation reports itself in its own field:
69
+ ``steps`` for traversal, ``declared_invariants`` for what a model
70
+ declares. Both are omitted from the payload when zero, matching the
71
+ existing convention that an absent number is not invited to be read as a
72
+ measured zero.
73
+ """
74
+
75
+ invariants: int = 0
76
+ entities: int = 0
77
+ steps: int = 0
78
+ declared_invariants: int = 0
79
+
80
+ def to_dict(self) -> Dict[str, int]:
81
+ out = {"invariants": self.invariants, "entities": self.entities}
82
+ if self.steps:
83
+ out["steps"] = self.steps
84
+ if self.declared_invariants:
85
+ out["declared_invariants"] = self.declared_invariants
86
+ return out
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Envelope:
91
+ """A tool response: what was checked, what was not, what is unknown."""
92
+
93
+ checked: CheckedSummary
94
+ findings: List[Problem] = field(default_factory=list)
95
+ not_checked: List[NotEvaluated] = field(default_factory=list)
96
+ questions: List[Dict[str, Any]] = field(default_factory=list)
97
+ source: str = SOURCE_LIVE
98
+ reason: Optional[str] = None
99
+
100
+ @property
101
+ def is_fully_evaluated(self) -> bool:
102
+ """True when nothing was declined. Deliberately not called
103
+ ``is_healthy`` — an envelope with no findings and eight declines is
104
+ not health, it is silence, and the two must not share a name."""
105
+ return not self.not_checked
106
+
107
+ def to_dict(self) -> Dict[str, Any]:
108
+ meta: Dict[str, Any] = {"source": self.source}
109
+ if self.reason is not None:
110
+ meta["reason"] = self.reason
111
+ return {
112
+ "checked": self.checked.to_dict(),
113
+ "findings": [_problem_to_dict(p) for p in self.findings],
114
+ "not_checked": [_not_evaluated_to_dict(n) for n in self.not_checked],
115
+ "questions": list(self.questions),
116
+ "meta": meta,
117
+ }
118
+
119
+
120
+ def _problem_to_dict(problem: Problem) -> Dict[str, Any]:
121
+ axiom = getattr(problem, "axiom", None)
122
+ severity = getattr(problem, "severity", None)
123
+ return {
124
+ "entity_id": getattr(problem, "entity_id", ""),
125
+ "problem_type": getattr(problem, "problem_type", ""),
126
+ "axiom": getattr(axiom, "value", None) if axiom is not None else None,
127
+ "severity": getattr(severity, "value", None) if severity is not None else None,
128
+ "reason": getattr(problem, "reason", ""),
129
+ }
130
+
131
+
132
+ def _not_evaluated_to_dict(record: NotEvaluated) -> Dict[str, Any]:
133
+ out: Dict[str, Any] = {
134
+ "entity_id": record.entity_id,
135
+ "entity_type": record.entity_type,
136
+ "indicator": record.indicator,
137
+ "axiom": record.axiom.value,
138
+ "reason": record.reason.value,
139
+ }
140
+ if record.detail:
141
+ out["detail"] = record.detail
142
+ # Only present for sample-floor declines; omitted rather than null so a
143
+ # reader is not invited to interpret a missing count as zero.
144
+ if record.observations_count is not None:
145
+ out["observations"] = record.observations_count
146
+ if record.required_count is not None:
147
+ out["required"] = record.required_count
148
+ # `observations` is a count INSIDE the window and `required` is
149
+ # a global floor. Emitting the pair without the window invites "collect
150
+ # more data", which is false whenever the rate cannot span the floor. The
151
+ # window and the measured interval make the ratio interpretable; the
152
+ # `remedy` line states the conclusion, because an agent reading this will
153
+ # act on it and should not have to do the arithmetic.
154
+ if record.window_seconds is not None:
155
+ out["window_seconds"] = record.window_seconds
156
+ if record.total_observations is not None:
157
+ out["total_observations"] = record.total_observations
158
+ if record.sampling_interval_seconds is not None:
159
+ out["sampling_interval_seconds"] = record.sampling_interval_seconds
160
+ if record.floor_unreachable_at_this_rate:
161
+ out["floor_unreachable_at_this_rate"] = True
162
+ out["remedy"] = (
163
+ f"sample more often than every "
164
+ f"{record.sampling_interval_seconds:.0f}s, or widen the window: "
165
+ f"{record.required_count} samples cannot fit in "
166
+ f"{record.window_seconds:.0f}s at the observed rate. Collecting "
167
+ f"for longer will not help."
168
+ )
169
+ return out
170
+
171
+
172
+ def _question_to_dict(question: Any) -> Dict[str, Any]:
173
+ """Normalise a DISCOVER-mode question.
174
+
175
+ Accepts the engine's own ``TopologyQuestion`` and plain dicts, because the
176
+ gap surface is optional and a caller may supply its own.
177
+
178
+ corrected the field names here. The first version read
179
+ ``question`` and ``gap_type`` off the object; ``TopologyQuestion`` actually
180
+ carries ``question_text`` and a nested ``gap`` whose ``gap_type`` is the
181
+ enum. Both lookups fell through to ``getattr`` defaults, so a real
182
+ question serialised as its dataclass repr — a bug that could only surface
183
+ on wiring the thing to an actual traversal, which is what did.
184
+ """
185
+ if isinstance(question, dict):
186
+ return dict(question)
187
+
188
+ text = getattr(question, "question_text", None)
189
+ if text is None:
190
+ text = getattr(question, "question", None)
191
+ gap = getattr(question, "gap", None)
192
+ gap_type = getattr(gap, "gap_type", None) if gap is not None else None
193
+ location = getattr(gap, "location", None) if gap is not None else None
194
+
195
+ out: Dict[str, Any] = {
196
+ "question": text if text is not None else str(question),
197
+ "gap_type": getattr(gap_type, "value", str(gap_type)) if gap_type else None,
198
+ "location": location,
199
+ }
200
+ priority = getattr(question, "priority", None)
201
+ if priority is not None:
202
+ out["priority"] = priority
203
+ context = getattr(question, "context_path", None)
204
+ if context:
205
+ out["context_path"] = list(context)
206
+ return out
207
+
208
+
209
+ def build_envelope(
210
+ result: DetectionResult,
211
+ questions: Optional[Sequence[Any]] = None,
212
+ source: str = SOURCE_LIVE,
213
+ reason: Optional[str] = None,
214
+ ) -> Envelope:
215
+ """Assemble an envelope from a detection pass.
216
+
217
+ ``findings`` merges ``problems`` and ``warnings`` deliberately. The
218
+ reasoner splits them by severity, which is a presentation choice; a caller
219
+ asking what was found wants both, and dropping warnings is how
220
+ BOUNDEDNESS's warning-threshold breach goes missing from a response that
221
+ claims to report findings.
222
+
223
+ ``questions`` is a sequence rather than derived here, because gathering
224
+ them requires a topology this module deliberately does not depend on.
225
+ """
226
+ ordered = sorted(
227
+ list(questions or []),
228
+ key=lambda q: getattr(q, "priority", 0.0) or 0.0,
229
+ reverse=True,
230
+ )
231
+ return Envelope(
232
+ checked=CheckedSummary(
233
+ invariants=getattr(result, "evaluations_attempted", 0),
234
+ entities=result.entities_checked,
235
+ ),
236
+ findings=list(result.problems) + list(result.warnings),
237
+ not_checked=list(result.not_evaluated),
238
+ questions=[_question_to_dict(q) for q in ordered],
239
+ source=source,
240
+ reason=reason,
241
+ )
242
+
243
+
244
+ def unavailable_envelope(reason: str) -> Envelope:
245
+ """The established pattern's bootstrap-aware fallback: a tool whose substrate is not
246
+ up answers with an envelope naming the reason, never an error and never a
247
+ misleading empty success."""
248
+ return Envelope(
249
+ checked=CheckedSummary(),
250
+ source=SOURCE_UNAVAILABLE,
251
+ reason=reason,
252
+ )
253
+
254
+
255
+ def summarise(envelope: Envelope) -> str:
256
+ """One-line human form, the shape the CD body sketched.
257
+
258
+ Used in tool descriptions and the demo transcript; the machine
259
+ contract is ``to_dict``.
260
+ """
261
+ parts = [
262
+ f"checked {envelope.checked.invariants} invariants "
263
+ f"across {envelope.checked.entities} entities",
264
+ f"findings {len(envelope.findings)}",
265
+ f"not_checked {len(envelope.not_checked)}",
266
+ f"questions {len(envelope.questions)}",
267
+ ]
268
+ if envelope.source != SOURCE_LIVE:
269
+ parts.append(f"source {envelope.source} ({envelope.reason})")
270
+ return "; ".join(parts)
@@ -0,0 +1,112 @@
1
+ # A worked example: a two-tank water system.
2
+ #
3
+ # Deliberately synthetic. It is here to teach the schema and to give the engine
4
+ # something to run against out of the box -- it is NOT one of the curated
5
+ # domain models, which are not published. Every one of the eight axioms is
6
+ # declared at least once below, so this file doubles as the schema reference.
7
+
8
+ domain:
9
+ id: water-tank
10
+ name: Two-Tank Water System
11
+ description: >
12
+ A supply tank feeding a header tank through a pump, with a controller
13
+ holding the header level at setpoint. Small enough to read in one sitting.
14
+
15
+ entity_types:
16
+ - Tank
17
+ - Pump
18
+ - Controller
19
+
20
+ relationship_types:
21
+ - feeds
22
+ - controls
23
+
24
+ indicators:
25
+ Tank:
26
+ # BOUNDEDNESS -- an upper bound. Thresholds are upper bounds ONLY; for a
27
+ # lower-is-worse quantity use HOMEOSTASIS against a baseline instead.
28
+ - name: level_pct
29
+ type: NUMERIC
30
+ axioms: [BOUNDEDNESS, HOMEOSTASIS]
31
+ warning: 85
32
+ critical: 95
33
+ window: 1h
34
+
35
+ # CONSERVATION -- what flows in should leave or accumulate. The nested
36
+ # block is what makes this configurable from YAML at all.
37
+ - name: inflow_lps
38
+ type: NUMERIC
39
+ axioms: [CONSERVATION]
40
+ window: 15m
41
+ conservation:
42
+ output_properties: [outflow_lps]
43
+ tolerance: 0.05
44
+
45
+ - name: outflow_lps
46
+ type: NUMERIC
47
+ axioms: [] # observation-only: recorded, never checked per cycle
48
+
49
+ # CONSISTENCY -- two independent readings that should agree.
50
+ # `role: percentage` selects the 0-100 rule. Without it the engine would
51
+ # infer the same thing from the `pct` token in the name, which is how
52
+ # this line worked before the field existed -- correctly, and by accident.
53
+ - name: level_pct_redundant
54
+ type: NUMERIC
55
+ role: percentage
56
+ axioms: [CONSISTENCY]
57
+ window: 15m
58
+
59
+ Pump:
60
+ # STABILITY -- does it settle, or hunt?
61
+ - name: speed_rpm
62
+ type: NUMERIC
63
+ axioms: [STABILITY, BOUNDEDNESS]
64
+ warning: 2800
65
+ critical: 3200
66
+ window: 30m
67
+
68
+ # MONOTONICITY -- a lifetime counter may only rise. allow_reset covers
69
+ # the genuine restart case rather than treating it as a violation.
70
+ - name: run_hours_total
71
+ type: NUMERIC
72
+ axioms: [MONOTONICITY]
73
+ window: 24h
74
+ monotonicity:
75
+ direction: increasing
76
+ allow_reset: true
77
+
78
+ # CONNECTIVITY -- a pump with nothing to feed is a modelling error.
79
+ - name: feeds_a_tank
80
+ type: RELATIONSHIP
81
+ axioms: [CONNECTIVITY]
82
+ target_type: Tank
83
+ relation_type: feeds
84
+ min_cardinality: 1
85
+ max_cardinality: 2
86
+ violation_severity: HIGH
87
+
88
+ Controller:
89
+ # RESPONSIVENESS -- the deadline is part of the claim.
90
+ #
91
+ # `role:` IS WHAT MAKES THIS EVALUATE. RESPONSIVENESS and CONSISTENCY are
92
+ # about a KIND of quantity -- a latency, a count, a percentage, a ratio --
93
+ # so the model says which, and the checker no longer has to guess from
94
+ # English. Leave `role:` out and the engine still infers one from the
95
+ # name, so older models keep working; but an indicator whose name carries
96
+ # no recognised word then declines `not_applicable` while the pass stays
97
+ # green.
98
+ #
99
+ # This file worked around that by RENAMING: it read `setpoint_error_pct`
100
+ # until 2026-08-14, which could never fire, and was then renamed to
101
+ # `setpoint_response_error_pct` so the guess would land. Renaming a domain
102
+ # concept to satisfy a checker is the wrong remedy, and the name is back.
103
+ # `model_describe(s)` lists any declaration that cannot fire, so this is
104
+ # answerable without running a cycle.
105
+ - name: setpoint_error_pct
106
+ type: NUMERIC
107
+ role: latency
108
+ axioms: [RESPONSIVENESS, HOMEOSTASIS]
109
+ warning: 5
110
+ critical: 12
111
+ window: 30m
112
+ timeout: 10m
@@ -0,0 +1,252 @@
1
+ """FireFrequencyTracker — dedicated rolling-window fire counter.
2
+
3
+ Per the FireCounterMixin (date-bucket Counter shape) was the
4
+ v1 instrumentation; an internal ruling lands the dedicated v2 tracker with:
5
+
6
+ - Per-(axiom, domain, indicator) granularity (the mixin only
7
+ tracked (domain) — an internal ruling adds the axiom + indicator dimensions
8
+ the state_api spec called for).
9
+ - Deque-based exact rolling-window counting (no day-bucket
10
+ approximation; sliding window of arbitrary length).
11
+ - -style cadence WARN on suspiciously high fire rates.
12
+
13
+ An internal ruling landed that migration: the mixin is gone, and the reasoner records
14
+ every axiom's fires into the shared tracker at its dispatch boundary. This
15
+ module moved from ``reflection/`` to ``detection/`` at the same time, so the
16
+ eight checkers can be counted without ``detection`` importing ``reflection`` —
17
+ the two packages are otherwise decoupled, and ``detection`` is what becomes
18
+ the extracted engine.
19
+
20
+ API contract:
21
+
22
+ - ``record_fire(axiom, domain, indicator=None, timestamp=None)``
23
+ - ``get_fire_rate(axiom, domain, indicator=None, window=24h) -> int``
24
+ - ``get_fires_by_axiom_domain(window=24h) -> Dict[axiom, Dict[domain, int]]``
25
+ - ``get_fires_by_indicator(window=24h) -> Dict[(axiom, domain, indicator), int]``
26
+ - ``prune(now=None)`` — evict entries older than retention
27
+ - ``count()`` / ``clear()`` ops helpers
28
+
29
+ Per read-only-by-design + hook-not-replicate principles:
30
+ axiom checkers + reflection-side consumers both read this tracker;
31
+ no shadow storage.
32
+ """
33
+
34
+ import logging
35
+ from collections import Counter, defaultdict, deque
36
+ from datetime import datetime, timedelta
37
+ from typing import Counter as TypedCounter
38
+ from typing import DefaultDict, Deque, Dict, Optional, Tuple
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ # Default retention window — entries older than this are evicted
44
+ # on prune. Generous default so the 24h get_fire_rate query never
45
+ # misses entries due to clock skew.
46
+ _DEFAULT_RETENTION = timedelta(days=2)
47
+
48
+
49
+ class FireFrequencyTracker:
50
+ """per-(axiom, domain, indicator) rolling fire counter.
51
+
52
+ Args:
53
+ retention: keep entries this long; older entries evicted on
54
+ ``prune`` or ``record_fire``. Default 2 days.
55
+ warn_rate_per_hour: if a single (axiom, domain) bucket
56
+ exceeds this rate in the past 1h, emit a WARN cadence
57
+ (sibling to heartbeat WARN). Default 100/hr.
58
+ warn_repeat_every: every Nth warn triggers (first-
59
+ occurrence + every-Nth cadence). Default 10.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ retention: timedelta = _DEFAULT_RETENTION,
65
+ warn_rate_per_hour: int = 100,
66
+ warn_repeat_every: int = 10,
67
+ ):
68
+ self.retention = retention
69
+ self.warn_rate_per_hour = int(warn_rate_per_hour)
70
+ self.warn_repeat_every = int(warn_repeat_every)
71
+ # (axiom, domain, indicator) -> deque[datetime]
72
+ # indicator can be None — represented as "" in the key for
73
+ # hashability + deterministic comparison.
74
+ self._fires: DefaultDict[Tuple[str, str, str], Deque[datetime]] = (
75
+ defaultdict(deque)
76
+ )
77
+ # WARN cadence tracking — count (date_iso, axiom, domain) bucket.
78
+ self._warn_counts: TypedCounter = Counter()
79
+
80
+ @staticmethod
81
+ def _key(axiom: str, domain: Optional[str], indicator: Optional[str]) -> Tuple[str, str, str]:
82
+ return (str(axiom), str(domain or "unknown"), str(indicator or ""))
83
+
84
+ def record_fire(
85
+ self,
86
+ axiom: str,
87
+ domain: Optional[str] = None,
88
+ indicator: Optional[str] = None,
89
+ timestamp: Optional[datetime] = None,
90
+ ) -> None:
91
+ """Record one fire event.
92
+
93
+ Args:
94
+ axiom: Axiom name (e.g. ``"BOUNDEDNESS"``).
95
+ domain: Optional domain ID; None → "unknown" bucket.
96
+ indicator: Optional indicator name; None → empty bucket
97
+ (axiom-level total).
98
+ timestamp: Override clock for testing.
99
+ """
100
+ now = timestamp or datetime.utcnow()
101
+ key = self._key(axiom, domain, indicator)
102
+ self._fires[key].append(now)
103
+ self._prune_one(key, now)
104
+ # Cadence WARN if 1h rate exceeds threshold.
105
+ recent_1h = self._count_in_window(key, now - timedelta(hours=1), now)
106
+ if recent_1h >= self.warn_rate_per_hour:
107
+ self._maybe_warn(axiom, domain or "unknown", recent_1h, now)
108
+
109
+ def _prune_one(self, key: Tuple[str, str, str], now: datetime) -> None:
110
+ """Drop entries older than retention from one deque."""
111
+ cutoff = now - self.retention
112
+ dq = self._fires[key]
113
+ while dq and dq[0] < cutoff:
114
+ dq.popleft()
115
+ if not dq:
116
+ del self._fires[key]
117
+
118
+ def prune(self, now: Optional[datetime] = None) -> int:
119
+ """Evict every entry older than retention. Returns count removed."""
120
+ now = now or datetime.utcnow()
121
+ cutoff = now - self.retention
122
+ removed = 0
123
+ for key in list(self._fires.keys()):
124
+ dq = self._fires[key]
125
+ while dq and dq[0] < cutoff:
126
+ dq.popleft()
127
+ removed += 1
128
+ if not dq:
129
+ del self._fires[key]
130
+ return removed
131
+
132
+ def _count_in_window(
133
+ self,
134
+ key: Tuple[str, str, str],
135
+ cutoff: datetime,
136
+ now: datetime,
137
+ ) -> int:
138
+ """Count entries in `[cutoff, now]` for one key."""
139
+ dq = self._fires.get(key, deque())
140
+ # deque is sorted by insertion time (timestamps monotonic per
141
+ # _key); count entries >= cutoff.
142
+ return sum(1 for ts in dq if ts >= cutoff)
143
+
144
+ def get_fire_rate(
145
+ self,
146
+ axiom: str,
147
+ domain: Optional[str] = None,
148
+ indicator: Optional[str] = None,
149
+ window: timedelta = timedelta(hours=24),
150
+ now: Optional[datetime] = None,
151
+ ) -> int:
152
+ """Return fire count for ``(axiom, domain, indicator)`` over ``window``."""
153
+ now = now or datetime.utcnow()
154
+ cutoff = now - window
155
+ key = self._key(axiom, domain, indicator)
156
+ return self._count_in_window(key, cutoff, now)
157
+
158
+ def get_fires_by_axiom_domain(
159
+ self,
160
+ window: timedelta = timedelta(hours=24),
161
+ now: Optional[datetime] = None,
162
+ ) -> Dict[str, Dict[str, int]]:
163
+ """Return ``{axiom: {domain: total_count}}`` aggregated over indicators.
164
+
165
+ Sibling to ``FireCounterMixin.get_fire_counts_by_domain``;
166
+ provides the same surface so reflection state_api can swap to
167
+ this tracker without changing the API contract.
168
+ """
169
+ now = now or datetime.utcnow()
170
+ cutoff = now - window
171
+ out: DefaultDict[str, Counter] = defaultdict(Counter)
172
+ for (axiom, domain, _indicator), dq in self._fires.items():
173
+ count = sum(1 for ts in dq if ts >= cutoff)
174
+ if count > 0:
175
+ out[axiom][domain] += count
176
+ return {a: dict(c) for a, c in out.items()}
177
+
178
+ def get_fires_by_indicator(
179
+ self,
180
+ window: timedelta = timedelta(hours=24),
181
+ now: Optional[datetime] = None,
182
+ ) -> Dict[Tuple[str, str, str], int]:
183
+ """Return ``{(axiom, domain, indicator): count}`` over ``window``."""
184
+ now = now or datetime.utcnow()
185
+ cutoff = now - window
186
+ out: Dict[Tuple[str, str, str], int] = {}
187
+ for key, dq in self._fires.items():
188
+ count = sum(1 for ts in dq if ts >= cutoff)
189
+ if count > 0:
190
+ out[key] = count
191
+ return out
192
+
193
+ def count(self) -> int:
194
+ """Total retained events across all buckets."""
195
+ return sum(len(dq) for dq in self._fires.values())
196
+
197
+ def clear(self) -> int:
198
+ """Drop everything; returns count removed."""
199
+ n = self.count()
200
+ self._fires.clear()
201
+ self._warn_counts.clear()
202
+ return n
203
+
204
+ def _maybe_warn(
205
+ self,
206
+ axiom: str,
207
+ domain: str,
208
+ rate_1h: int,
209
+ now: datetime,
210
+ ) -> None:
211
+ """ first-occurrence + every-Nth WARN cadence."""
212
+ date_iso = now.date().isoformat()
213
+ warn_key = (date_iso, axiom, domain)
214
+ n = self._warn_counts[warn_key]
215
+ self._warn_counts[warn_key] += 1
216
+ if n == 0 or (self.warn_repeat_every > 0 and n % self.warn_repeat_every == 0):
217
+ logger.warning(
218
+ f"FireFrequencyTracker: high fire rate for "
219
+ f"({axiom}, {domain}) — {rate_1h} fires in past hour "
220
+ f"≥ warn_rate_per_hour={self.warn_rate_per_hour} "
221
+ f"(attempt #{n + 1} today)"
222
+ )
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # the shared tracker every axiom checker records into.
227
+ #
228
+ # A module-level instance rather than a constructor argument, deliberately.
229
+ # The eight checkers are built inside `UnifiedAxiomReasoner.__init__` with no
230
+ # tracker in scope, and threading one through would mean changing that
231
+ # signature plus every construction site — a wide change to a kernel class for
232
+ # a metric nothing gates on. The mixin this replaces held per-checker state
233
+ # and was read globally, so a shared instance is the same reachability with
234
+ # one counter instead of eight.
235
+ #
236
+ # The tracker keys on (axiom, domain, indicator), so one instance holds every
237
+ # axiom without collision. That is why v2 can be shared where v1 could not.
238
+ _SHARED_TRACKER: Optional["FireFrequencyTracker"] = None
239
+
240
+
241
+ def get_shared_tracker() -> "FireFrequencyTracker":
242
+ """The process-wide fire counter. Created on first use."""
243
+ global _SHARED_TRACKER
244
+ if _SHARED_TRACKER is None:
245
+ _SHARED_TRACKER = FireFrequencyTracker()
246
+ return _SHARED_TRACKER
247
+
248
+
249
+ def reset_shared_tracker() -> None:
250
+ """Drop the shared tracker. For tests that need isolation between cases."""
251
+ global _SHARED_TRACKER
252
+ _SHARED_TRACKER = None
@@ -0,0 +1,12 @@
1
+ """
2
+ History module for observation storage.
3
+
4
+ Provides:
5
+ - ObservationHistory: Store and query historical observations
6
+ - AxiomReadinessTracker: Track axiom readiness per entity
7
+ """
8
+
9
+ from .observation import InMemoryObservationHistory
10
+ from .readiness import AxiomReadinessTracker
11
+
12
+ __all__ = ['InMemoryObservationHistory', 'AxiomReadinessTracker']