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.
- arbiter_engine/__init__.py +31 -0
- arbiter_engine/api.py +473 -0
- arbiter_engine/axiom_thresholds.py +118 -0
- arbiter_engine/envelope.py +270 -0
- arbiter_engine/examples/water_tank.yaml +112 -0
- arbiter_engine/fire_frequency.py +252 -0
- arbiter_engine/history/__init__.py +12 -0
- arbiter_engine/history/observation.py +596 -0
- arbiter_engine/history/observation_production.py +335 -0
- arbiter_engine/history/observation_source_wiring.py +120 -0
- arbiter_engine/history/readiness.py +288 -0
- arbiter_engine/interfaces.py +1353 -0
- arbiter_engine/mcp/__init__.py +1 -0
- arbiter_engine/mcp/server.py +221 -0
- arbiter_engine/ontology/__init__.py +18 -0
- arbiter_engine/ontology/axiom_verdicts_production.py +332 -0
- arbiter_engine/ontology/axioms/__init__.py +26 -0
- arbiter_engine/ontology/axioms/boundedness.py +340 -0
- arbiter_engine/ontology/axioms/connectivity.py +396 -0
- arbiter_engine/ontology/axioms/conservation.py +266 -0
- arbiter_engine/ontology/axioms/consistency.py +422 -0
- arbiter_engine/ontology/axioms/extensions.py +106 -0
- arbiter_engine/ontology/axioms/homeostasis.py +783 -0
- arbiter_engine/ontology/axioms/monotonicity.py +444 -0
- arbiter_engine/ontology/axioms/responsiveness.py +752 -0
- arbiter_engine/ontology/axioms/roles.py +207 -0
- arbiter_engine/ontology/axioms/stability.py +327 -0
- arbiter_engine/ontology/domain_loader.py +485 -0
- arbiter_engine/ontology/loader.py +802 -0
- arbiter_engine/ontology/reasoner.py +1069 -0
- arbiter_engine/propagation/__init__.py +1 -0
- arbiter_engine/propagation/impact_estimator.py +305 -0
- arbiter_engine/propagation/lp_confidence.py +123 -0
- arbiter_engine/propagation/mcts_root_cause.py +266 -0
- arbiter_engine/propagation/root_cause.py +559 -0
- arbiter_engine/propagation/weight_learner.py +216 -0
- arbiter_engine/rca/__init__.py +4 -0
- arbiter_engine/rca/greedy_set_cover.py +291 -0
- arbiter_engine/residual/__init__.py +1 -0
- arbiter_engine/residual/predict_vs_mirror.py +542 -0
- arbiter_engine/temporal/__init__.py +1 -0
- arbiter_engine/temporal/temporal_edge.py +584 -0
- arbiter_engine/temporal/trend_projection.py +430 -0
- arbiter_engine/twin/__init__.py +23 -0
- arbiter_engine/twin/action_clears_problem.py +217 -0
- arbiter_engine/twin/builder.py +479 -0
- arbiter_engine/twin/gap.py +132 -0
- arbiter_engine/twin/hypothesis_generator.py +530 -0
- arbiter_engine/twin/hypothesis_production.py +236 -0
- arbiter_engine/twin/kernel_pipeline_executor.py +571 -0
- arbiter_engine/twin/monte_carlo_predictor.py +1001 -0
- arbiter_engine/twin/optimization_production.py +233 -0
- arbiter_engine/twin/pipeline_production.py +176 -0
- arbiter_engine/twin/topology.py +450 -0
- arbiter_engine/twin/topology_optimizer.py +412 -0
- arbiter_engine/twin/traverser.py +1260 -0
- arbiter_engine/twin/traverser_production.py +283 -0
- arbiter_engine/types.py +427 -0
- arbiter_engine-0.1.0.dist-info/METADATA +278 -0
- arbiter_engine-0.1.0.dist-info/RECORD +63 -0
- arbiter_engine-0.1.0.dist-info/WHEEL +4 -0
- arbiter_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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']
|