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 @@
1
+ """arbiter-engine v0.1 — arbiter_engine/mcp."""
@@ -0,0 +1,221 @@
1
+ """The MCP transport shim.
2
+
3
+ Deliberately thin. All five tools live in :mod:`arbiter_engine.api`
4
+ as plain functions over the engine (moved there, because they are
5
+ engine API rather than transport); this module only registers them with the SDK and
6
+ serialises the envelope. Everything worth testing is testable without
7
+ importing ``mcp`` at all, which is why the tool tests need no protocol
8
+ round-trip.
9
+
10
+ The SDK import is **lazy**, inside :func:`build_server`. Importing this module
11
+ must not require ``mcp`` to be installed — the engine is the dependency, the
12
+ transport is not (the scope ruling holds the platform; the server
13
+ holds neither).
14
+
15
+ Run with:
16
+
17
+ python3 -m arbiter_engine.mcp.server
18
+
19
+ (Inside the orchestrator repo the same module runs at its in-repo path, from
20
+ the repo parent on the path. That path is deliberately not spelled out here.)
21
+
22
+ This module now SHIPS -- the public-API ruling decided the
23
+ surface is a permanent public contract -- so its own docstring is public text. It previously gave only
24
+ the in-repo invocation, with an absolute operator path in front of it: a private
25
+ package name and a private filesystem path, both in a docstring, where the
26
+ build's stray scan cannot see them because that scan matches import STATEMENTS
27
+ and this is prose. Name what a reader actually has, and nothing else.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ from typing import Any, Dict, List
34
+
35
+ from arbiter_engine.api import (
36
+ EngineSession, attest, check, gaps, model_describe, traverse,
37
+ )
38
+
39
+ #: The five primitives, in the order an internal ruling lists them. Each entry is the
40
+ #: name, a one-line description for the client, and the JSON-Schema input.
41
+ TOOL_SPECS: List[Dict[str, Any]] = [
42
+ {
43
+ "name": "model_describe",
44
+ "description": (
45
+ "What domain model is loaded: entity types, indicators, and the "
46
+ "axioms each indicator DECLARES. Call this before reasoning — it "
47
+ "is the vocabulary, and an entity type absent from it does not "
48
+ "exist in this model. Declared is not the same as evaluated."
49
+ ),
50
+ "inputSchema": {"type": "object", "properties": {}},
51
+ },
52
+ {
53
+ "name": "check",
54
+ "description": (
55
+ "Evaluate the declared invariants over the supplied observations. "
56
+ "Returns findings AND what could not be evaluated, with reasons — "
57
+ "an empty findings list with a populated not_checked list means "
58
+ "nothing was measured, not that nothing is wrong."
59
+ ),
60
+ "inputSchema": {"type": "object", "properties": {}},
61
+ },
62
+ {
63
+ "name": "traverse",
64
+ "description": (
65
+ "Walk the entity graph. One primitive subsumes root-cause "
66
+ "(direction=reverse), impact (forward), and what-if "
67
+ "(value_mode=hypothetical with overrides)."
68
+ ),
69
+ "inputSchema": {
70
+ "type": "object",
71
+ "properties": {
72
+ "start_nodes": {"type": "array", "items": {"type": "string"}},
73
+ "direction": {
74
+ "type": "string",
75
+ "enum": ["forward", "reverse", "bidirectional"],
76
+ },
77
+ # An internal ruling fed PROJECTED (2026-08-04), so the enum offers it.
78
+ # It was withheld while the mode was inert; advertising a
79
+ # capability nothing implements is the shape, and so
80
+ # is leaving a withholding note whose stated reason has since
81
+ # been resolved.
82
+ "value_mode": {
83
+ "type": "string",
84
+ "enum": ["current", "hypothetical", "projected"],
85
+ },
86
+ "max_hops": {"type": "integer"},
87
+ "overrides": {"type": "object"},
88
+ },
89
+ "required": ["start_nodes"],
90
+ },
91
+ },
92
+ {
93
+ "name": "gaps",
94
+ "description": (
95
+ "What the model is missing, as priority-ranked questions. This is "
96
+ "what the engine needs to know next in order to answer better."
97
+ ),
98
+ "inputSchema": {
99
+ "type": "object",
100
+ "properties": {"start_node": {"type": "string"}},
101
+ },
102
+ },
103
+ {
104
+ "name": "attest",
105
+ "description": (
106
+ "The evidence behind a finding: which axiom, which threshold, how "
107
+ "many observations. Engine-side evidence only."
108
+ ),
109
+ "inputSchema": {
110
+ "type": "object",
111
+ "properties": {
112
+ "problem_type": {"type": "string"},
113
+ "entity_id": {"type": "string"},
114
+ },
115
+ "required": ["problem_type"],
116
+ },
117
+ },
118
+ ]
119
+
120
+ _HANDLERS = {
121
+ "model_describe": lambda s, a: model_describe(s),
122
+ "check": lambda s, a: check(s),
123
+ "traverse": lambda s, a: traverse(
124
+ s, a["start_nodes"], a.get("direction", "forward"),
125
+ a.get("value_mode", "current"), a.get("max_hops", 4),
126
+ a.get("overrides")),
127
+ "gaps": lambda s, a: gaps(s, a.get("start_node")),
128
+ "attest": lambda s, a: attest(s, a["problem_type"], a.get("entity_id")),
129
+ }
130
+
131
+
132
+ def dispatch(session: EngineSession, name: str,
133
+ arguments: Dict[str, Any]) -> Dict[str, Any]:
134
+ """Route one tool call and return the envelope as a dict.
135
+
136
+ Kept separate from the SDK registration so the routing table itself is
137
+ testable — a name present in TOOL_SPECS but missing from _HANDLERS is the
138
+ kind of gap that otherwise surfaces only at runtime.
139
+ """
140
+ handler = _HANDLERS.get(name)
141
+ if handler is None:
142
+ raise KeyError(f"unknown tool: {name}")
143
+ return handler(session, arguments or {}).to_dict()
144
+
145
+
146
+ def build_server(session: EngineSession | None = None):
147
+ """Construct the MCP server. Imports the SDK lazily.
148
+
149
+ this targets ``mcp.server.MCPServer``, the high-level class in
150
+ the 2.x SDK, which derives each tool's input schema from the wrapper's
151
+ signature. The first version registered handlers with
152
+ ``@server.list_tools()`` / ``@server.call_tool()`` on the low-level
153
+ ``Server``; those decorators do not exist in 2.x, so **it raised
154
+ ``AttributeError`` on the first line of registration and had never once
155
+ been run.** Nothing caught it because the SDK cannot be installed in this
156
+ repo's environment, so the shim had no test at all — see the module note
157
+ below.
158
+
159
+ The wrappers are deliberately trivial: each one calls :func:`dispatch`,
160
+ which stays the single routing table and stays testable without the SDK.
161
+ ``TOOL_SPECS`` remains the source of the descriptions, because those are
162
+ what an agent actually reads.
163
+ """
164
+ from mcp.server import MCPServer # noqa: PLC0415 — deliberate lazy import
165
+
166
+ state = session or EngineSession()
167
+ server = MCPServer("arbiter-engine")
168
+ spec_by_name = {spec["name"]: spec for spec in TOOL_SPECS}
169
+
170
+ def _emit(name: str, arguments: Dict[str, Any]) -> str:
171
+ return json.dumps(dispatch(state, name, arguments), indent=2)
172
+
173
+ async def model_describe_tool() -> str:
174
+ return _emit("model_describe", {})
175
+
176
+ async def check_tool() -> str:
177
+ return _emit("check", {})
178
+
179
+ async def traverse_tool(
180
+ start_nodes: List[str],
181
+ direction: str = "forward",
182
+ value_mode: str = "current",
183
+ max_hops: int = 4,
184
+ overrides: Dict[str, Any] | None = None,
185
+ ) -> str:
186
+ return _emit("traverse", {
187
+ "start_nodes": start_nodes, "direction": direction,
188
+ "value_mode": value_mode, "max_hops": max_hops,
189
+ "overrides": overrides,
190
+ })
191
+
192
+ async def gaps_tool(start_node: str | None = None) -> str:
193
+ return _emit("gaps", {"start_node": start_node})
194
+
195
+ async def attest_tool(problem_type: str,
196
+ entity_id: str | None = None) -> str:
197
+ return _emit("attest", {"problem_type": problem_type,
198
+ "entity_id": entity_id})
199
+
200
+ for name, fn in (
201
+ ("model_describe", model_describe_tool),
202
+ ("check", check_tool),
203
+ ("traverse", traverse_tool),
204
+ ("gaps", gaps_tool),
205
+ ("attest", attest_tool),
206
+ ):
207
+ server.add_tool(fn, name=name,
208
+ description=spec_by_name[name]["description"])
209
+
210
+ return server
211
+
212
+
213
+ def main() -> int:
214
+ import asyncio
215
+
216
+ asyncio.run(build_server().run_stdio_async())
217
+ return 0
218
+
219
+
220
+ if __name__ == "__main__":
221
+ raise SystemExit(main())
@@ -0,0 +1,18 @@
1
+ """
2
+ Ontology Reasoning - Layer 2 Detection.
3
+
4
+ The ontology layer uses semantic reasoning based on health indicators
5
+ defined in RDF/OWL ontologies. It maps entities to health concepts
6
+ and checks the 6 System Health Axioms.
7
+
8
+ Key Features:
9
+ - RDF/TTL ontology support via rdflib
10
+ - Health meta-ontology for domain-agnostic concepts
11
+ - Per-axiom reasoning with configurable thresholds
12
+ - History-aware detection for temporal axioms
13
+ """
14
+
15
+ from .reasoner import UnifiedAxiomReasoner
16
+ from .loader import OntologyLoader
17
+
18
+ __all__ = ['UnifiedAxiomReasoner', 'OntologyLoader']
@@ -0,0 +1,332 @@
1
+ """AxiomVerdict production-readiness substrate (chain head).
2
+
3
+ Production-readiness substrate for the 8 canonical axiom checkers at
4
+ arbiter_engine/ontology/axioms/ (HOMEOSTASIS / RESPONSIVENESS / MONOTONICITY /
5
+ BOUNDEDNESS / CONSISTENCY / CONSERVATION / CAUSALITY / PREDICTION_QUALITY).
6
+
7
+ Per sibling-within-module precedent: axioms/ dir hosts 8
8
+ sibling checker modules with no single shared parent module suitable for
9
+ sibling extension. ships substrate at new standalone module
10
+ (parent = `arbiter_engine/ontology/`, sibling to `axioms/` package) rather
11
+ than mutate any individual checker. the established pattern sibling-substrate
12
+ discipline preserved (substrate sibling at axis-parent level rather than
13
+ single-module level).
14
+
15
+ Adds per-verdict production recording + 5 production-readiness public
16
+ functions + an established pattern env-gate. Composes hybrid
17
+ emit-policy decision (transition OR confidence_threshold gate) +
18
+ attestation severity floor + NaturalCategoryDispatcher
19
+ (emit_policy axis dispatch via existing 9th canonical axis added; no new axis).
20
+
21
+ the established pattern sibling-substrate-within-existing-module variant.
22
+
23
+ Domain-agnostic: axiom_name + entity_id + verdict + confidence scalars
24
+ opaque; no per-domain dispatch.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import threading
31
+ from contextlib import contextmanager
32
+ from contextvars import ContextVar
33
+ from dataclasses import dataclass
34
+ from datetime import datetime
35
+ from typing import Dict, Iterator, List, Optional
36
+
37
+
38
+ def _env_bool(name: str, default: bool = False) -> bool:
39
+ raw = os.environ.get(name, "1" if default else "0").strip().lower()
40
+ return raw in ("1", "true", "yes", "on")
41
+
42
+
43
+ DT_AXIOM_VERDICT_PRODUCTION_ENABLED: bool = _env_bool(
44
+ "DT_AXIOM_VERDICT_PRODUCTION_ENABLED", default=False
45
+ )
46
+ DT_AXIOM_VERDICT_PRODUCTION_RING_CAP: int = int(
47
+ os.environ.get("DT_AXIOM_VERDICT_PRODUCTION_RING_CAP", "10000")
48
+ )
49
+ DT_AXIOM_VERDICT_CONFIDENCE_THRESHOLD: float = float(
50
+ os.environ.get("DT_AXIOM_VERDICT_CONFIDENCE_THRESHOLD", "0.5")
51
+ )
52
+
53
+ # Per hybrid emit-policy default
54
+ PRODUCTION_AXIOM_VERDICT_EMIT_POLICY_HYBRID: str = "hybrid"
55
+ PRODUCTION_AXIOM_VERDICT_EMIT_POLICY_FULL_EMIT: str = "full_emit"
56
+ PRODUCTION_AXIOM_VERDICT_EMIT_POLICY_SUPPRESSED: str = "suppressed"
57
+ KNOWN_PRODUCTION_AXIOM_VERDICT_EMIT_POLICIES = frozenset([
58
+ PRODUCTION_AXIOM_VERDICT_EMIT_POLICY_HYBRID,
59
+ PRODUCTION_AXIOM_VERDICT_EMIT_POLICY_FULL_EMIT,
60
+ PRODUCTION_AXIOM_VERDICT_EMIT_POLICY_SUPPRESSED,
61
+ ])
62
+ DEFAULT_PRODUCTION_AXIOM_VERDICT_EMIT_POLICY: str = (
63
+ PRODUCTION_AXIOM_VERDICT_EMIT_POLICY_HYBRID
64
+ )
65
+
66
+ # The 8 canonical axiom names per arbiter_engine/ontology/axioms/*.py
67
+ KNOWN_CANONICAL_AXIOMS = frozenset([
68
+ "HOMEOSTASIS",
69
+ "RESPONSIVENESS",
70
+ "MONOTONICITY",
71
+ "BOUNDEDNESS",
72
+ "CONSISTENCY",
73
+ "CONSERVATION",
74
+ "CAUSALITY",
75
+ "PREDICTION_QUALITY",
76
+ ])
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class ProductionAxiomVerdict:
81
+ """ per-verdict production-readiness axiom-verdict event.
82
+
83
+ 5 opaque fields domain-agnostic invariant. Frozen for
84
+ audit-trail provenance emit-policy decision.
85
+
86
+ (Bucket A; 2nd worked example after RCA):
87
+ added optional ``cluster_id`` for per-axis cluster-scope filtering.
88
+ Default None preserves previously behavior; emission callsites that
89
+ pass cluster_id stamp the record so
90
+ ``get_axiom_verdicts(cluster_id=X)`` can filter. Field added with
91
+ default so existing emissions are backward-compat.
92
+ """
93
+
94
+ axiom_name: str
95
+ entity_id: str
96
+ verdict: str # "PASS" | "FAIL" | "UNKNOWN"
97
+ confidence: float
98
+ emit_policy_per_cd1098: str
99
+ cluster_id: Optional[str] = None # (Bucket A)
100
+
101
+
102
+ def resolve_production_axiom_verdict_emit_policy(value: Optional[str]) -> str:
103
+ """Safe-default to hybrid."""
104
+ if value is None or value not in KNOWN_PRODUCTION_AXIOM_VERDICT_EMIT_POLICIES:
105
+ return DEFAULT_PRODUCTION_AXIOM_VERDICT_EMIT_POLICY
106
+ return value
107
+
108
+
109
+ _PRODUCTION_VERDICTS: List[ProductionAxiomVerdict] = []
110
+ _PRODUCTION_LOCK = threading.RLock()
111
+ _PRODUCTION_LAST_VERDICT: Dict[tuple, str] = {} # (entity_id, axiom_name) -> verdict
112
+
113
+
114
+ # ---------- Layer 3 ContextVar emission-scope stamping ----------
115
+ #
116
+ # `record_axiom_verdict` is called from `arbiter_engine/ontology/reasoner.py`
117
+ # inside the per-axiom check loop. That code path does NOT know cluster_id
118
+ # at the function-signature level — DetectorInterface.detect() takes
119
+ # (entities, graph, history) without cluster_id, and threading cluster_id
120
+ # through the detector interface chain is out-of-scope (would touch every
121
+ # detector implementation).
122
+ #
123
+ # Instead, callers in the full system (live loop + Monte Carlo path) wrap
124
+ # the detect-call with `cluster_scope(cluster_id)`; the ContextVar
125
+ # propagates correctly through async/await chains (asyncio.create_task
126
+ # copies the context by default). Inside `record_axiom_verdict`, if the
127
+ # explicit `cluster_id` parameter is None, we fall back to reading the
128
+ # ContextVar. This is minimal-invasive Layer-3 stamping without rewriting
129
+ # the detector contract.
130
+ #
131
+ # substrate-callsite-gap discipline (post-promotion to
132
+ # reference architecture): the substrate-side ContextVar +
133
+ # the caller-side `with cluster_scope(cluster_id):` block together form
134
+ # the bridge between substrate-emission and cluster-aware caller context.
135
+ _CURRENT_CLUSTER_ID: ContextVar[Optional[str]] = ContextVar(
136
+ "axiom_verdict_cluster_id", default=None,
137
+ )
138
+
139
+
140
+ @contextmanager
141
+ def cluster_scope(cluster_id: Optional[str]) -> Iterator[None]:
142
+ """ Layer 3 helper: wrap a detect-call so emissions stamp
143
+ cluster_id automatically.
144
+
145
+ Usage in the full system:
146
+
147
+ with cluster_scope(cluster_id):
148
+ detection_result = await bridge.detect(entities, graph)
149
+
150
+ Any `record_axiom_verdict()` calls inside the block — including those
151
+ several frames deep in the detector chain — will pick up cluster_id
152
+ from the ContextVar when their own `cluster_id` parameter is None.
153
+ Explicit parameter still wins (precedence: explicit > ContextVar >
154
+ None). Safe for nested scopes via ContextVar reset.
155
+
156
+ cluster_id=None is also valid (no-op scope; preserves backward compat).
157
+ """
158
+ token = _CURRENT_CLUSTER_ID.set(cluster_id)
159
+ try:
160
+ yield
161
+ finally:
162
+ _CURRENT_CLUSTER_ID.reset(token)
163
+
164
+
165
+ def _resolve_cluster_id(explicit: Optional[str]) -> Optional[str]:
166
+ """Resolve effective cluster_id: explicit param > ContextVar > None."""
167
+ if explicit is not None:
168
+ return explicit
169
+ return _CURRENT_CLUSTER_ID.get()
170
+
171
+
172
+ def _wire_composition_for_entity(entity_id: str) -> None:
173
+ """feed an entity's full per-axiom verdict snapshot to the
174
+ axiom-composition wiring at the recording callsite (was callsite-less —
175
+ the established pattern substrate-callsite gap, review Part 66 / ARCHITECTURE Part 65.3).
176
+
177
+ Reads the wire-up gate dynamically so it stays byte-identical when
178
+ ``DT_AXIOM_COMPOSITION_WIRING_ENABLED`` is OFF (default): early-return, no
179
+ snapshot build, no state change. When ON, rebuilds the entity's per-axiom
180
+ snapshot from ``_PRODUCTION_LAST_VERDICT`` and registers it so
181
+ ``get_axiom_composition_wired_entity_count()`` becomes nonzero (which is
182
+ what flips ``/dt-axiom-verdicts`` from ``warming_up`` to ``ready``).
183
+ """
184
+ try:
185
+ from arbiter_engine.ontology import (
186
+ axiom_composition_wiring as _acw,
187
+ )
188
+ except Exception: # noqa: BLE001 — composition wiring not deployed
189
+ return
190
+ if not _acw.DT_AXIOM_COMPOSITION_WIRING_ENABLED:
191
+ return
192
+ with _PRODUCTION_LOCK:
193
+ snapshot = {
194
+ ax: v
195
+ for (eid, ax), v in _PRODUCTION_LAST_VERDICT.items()
196
+ if eid == entity_id
197
+ }
198
+ if snapshot:
199
+ _acw.wire_axiom_composition_for_entity(entity_id, snapshot)
200
+
201
+
202
+ def record_axiom_verdict(
203
+ axiom_name: str,
204
+ entity_id: str,
205
+ verdict: str,
206
+ confidence: float,
207
+ emit_policy: Optional[str] = None,
208
+ cluster_id: Optional[str] = None,
209
+ ) -> Optional[ProductionAxiomVerdict]:
210
+ """Record an axiom-verdict event at production-readiness shape.
211
+
212
+ Returns the stored ProductionAxiomVerdict when gate enabled AND
213
+ emit_policy admits the event; returns None when gate off OR
214
+ emit_policy suppressed OR hybrid mode rejects (verdict-transition OR confidence >= threshold) gate.
215
+
216
+ Hybrid mode gate: admits if (a) prior verdict for (entity_id,
217
+ axiom_name) differs from current, OR (b) confidence >= threshold.
218
+
219
+ (Bucket A): optional ``cluster_id`` stamps the record
220
+ so per-cluster retrieval queries can filter. Default None preserves
221
+ previously emission behavior (callsites that don't pass cluster_id
222
+ still record verdicts — they're then queryable via the global path
223
+ `get_axiom_verdicts()` but excluded from filtered queries).
224
+ """
225
+ if not DT_AXIOM_VERDICT_PRODUCTION_ENABLED:
226
+ return None
227
+ policy = resolve_production_axiom_verdict_emit_policy(emit_policy)
228
+ if policy == PRODUCTION_AXIOM_VERDICT_EMIT_POLICY_SUPPRESSED:
229
+ return None
230
+ key = (entity_id, axiom_name)
231
+ if policy == PRODUCTION_AXIOM_VERDICT_EMIT_POLICY_HYBRID:
232
+ with _PRODUCTION_LOCK:
233
+ prior_verdict = _PRODUCTION_LAST_VERDICT.get(key)
234
+ verdict_transition = (prior_verdict is None) or (prior_verdict != verdict)
235
+ confidence_crosses = float(confidence) >= DT_AXIOM_VERDICT_CONFIDENCE_THRESHOLD
236
+ if not (verdict_transition or confidence_crosses):
237
+ return None
238
+ # Layer 3: explicit cluster_id wins; fall back to ContextVar
239
+ # set by `cluster_scope(cluster_id)` in the caller (typically the full system
240
+ # detect-loop wrapper). None when neither is set (previously path).
241
+ effective_cluster_id = _resolve_cluster_id(cluster_id)
242
+ record = ProductionAxiomVerdict(
243
+ axiom_name=axiom_name,
244
+ entity_id=entity_id,
245
+ verdict=verdict,
246
+ confidence=float(confidence),
247
+ emit_policy_per_cd1098=policy,
248
+ cluster_id=effective_cluster_id,
249
+ )
250
+ with _PRODUCTION_LOCK:
251
+ _PRODUCTION_VERDICTS.append(record)
252
+ _PRODUCTION_LAST_VERDICT[key] = verdict
253
+ if len(_PRODUCTION_VERDICTS) > DT_AXIOM_VERDICT_PRODUCTION_RING_CAP:
254
+ del _PRODUCTION_VERDICTS[
255
+ : len(_PRODUCTION_VERDICTS) - DT_AXIOM_VERDICT_PRODUCTION_RING_CAP
256
+ ]
257
+ # land the previously-callsite-less axiom-composition wire so
258
+ # composition_wired_count can be nonzero. Self-gated on the established pattern
259
+ # DT_AXIOM_COMPOSITION_WIRING_ENABLED (default OFF -> no-op, byte-identical).
260
+ _wire_composition_for_entity(entity_id)
261
+ return record
262
+
263
+
264
+ def _filter_by_cluster_id(
265
+ verdicts: List[ProductionAxiomVerdict],
266
+ cluster_id: Optional[str],
267
+ ) -> List[ProductionAxiomVerdict]:
268
+ """ helper: filter verdicts by cluster_id when non-None.
269
+
270
+ cluster_id=None returns the full list (backward compat).
271
+ cluster_id="X" returns only verdicts with v.cluster_id == "X".
272
+ Verdicts emitted previously have cluster_id=None and are
273
+ excluded from any non-None filter (signaling "not scoped").
274
+ Mirror of the RCA `_filter_by_cluster_id` pattern.
275
+ """
276
+ if cluster_id is None:
277
+ return verdicts
278
+ return [v for v in verdicts if v.cluster_id == cluster_id]
279
+
280
+
281
+ def get_axiom_verdicts(
282
+ cluster_id: Optional[str] = None,
283
+ ) -> List[ProductionAxiomVerdict]:
284
+ """All recorded production axiom-verdict records. Empty when gate off.
285
+
286
+ optional ``cluster_id`` filter. None = all (backward compat);
287
+ string value returns only verdicts stamped with that cluster_id.
288
+ """
289
+ if not DT_AXIOM_VERDICT_PRODUCTION_ENABLED:
290
+ return []
291
+ with _PRODUCTION_LOCK:
292
+ all_verdicts = list(_PRODUCTION_VERDICTS)
293
+ return _filter_by_cluster_id(all_verdicts, cluster_id)
294
+
295
+
296
+ def get_axiom_verdict_count(cluster_id: Optional[str] = None) -> int:
297
+ """Aggregate count of recorded production axiom-verdict records.
298
+
299
+ Dashboard-data defensive-accessor entry point + Pattern
300
+ 171. Returns 0 when gate off.
301
+
302
+ optional ``cluster_id`` filter. None = aggregate count;
303
+ string value returns count of verdicts stamped with that cluster_id.
304
+ """
305
+ if not DT_AXIOM_VERDICT_PRODUCTION_ENABLED:
306
+ return 0
307
+ return len(get_axiom_verdicts(cluster_id=cluster_id))
308
+
309
+
310
+ def get_latest_verdict_for_entity(
311
+ entity_id: str, axiom_name: str,
312
+ ) -> Optional[str]:
313
+ """Last-known verdict for (entity_id, axiom_name); None when unknown
314
+ or gate off."""
315
+ if not DT_AXIOM_VERDICT_PRODUCTION_ENABLED:
316
+ return None
317
+ with _PRODUCTION_LOCK:
318
+ return _PRODUCTION_LAST_VERDICT.get((entity_id, axiom_name))
319
+
320
+
321
+ def known_verdict_axioms() -> List[str]:
322
+ """Diagnostic accessor — sorted unique axiom_name values."""
323
+ if not DT_AXIOM_VERDICT_PRODUCTION_ENABLED:
324
+ return []
325
+ with _PRODUCTION_LOCK:
326
+ return sorted({r.axiom_name for r in _PRODUCTION_VERDICTS})
327
+
328
+
329
+ def _reset_production_verdicts_for_tests() -> None:
330
+ with _PRODUCTION_LOCK:
331
+ _PRODUCTION_VERDICTS.clear()
332
+ _PRODUCTION_LAST_VERDICT.clear()
@@ -0,0 +1,26 @@
1
+ """
2
+ Per-axiom checker implementations.
3
+
4
+ Each axiom has its own checker that implements the detection logic
5
+ based on the mathematical definitions in the architecture document.
6
+ """
7
+
8
+ from .stability import StabilityChecker
9
+ from .boundedness import BoundednessChecker
10
+ from .connectivity import ConnectivityChecker
11
+ from .consistency import ConsistencyChecker
12
+ from .responsiveness import ResponsivenessChecker
13
+ from .homeostasis import HomeostasisChecker
14
+ from .conservation import ConservationChecker
15
+ from .monotonicity import MonotonicityChecker
16
+
17
+ __all__ = [
18
+ 'StabilityChecker',
19
+ 'BoundednessChecker',
20
+ 'ConnectivityChecker',
21
+ 'ConsistencyChecker',
22
+ 'ResponsivenessChecker',
23
+ 'HomeostasisChecker',
24
+ 'ConservationChecker',
25
+ 'MonotonicityChecker',
26
+ ]