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,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