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,31 @@
|
|
|
1
|
+
"""arbiter-engine v0.1 — the public API.
|
|
2
|
+
|
|
3
|
+
The names below are the contract. Everything else in this package is
|
|
4
|
+
importable and UNSUPPORTED: reaching for a deeper path is legitimate and
|
|
5
|
+
unpromised, and those paths may move without a major version.
|
|
6
|
+
|
|
7
|
+
Declared in the build manifest (public_api) and
|
|
8
|
+
generated by the source repository Do not edit here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from . import api
|
|
12
|
+
from .history.observation import InMemoryObservationHistory
|
|
13
|
+
from .interfaces import Entity, Observation, Problem, RelationshipGraph
|
|
14
|
+
from .ontology.domain_loader import DomainModel
|
|
15
|
+
from .ontology.reasoner import UnifiedAxiomReasoner
|
|
16
|
+
from .twin.traverser import TopologyTraverser
|
|
17
|
+
from .types import Axiom, Severity
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Axiom",
|
|
21
|
+
"DomainModel",
|
|
22
|
+
"Entity",
|
|
23
|
+
"InMemoryObservationHistory",
|
|
24
|
+
"Observation",
|
|
25
|
+
"Problem",
|
|
26
|
+
"RelationshipGraph",
|
|
27
|
+
"Severity",
|
|
28
|
+
"TopologyTraverser",
|
|
29
|
+
"UnifiedAxiomReasoner",
|
|
30
|
+
"api",
|
|
31
|
+
]
|
arbiter_engine/api.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""The engine's public API: five primitives.
|
|
2
|
+
|
|
3
|
+
``model_describe`` / ``check`` / ``traverse`` / ``gaps`` / ``attest``.
|
|
4
|
+
|
|
5
|
+
These are engine-level, not transport-level. Every import below is inside the
|
|
6
|
+
Option B cut, so this module ships with ``arbiter-engine`` and
|
|
7
|
+
depends on no protocol.
|
|
8
|
+
|
|
9
|
+
moved it here from ``arbiter_mcp/tools.py``, where it was filed
|
|
10
|
+
because MCP is where it was first needed. The misfiling was visible from
|
|
11
|
+
outside: an engine demo had to import from a package named for a protocol it
|
|
12
|
+
does not use, and the leak pin fired on exactly that. Left alone,
|
|
13
|
+
The extraction would have had to either ship a transport's name inside
|
|
14
|
+
the engine package or rename during the cut itself — the riskiest moment
|
|
15
|
+
available.
|
|
16
|
+
|
|
17
|
+
``arbiter_mcp/server.py`` imports these and adds a transport. That is the
|
|
18
|
+
whole relationship, and it points one way only.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from datetime import datetime, timedelta
|
|
24
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
|
25
|
+
|
|
26
|
+
from arbiter_engine.envelope import (
|
|
27
|
+
CheckedSummary, Envelope, build_envelope, unavailable_envelope,
|
|
28
|
+
)
|
|
29
|
+
from arbiter_engine.history.observation import InMemoryObservationHistory
|
|
30
|
+
from arbiter_engine.interfaces import (
|
|
31
|
+
Entity, RelationshipGraph,
|
|
32
|
+
)
|
|
33
|
+
from arbiter_engine.ontology.axioms.roles import (
|
|
34
|
+
unreachable_axioms as _unreachable_axioms,
|
|
35
|
+
)
|
|
36
|
+
from arbiter_engine.ontology.domain_loader import load_domain
|
|
37
|
+
from arbiter_engine.ontology.reasoner import UnifiedAxiomReasoner
|
|
38
|
+
|
|
39
|
+
#: An internal ruling withheld `projected` until an internal ruling fed it. An internal ruling landed
|
|
40
|
+
#: 2026-08-04 (`TopologyTraverser.project_values`), so the mode is now
|
|
41
|
+
#: offered — and `traverse` below projects before traversing, because
|
|
42
|
+
#: offering the mode without running the producer would reinstate the
|
|
43
|
+
#: exact inertness removed.
|
|
44
|
+
SUPPORTED_VALUE_MODES = ("current", "hypothetical", "projected")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EngineSession:
|
|
48
|
+
"""Holds the loaded domain and observations between tool calls.
|
|
49
|
+
|
|
50
|
+
An MCP server is long-lived and its tools are called independently, so
|
|
51
|
+
``check`` must be able to run against a model ``model_describe`` loaded
|
|
52
|
+
earlier. Keeping that state here rather than in the transport is what lets
|
|
53
|
+
the tools be tested as plain functions.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self.model = None
|
|
58
|
+
self.reasoner: Optional[UnifiedAxiomReasoner] = None
|
|
59
|
+
self.history = InMemoryObservationHistory()
|
|
60
|
+
self.entities: Dict[str, Entity] = {}
|
|
61
|
+
self.graph = RelationshipGraph()
|
|
62
|
+
self._last_result = None
|
|
63
|
+
|
|
64
|
+
# -- loading -----------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def load_model(self, source: Any) -> None:
|
|
67
|
+
self.model = load_domain(source)
|
|
68
|
+
reasoner = UnifiedAxiomReasoner()
|
|
69
|
+
# An internal ruling removed the seam this used to work around: the loader now
|
|
70
|
+
# ingests IndicatorSpec objects directly, so the typed form the engine
|
|
71
|
+
# loader emits no longer round-trips through a dict to satisfy a
|
|
72
|
+
# parser the caller does not need.
|
|
73
|
+
reasoner.loader.set_domain_indicators(self.model.indicators)
|
|
74
|
+
self.reasoner = reasoner
|
|
75
|
+
|
|
76
|
+
def add_entity(self, entity_id: str, entity_type: str,
|
|
77
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
78
|
+
name: str = "") -> None:
|
|
79
|
+
self.entities[entity_id] = Entity(
|
|
80
|
+
id=entity_id, type=entity_type, name=name or entity_id,
|
|
81
|
+
properties=dict(properties or {}),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def add_observations(self, entity_id: str, property_name: str,
|
|
85
|
+
values: Sequence[float],
|
|
86
|
+
interval_seconds: float = 60.0) -> None:
|
|
87
|
+
now = datetime.utcnow()
|
|
88
|
+
count = len(values)
|
|
89
|
+
for i, value in enumerate(values):
|
|
90
|
+
self.history.add(
|
|
91
|
+
entity_id, property_name, float(value),
|
|
92
|
+
now - timedelta(seconds=(count - i) * interval_seconds),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def add_relationship(self, source_id: str, relation_type: str,
|
|
96
|
+
target_id: str) -> None:
|
|
97
|
+
"""The third input kind. CONNECTIVITY reads this and nothing else.
|
|
98
|
+
|
|
99
|
+
The session held a ``RelationshipGraph`` from the beginning and
|
|
100
|
+
no method put anything in it, so of the three kinds of input the engine
|
|
101
|
+
consumes, two had a feeder and one did not. The capability was never
|
|
102
|
+
missing — ``session.graph`` is public and ``RelationshipGraph`` is on the
|
|
103
|
+
supported surface — but a reader following the front door could satisfy
|
|
104
|
+
seven of the eight axioms and not the eighth.
|
|
105
|
+
|
|
106
|
+
The argument for keeping the session to three methods was that they are
|
|
107
|
+
a deliberate minimum. That argument does not survive contact with the
|
|
108
|
+
asymmetry: the minimum is one feeder per input kind, and this was two.
|
|
109
|
+
|
|
110
|
+
Deliberately narrower than ``RelationshipGraph.add_relationship``, which
|
|
111
|
+
also takes properties, strength, discovery time and cross-domain tags.
|
|
112
|
+
Those belong to callers building a topology directly; the session's job
|
|
113
|
+
is to make the common case reachable without reading the graph's
|
|
114
|
+
signature. Reach for ``session.graph`` when you need the rest.
|
|
115
|
+
"""
|
|
116
|
+
self.graph.add_relationship(source_id, relation_type, target_id)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# =====================================================================
|
|
120
|
+
# The five tools
|
|
121
|
+
# =====================================================================
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def model_describe(session: EngineSession) -> Envelope:
|
|
125
|
+
"""What domain is loaded: entity types, indicators, declared axioms.
|
|
126
|
+
|
|
127
|
+
This is the grounding tool. An agent calls it before reasoning so it
|
|
128
|
+
learns the vocabulary and cannot invent an entity type the model does not
|
|
129
|
+
contain.
|
|
130
|
+
|
|
131
|
+
**Reports declarations, not evaluations.** ``DomainModel.declared_axioms``
|
|
132
|
+
carries an explicit warning that the declared set is not the evaluated set
|
|
133
|
+
— several axioms have paths that consult no declaration. The
|
|
134
|
+
payload says ``declared_axioms`` for that reason, and the summary does not
|
|
135
|
+
claim to answer "what does this domain check?".
|
|
136
|
+
"""
|
|
137
|
+
if session.model is None:
|
|
138
|
+
return unavailable_envelope("no domain model loaded")
|
|
139
|
+
|
|
140
|
+
model = session.model
|
|
141
|
+
per_type: Dict[str, Any] = {}
|
|
142
|
+
for entity_type, specs in model.indicators.items():
|
|
143
|
+
per_type[entity_type] = [
|
|
144
|
+
{
|
|
145
|
+
"name": s.name,
|
|
146
|
+
"declared_axioms": [
|
|
147
|
+
getattr(a, "value", str(a)) for a in s.relevant_axioms],
|
|
148
|
+
# DECLARED and REACHABLE are different sets, and the
|
|
149
|
+
# difference used to be discoverable only by running a cycle
|
|
150
|
+
# and reading a decline. `role` is what moves a pair between
|
|
151
|
+
# them for the two role-gated axioms.
|
|
152
|
+
"role": getattr(s, "role", None),
|
|
153
|
+
"unreachable_axioms": [
|
|
154
|
+
getattr(a, "value", str(a))
|
|
155
|
+
for a in _unreachable_axioms(s)],
|
|
156
|
+
}
|
|
157
|
+
for s in specs
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
envelope = Envelope(
|
|
161
|
+
checked=CheckedSummary(
|
|
162
|
+
# `model_describe` evaluates nothing, so `invariants`
|
|
163
|
+
# (evaluations attempted) is 0. What it can report is what the
|
|
164
|
+
# model DECLARES, and that goes in its own field: reporting a
|
|
165
|
+
# declaration count as `invariants` was the conflation
|
|
166
|
+
# inside the honesty leg itself.
|
|
167
|
+
invariants=0,
|
|
168
|
+
declared_invariants=sum(len(s.relevant_axioms)
|
|
169
|
+
for s in model.all_indicators()),
|
|
170
|
+
entities=len(model.entity_types),
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
# The model description rides in questions=[] / findings=[]; the payload
|
|
174
|
+
# is attached so the transport can serialise one shape for every tool.
|
|
175
|
+
payload = envelope.to_dict()
|
|
176
|
+
payload["model"] = {
|
|
177
|
+
"domain_id": model.domain_id,
|
|
178
|
+
"name": model.name,
|
|
179
|
+
"entity_types": list(model.entity_types),
|
|
180
|
+
"relationship_types": list(model.relationship_types),
|
|
181
|
+
"indicators": per_type,
|
|
182
|
+
"declared_axioms": [
|
|
183
|
+
getattr(a, "value", str(a)) for a in model.declared_axioms()],
|
|
184
|
+
# the statically-decidable half of the gap the note below
|
|
185
|
+
# describes. Not every declared axiom that fails to fire is listed here
|
|
186
|
+
# (some depend on inputs), but every pair listed here CANNOT fire, and
|
|
187
|
+
# that was previously knowable only by running the engine.
|
|
188
|
+
"unreachable_declarations": model.unreachable_declarations(),
|
|
189
|
+
"note": (
|
|
190
|
+
"declared_axioms is what the model declares, not what the engine "
|
|
191
|
+
"evaluates; some axioms have evaluation paths that consult no "
|
|
192
|
+
"declaration. unreachable_declarations lists pairs that "
|
|
193
|
+
"provably cannot evaluate under any input"
|
|
194
|
+
),
|
|
195
|
+
}
|
|
196
|
+
return _WithPayload(envelope, payload)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def check(session: EngineSession) -> Envelope:
|
|
200
|
+
"""Evaluate the declared invariants over the supplied observations."""
|
|
201
|
+
if session.reasoner is None:
|
|
202
|
+
return unavailable_envelope("no domain model loaded")
|
|
203
|
+
if not session.entities:
|
|
204
|
+
return unavailable_envelope("no entities supplied")
|
|
205
|
+
|
|
206
|
+
result = session.reasoner.detect(
|
|
207
|
+
list(session.entities.values()), session.graph, session.history)
|
|
208
|
+
session._last_result = result
|
|
209
|
+
return build_envelope(result)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def traverse(session: EngineSession, start_nodes: Sequence[str],
|
|
213
|
+
direction: str = "forward", value_mode: str = "current",
|
|
214
|
+
max_hops: int = 4,
|
|
215
|
+
overrides: Optional[Dict[str, Dict[str, Any]]] = None) -> Envelope:
|
|
216
|
+
"""The kernel: subsumes root cause, impact, what-if, conservation and
|
|
217
|
+
connectivity as points in one parameter space.
|
|
218
|
+
|
|
219
|
+
``value_mode='projected'`` is refused rather than silently downgraded —
|
|
220
|
+
records that PREDICT is plumbed but unfed, and a tool that accepts
|
|
221
|
+
a mode it cannot honour is worse than one that declines it.
|
|
222
|
+
"""
|
|
223
|
+
if value_mode not in SUPPORTED_VALUE_MODES:
|
|
224
|
+
return unavailable_envelope(
|
|
225
|
+
f"value_mode {value_mode!r} is not supported; this build accepts "
|
|
226
|
+
f"{', '.join(SUPPORTED_VALUE_MODES)}."
|
|
227
|
+
)
|
|
228
|
+
topology = _build_topology(session)
|
|
229
|
+
if topology is None:
|
|
230
|
+
return unavailable_envelope(
|
|
231
|
+
"no topology available: supply entities before traversing")
|
|
232
|
+
|
|
233
|
+
from arbiter_engine.twin.topology import (
|
|
234
|
+
TraversalDirection, TraversalRequest, ValueMode,
|
|
235
|
+
)
|
|
236
|
+
from arbiter_engine.twin.traverser import TopologyTraverser
|
|
237
|
+
|
|
238
|
+
request = TraversalRequest(
|
|
239
|
+
start_nodes=list(start_nodes),
|
|
240
|
+
direction=TraversalDirection[direction.upper()],
|
|
241
|
+
value_mode=ValueMode[value_mode.upper()],
|
|
242
|
+
max_hops=max_hops,
|
|
243
|
+
overrides=dict(overrides or {}),
|
|
244
|
+
)
|
|
245
|
+
traverser = TopologyTraverser(topology, observation_history=session.history)
|
|
246
|
+
projected_count = 0
|
|
247
|
+
if value_mode == "projected":
|
|
248
|
+
# the producer must run or PROJECTED silently reads present
|
|
249
|
+
# values — which is what made the mode inert for its whole existence.
|
|
250
|
+
projected_count = traverser.project_values()
|
|
251
|
+
if projected_count == 0:
|
|
252
|
+
return unavailable_envelope(
|
|
253
|
+
"value_mode 'projected' needs observation history to fit a "
|
|
254
|
+
"trend; none of the supplied entities had enough. Add "
|
|
255
|
+
"observations or use 'current'."
|
|
256
|
+
)
|
|
257
|
+
# the count above is TOPOLOGY-WIDE, and the risk is
|
|
258
|
+
# per-node. Found by the round-trip: one entity with 40 observations
|
|
259
|
+
# made `project_values()` return 1, so a traversal starting at an
|
|
260
|
+
# entity with *no* history sailed past this guard and reported
|
|
261
|
+
# `source: live` while reading present values. That is exactly the
|
|
262
|
+
# failure mode the guard exists to prevent — narrowed to a
|
|
263
|
+
# smaller window rather than closed. Ask whether the nodes being
|
|
264
|
+
# traversed projected, not whether anything did.
|
|
265
|
+
unprojected = [
|
|
266
|
+
node_id for node_id in start_nodes
|
|
267
|
+
if not getattr(
|
|
268
|
+
topology.nodes.get(node_id), "projected_values", None)
|
|
269
|
+
]
|
|
270
|
+
if len(unprojected) == len(list(start_nodes)):
|
|
271
|
+
return unavailable_envelope(
|
|
272
|
+
"value_mode 'projected' has no fitted trend for "
|
|
273
|
+
f"{', '.join(unprojected)}: those entities lack the "
|
|
274
|
+
"observation history to project from. Other entities in the "
|
|
275
|
+
"topology do, which is why this is not an empty-history "
|
|
276
|
+
"error. Add observations for them or use 'current'."
|
|
277
|
+
)
|
|
278
|
+
result = traverser.traverse(request)
|
|
279
|
+
|
|
280
|
+
# An internal ruling set this to 0 on the premise that a traversal evaluates no
|
|
281
|
+
# invariants. That was true when written and stopped being true at
|
|
282
|
+
# an internal ruling, which carried the declared thresholds onto the nodes so
|
|
283
|
+
# `_evaluate_axioms` can fire. Reporting 0 beside a non-empty `findings`
|
|
284
|
+
# list would be the same defect fixed, pointing the other way:
|
|
285
|
+
# an envelope that understates what it did is no more honest than one
|
|
286
|
+
# that overstates it.
|
|
287
|
+
#
|
|
288
|
+
# and the replacement for that premise was wrong too, in the
|
|
289
|
+
# other direction. This counted `axiom_states` on each walked node, which
|
|
290
|
+
# is what the BUILDER SEEDED: one state per declared axiom. The evaluator
|
|
291
|
+
# handles BOUNDEDNESS only and skips any state whose property is absent
|
|
292
|
+
# from the values, so a walk that evaluated one invariant reported four,
|
|
293
|
+
# and a walk with `collect_axiom_violations` off — evaluating nothing —
|
|
294
|
+
# reported four as well. Between them, the field has now been wrong as
|
|
295
|
+
# traversal steps, and as declarations, in the one place whose entire job
|
|
296
|
+
# is to be an honest denominator.
|
|
297
|
+
#
|
|
298
|
+
# The count now comes from the traverser, which is the only thing that
|
|
299
|
+
# knows what it attempted. Deriving it here was a second implementation of
|
|
300
|
+
# a predicate owned elsewhere, and it disagreed with the original.
|
|
301
|
+
envelope = Envelope(
|
|
302
|
+
checked=CheckedSummary(
|
|
303
|
+
invariants=result.axiom_evaluations_attempted,
|
|
304
|
+
steps=len(result.steps),
|
|
305
|
+
entities=result.total_nodes_visited,
|
|
306
|
+
),
|
|
307
|
+
findings=list(result.problems_detected),
|
|
308
|
+
questions=[_q(q) for q in result.questions_generated],
|
|
309
|
+
)
|
|
310
|
+
return envelope
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def gaps(session: EngineSession,
|
|
314
|
+
start_node: Optional[str] = None) -> Envelope:
|
|
315
|
+
"""DISCOVER mode: what the model is missing, priority-ranked.
|
|
316
|
+
|
|
317
|
+
This is the *what it needs to know next* leg of the envelope, surfaced as
|
|
318
|
+
its own tool because an agent may want the questions without running a
|
|
319
|
+
traversal for findings.
|
|
320
|
+
"""
|
|
321
|
+
topology = _build_topology(session)
|
|
322
|
+
if topology is None:
|
|
323
|
+
return unavailable_envelope(
|
|
324
|
+
"no topology available: supply entities before discovering gaps")
|
|
325
|
+
|
|
326
|
+
from arbiter_engine.twin.traverser import TopologyTraverser
|
|
327
|
+
traverser = TopologyTraverser(topology)
|
|
328
|
+
|
|
329
|
+
starts = [start_node] if start_node else list(topology.nodes.keys())
|
|
330
|
+
seen: Dict[Any, Any] = {}
|
|
331
|
+
for node_id in starts:
|
|
332
|
+
for question in traverser.discover_gaps(node_id):
|
|
333
|
+
gap = getattr(question, "gap", None)
|
|
334
|
+
key = (getattr(getattr(gap, "gap_type", None), "value", None),
|
|
335
|
+
getattr(gap, "location", None))
|
|
336
|
+
seen.setdefault(key, question)
|
|
337
|
+
|
|
338
|
+
# the topology's STRUCTURAL gaps, which are a separate
|
|
339
|
+
# population from the traversal-time ones above and were reaching no
|
|
340
|
+
# consumer at all.
|
|
341
|
+
#
|
|
342
|
+
# `traverse` only ever generates MISSING_NODE questions, and only for a
|
|
343
|
+
# start node absent from the topology or an edge pointing at an unknown
|
|
344
|
+
# entity. The builder separately computes orphans and missing properties
|
|
345
|
+
# into `topology.gaps`, and **nothing anywhere read that list** — so
|
|
346
|
+
# `discover_gaps` could not surface them however it was called. Fixing the
|
|
347
|
+
# builder alone (so the engine path computes gaps at all) was necessary
|
|
348
|
+
# and not sufficient; this is the second half.
|
|
349
|
+
#
|
|
350
|
+
# Deduplicated on the same `(gap_type, location)` key, so a structural gap
|
|
351
|
+
# that a traversal also found keeps the traversal's richer context path.
|
|
352
|
+
from arbiter_engine.twin.topology import TopologyQuestion
|
|
353
|
+
for gap in getattr(topology, "gaps", ()):
|
|
354
|
+
key = (getattr(getattr(gap, "gap_type", None), "value", None),
|
|
355
|
+
getattr(gap, "location", None))
|
|
356
|
+
if key in seen:
|
|
357
|
+
continue
|
|
358
|
+
seen[key] = TopologyQuestion(
|
|
359
|
+
gap=gap,
|
|
360
|
+
question_text=gap.question,
|
|
361
|
+
priority=0.5,
|
|
362
|
+
context_path=[],
|
|
363
|
+
suggested_resolvers=[gap.suggested_strategy],
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
ordered = sorted(seen.values(),
|
|
367
|
+
key=lambda q: getattr(q, "priority", 0.0) or 0.0,
|
|
368
|
+
reverse=True)
|
|
369
|
+
return Envelope(
|
|
370
|
+
checked=CheckedSummary(invariants=0, entities=len(starts)),
|
|
371
|
+
questions=[_q(q) for q in ordered],
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def attest(session: EngineSession, problem_type: str,
|
|
376
|
+
entity_id: Optional[str] = None) -> Envelope:
|
|
377
|
+
"""The evidence trail behind a finding.
|
|
378
|
+
|
|
379
|
+
**Thin by decision, not by omission**: it reports what the engine
|
|
380
|
+
itself knows — the axiom, the threshold, the observations used, the floor
|
|
381
|
+
applied. The richer production-record trail needs
|
|
382
|
+
the full system, which placed in v0.2; the tool deepens
|
|
383
|
+
there rather than changing shape.
|
|
384
|
+
"""
|
|
385
|
+
result = session._last_result
|
|
386
|
+
if result is None:
|
|
387
|
+
return unavailable_envelope("nothing checked yet: call check first")
|
|
388
|
+
|
|
389
|
+
matches = [
|
|
390
|
+
p for p in list(result.problems) + list(result.warnings)
|
|
391
|
+
if p.problem_type == problem_type
|
|
392
|
+
and (entity_id is None or p.entity_id == entity_id)
|
|
393
|
+
]
|
|
394
|
+
if not matches:
|
|
395
|
+
return unavailable_envelope(
|
|
396
|
+
f"no finding named {problem_type!r} in the last check")
|
|
397
|
+
|
|
398
|
+
envelope = Envelope(
|
|
399
|
+
# `attest` looks up an already-computed finding. It
|
|
400
|
+
# evaluates nothing, and the number of matches is already visible in
|
|
401
|
+
# `findings`; reporting it as `invariants` claimed an evaluation that
|
|
402
|
+
# did not happen.
|
|
403
|
+
checked=CheckedSummary(invariants=0, entities=1),
|
|
404
|
+
findings=matches,
|
|
405
|
+
)
|
|
406
|
+
payload = envelope.to_dict()
|
|
407
|
+
payload["evidence"] = [
|
|
408
|
+
{
|
|
409
|
+
"problem_type": p.problem_type,
|
|
410
|
+
"entity_id": p.entity_id,
|
|
411
|
+
"axiom": getattr(p.axiom, "value", None) if p.axiom else None,
|
|
412
|
+
"evidence": dict(getattr(p, "evidence", {}) or {}),
|
|
413
|
+
"confidence": getattr(p, "confidence", None),
|
|
414
|
+
"boundary": (
|
|
415
|
+
"engine-side evidence only; production attestation records "
|
|
416
|
+
"are v0.2"
|
|
417
|
+
),
|
|
418
|
+
}
|
|
419
|
+
for p in matches
|
|
420
|
+
]
|
|
421
|
+
return _WithPayload(envelope, payload)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# =====================================================================
|
|
425
|
+
# helpers
|
|
426
|
+
# =====================================================================
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class _WithPayload(Envelope):
|
|
430
|
+
"""An envelope carrying a tool-specific payload alongside the four legs.
|
|
431
|
+
|
|
432
|
+
Subclassed rather than adding an ``extra`` field to :class:`Envelope`,
|
|
433
|
+
because the envelope's contract is the four legs plus meta and every tool
|
|
434
|
+
must satisfy it identically. Tool-specific data is additive on the wire.
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
def __init__(self, base: Envelope, payload: Dict[str, Any]) -> None:
|
|
438
|
+
super().__init__(
|
|
439
|
+
checked=base.checked, findings=base.findings,
|
|
440
|
+
not_checked=base.not_checked, questions=base.questions,
|
|
441
|
+
source=base.source, reason=base.reason,
|
|
442
|
+
)
|
|
443
|
+
object.__setattr__(self, "_payload", payload)
|
|
444
|
+
|
|
445
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
446
|
+
return dict(self._payload)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _q(question: Any) -> Dict[str, Any]:
|
|
450
|
+
from arbiter_engine.envelope import _question_to_dict
|
|
451
|
+
return _question_to_dict(question)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _build_topology(session: EngineSession):
|
|
455
|
+
"""Build a topology from the session's entities.
|
|
456
|
+
|
|
457
|
+
``build_from_relationship_graph`` takes a ``Dict[str, Entity]``, not a
|
|
458
|
+
list — it iterates ``.items()`` and feeds the same mapping to
|
|
459
|
+
``_build_id_alias_map``. Passing a list raises rather than degrading, so
|
|
460
|
+
this is caught on first call rather than silently producing an empty
|
|
461
|
+
graph, which is the better failure of the two.
|
|
462
|
+
"""
|
|
463
|
+
if not session.entities:
|
|
464
|
+
return None
|
|
465
|
+
from arbiter_engine.twin.builder import TopologyBuilder
|
|
466
|
+
builder = TopologyBuilder()
|
|
467
|
+
# pass the declared indicators so structural gap discovery runs.
|
|
468
|
+
# Without this the topology carried no gaps at all and `gaps` returned an
|
|
469
|
+
# empty questions leg for every model, which is indistinguishable from
|
|
470
|
+
# "this model has no gaps" and is why the demo showed none.
|
|
471
|
+
indicators = getattr(session.model, "indicators", None) if session.model else None
|
|
472
|
+
return builder.build_from_relationship_graph(
|
|
473
|
+
dict(session.entities), session.graph, indicators)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Per-entity axiom threshold overrides — resolution, and the key they live under.
|
|
2
|
+
|
|
3
|
+
This code was in `arbiter_engine/twin/monte_carlo_predictor.py`, which is
|
|
4
|
+
where it was first needed and not where it belongs. Six of the eight axiom
|
|
5
|
+
checkers import `resolve_axiom_threshold`, and none of them have anything to
|
|
6
|
+
do with Monte Carlo simulation — they were reaching across the package into a
|
|
7
|
+
1,080-line predictor to fetch eighty lines of dictionary lookup.
|
|
8
|
+
|
|
9
|
+
That coupling had a concrete cost: `arbiter-oss-strategy.md` puts the Monte
|
|
10
|
+
Carlo predictor out of the v0.1 engine extraction, and executing that cut as
|
|
11
|
+
written would have taken six checkers with it. Moving the resolver here makes
|
|
12
|
+
the predictor deletable without touching `ontology/axioms/` at all.
|
|
13
|
+
|
|
14
|
+
The behaviour is unchanged and deliberately so — this is a relocation, not a
|
|
15
|
+
rewrite. `monte_carlo_predictor` re-exports both names so existing imports
|
|
16
|
+
keep working.
|
|
17
|
+
|
|
18
|
+
## What an override is
|
|
19
|
+
|
|
20
|
+
A simulation (or any caller) can stamp per-entity threshold overrides onto an
|
|
21
|
+
entity's own properties under a single sentinel key. Checkers read through
|
|
22
|
+
`resolve_axiom_threshold`, which returns the override when one is present for
|
|
23
|
+
`(indicator, axiom)` and the caller's fallback otherwise. Fallbacks are
|
|
24
|
+
normally scalars from the global `AxiomParameters`.
|
|
25
|
+
|
|
26
|
+
Storing overrides on the entity rather than in a side channel is what lets a
|
|
27
|
+
per-sample perturbation flow through the ordinary detection path without any
|
|
28
|
+
checker knowing it is being simulated.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
#: Entity-property key carrying per-entity threshold overrides. The value is a
|
|
39
|
+
#: dict of ``(indicator, axiom) -> (warn, critical)``. Named with the sentinel
|
|
40
|
+
#: dunder shape so it cannot collide with a real domain property.
|
|
41
|
+
CD508_ENTITY_PROPERTY_KEY = "__cd508_axiom_thresholds__"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_axiom_threshold(
|
|
45
|
+
entity: Any,
|
|
46
|
+
indicator: str,
|
|
47
|
+
axiom: str,
|
|
48
|
+
fallback: Any,
|
|
49
|
+
*,
|
|
50
|
+
bound: str = "warn",
|
|
51
|
+
) -> Any:
|
|
52
|
+
"""Return the per-entity override for ``(indicator, axiom)``, else ``fallback``.
|
|
53
|
+
|
|
54
|
+
Integration pattern at an axiom-checker read site:
|
|
55
|
+
|
|
56
|
+
warn = resolve_axiom_threshold(entity, "cpu", "BOUNDEDNESS",
|
|
57
|
+
fallback=self.params.boundedness_warning_ratio,
|
|
58
|
+
bound="warn")
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
entity: Detection Entity (real or test fixture). Read via
|
|
62
|
+
``entity.properties.get(CD508_ENTITY_PROPERTY_KEY, {})``.
|
|
63
|
+
indicator: Indicator name, e.g. ``"cpu"``.
|
|
64
|
+
axiom: Axiom name, e.g. ``"BOUNDEDNESS"``.
|
|
65
|
+
fallback: Returned when no override applies. Typically a scalar from
|
|
66
|
+
``self.params.<field>``; may be None.
|
|
67
|
+
bound: ``"warn"`` (default) / ``"critical"`` / ``"both"``. ``"both"``
|
|
68
|
+
returns the whole ``(warn, critical)`` tuple; unknown values fall
|
|
69
|
+
through to ``"warn"``.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The override for the selected bound when present and non-None,
|
|
73
|
+
otherwise ``fallback``.
|
|
74
|
+
|
|
75
|
+
The return shape follows the caller: a scalar fallback yields a scalar, and
|
|
76
|
+
``bound="both"`` yields a tuple. That variance is intentional — it lets each
|
|
77
|
+
read site consume the shape it actually wants — but single-threshold check
|
|
78
|
+
paths should prefer the scalar forms, which read more clearly.
|
|
79
|
+
|
|
80
|
+
Every failure path returns ``fallback``. A malformed override is a fault in
|
|
81
|
+
whatever wrote it, and the right behaviour is to fall back to the
|
|
82
|
+
configured threshold with a warning rather than to let a simulation artifact
|
|
83
|
+
crash live detection.
|
|
84
|
+
"""
|
|
85
|
+
if entity is None:
|
|
86
|
+
return fallback
|
|
87
|
+
|
|
88
|
+
props = getattr(entity, "properties", None)
|
|
89
|
+
if not props:
|
|
90
|
+
return fallback
|
|
91
|
+
|
|
92
|
+
override_dict = props.get(CD508_ENTITY_PROPERTY_KEY)
|
|
93
|
+
if not override_dict or not isinstance(override_dict, dict):
|
|
94
|
+
return fallback
|
|
95
|
+
|
|
96
|
+
bounds_tuple = override_dict.get((indicator, axiom))
|
|
97
|
+
if bounds_tuple is None:
|
|
98
|
+
return fallback
|
|
99
|
+
|
|
100
|
+
if not isinstance(bounds_tuple, tuple) or len(bounds_tuple) != 2:
|
|
101
|
+
# The marker is load-bearing, not decoration: it is the audit
|
|
102
|
+
# grep handle for this fallback and is pinned by
|
|
103
|
+
# test_axiom_threshold_resolver_cd509. Dropping it during the
|
|
104
|
+
# relocation broke that pin, which is how it was found.
|
|
105
|
+
logger.warning(
|
|
106
|
+
"resolve_axiom_threshold: malformed override entry for "
|
|
107
|
+
"(%r, %r) on entity %r — expected a (warn, critical) 2-tuple, "
|
|
108
|
+
"got %r. Falling back.",
|
|
109
|
+
indicator, axiom, getattr(entity, "id", "<unknown>"), bounds_tuple,
|
|
110
|
+
)
|
|
111
|
+
return fallback
|
|
112
|
+
|
|
113
|
+
warn, critical = bounds_tuple
|
|
114
|
+
if bound == "critical":
|
|
115
|
+
return critical if critical is not None else fallback
|
|
116
|
+
if bound == "both":
|
|
117
|
+
return bounds_tuple
|
|
118
|
+
return warn if warn is not None else fallback
|