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,1260 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TopologyTraverser — unified BFS engine for the Digital Twin topology.
|
|
3
|
+
|
|
4
|
+
All problem solving passes through traverse(). Convenience methods
|
|
5
|
+
(find_root_causes, predict_impact, etc.) configure a TraversalRequest
|
|
6
|
+
and delegate to traverse().
|
|
7
|
+
|
|
8
|
+
Supports three directions (FORWARD, REVERSE, BIDIRECTIONAL) and three
|
|
9
|
+
value modes (CURRENT, PROJECTED, HYPOTHETICAL).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from collections import deque
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
from typing import Any, Callable, Dict, FrozenSet, List, Optional, Set, Tuple
|
|
18
|
+
|
|
19
|
+
from ..interfaces import Entity, Problem, ObservationHistory
|
|
20
|
+
from ..types import Axiom, Severity, DetectionLayer, AxiomParameters
|
|
21
|
+
from ..temporal.trend_projection import TrendProjection
|
|
22
|
+
from ..propagation.impact_estimator import DownstreamImpact, ImpactForecast
|
|
23
|
+
from ..propagation.root_cause import (
|
|
24
|
+
RootCauseResult,
|
|
25
|
+
collect_upstream_candidates,
|
|
26
|
+
select_root_causes_via_set_cover,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from .topology import (
|
|
30
|
+
TwinNode, TwinEdge, TopologyGap, DigitalTwinTopology,
|
|
31
|
+
AxiomState, TraversalRequest, TraversalResult, TraversalStep,
|
|
32
|
+
TopologyQuestion, ProjectedValue,
|
|
33
|
+
TraversalDirection, ValueMode, EdgeDirection, FlowType,
|
|
34
|
+
GapType, ResolutionStrategy,
|
|
35
|
+
)
|
|
36
|
+
from ..axiom_thresholds import (
|
|
37
|
+
resolve_axiom_threshold,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# Severity decay per hop for impact estimation.
|
|
43
|
+
_SEVERITY_DECAY = {
|
|
44
|
+
Severity.CRITICAL: Severity.HIGH,
|
|
45
|
+
Severity.HIGH: Severity.MEDIUM,
|
|
46
|
+
Severity.MEDIUM: Severity.LOW,
|
|
47
|
+
Severity.LOW: Severity.WARNING,
|
|
48
|
+
Severity.WARNING: Severity.INFO,
|
|
49
|
+
Severity.INFO: Severity.INFO,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# flow-direction markers for the structural CONSERVATION path.
|
|
54
|
+
# Matched as underscore-delimited tokens, never as substrings.
|
|
55
|
+
_FLOW_IN_TOKENS: FrozenSet[str] = frozenset({'in', 'input', 'received'})
|
|
56
|
+
_FLOW_OUT_TOKENS: FrozenSet[str] = frozenset({'out', 'output', 'sent'})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def classify_flow_direction(prop_name: str) -> Optional[str]:
|
|
60
|
+
"""Classify a property name as 'in', 'out', or None (neither).
|
|
61
|
+
|
|
62
|
+
Matching is on underscore-delimited tokens. The previous form
|
|
63
|
+
tested raw substring containment, which swept every property whose name
|
|
64
|
+
merely contained the marker letters anywhere into the inflow total —
|
|
65
|
+
in the shipped domain files that is ``material_integrity``,
|
|
66
|
+
``policy_intent``, ``root_cause_indicators``, ``seal_integrity``,
|
|
67
|
+
``wear_indicators``, ``sensor_invalid_read_rate``, ``freeze_instrument``
|
|
68
|
+
and ``periodic_detection_interval``, plus ``days_outstanding`` and
|
|
69
|
+
``load_generator_outage_simulation`` on the outflow side. None of those carry
|
|
70
|
+
flow, and summing them manufactures a conservation deficit from nothing.
|
|
71
|
+
|
|
72
|
+
Token matching rather than ``endswith`` is deliberate: the unit-suffixed
|
|
73
|
+
``flow_in_m3h`` / ``flow_out_m3h`` and the suffix-form ``voltage_output``
|
|
74
|
+
are genuine flow properties in the shipped domains, and a suffix test
|
|
75
|
+
would silently drop all three.
|
|
76
|
+
|
|
77
|
+
Known residual: a standalone ``in``/``input`` token inside a name that is
|
|
78
|
+
not a flow quantity still matches — ``engage_human_in_loop``,
|
|
79
|
+
``bad_actor_input``, ``line_input_status``. Name-based inference cannot
|
|
80
|
+
resolve those; only a declaration can. See on why this path
|
|
81
|
+
consults no declaration at all.
|
|
82
|
+
"""
|
|
83
|
+
tokens = set(prop_name.lower().split('_'))
|
|
84
|
+
if tokens & _FLOW_IN_TOKENS:
|
|
85
|
+
return 'in'
|
|
86
|
+
if tokens & _FLOW_OUT_TOKENS:
|
|
87
|
+
return 'out'
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TopologyTraverser:
|
|
92
|
+
"""Unified traversal engine for the Digital Twin topology."""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
topology: DigitalTwinTopology,
|
|
97
|
+
observation_history: Optional[ObservationHistory] = None,
|
|
98
|
+
# `axiom_checkers` was accepted here, stored, and never read
|
|
99
|
+
# by any code path. Removed rather than left in place: a parameter that
|
|
100
|
+
# is silently ignored is worse than an absent one, because a caller can
|
|
101
|
+
# pass checkers and reasonably expect them to be used. No caller passed
|
|
102
|
+
# it (verified across every construction site), so removal is safe
|
|
103
|
+
# despite the positional shift. Full axiom dispatch lives in
|
|
104
|
+
# UnifiedAxiomReasoner.
|
|
105
|
+
# `degradation_fitter` was accepted here too, and removed
|
|
106
|
+
# for a *different* reason from `axiom_checkers` above. It was not
|
|
107
|
+
# merely unread: it does not fit.
|
|
108
|
+
#
|
|
109
|
+
# `trend_projector` and `history` looked equally dead and were not —
|
|
110
|
+
# they were dead only because `project_values` did not exist yet, so
|
|
111
|
+
# removing them would have deleted the evidence for a missing step
|
|
112
|
+
# (wired it, and they are read there now). `degradation_fitter`
|
|
113
|
+
# was checked against the same test and fails it:
|
|
114
|
+
#
|
|
115
|
+
# - `DegradationFitter.fit(observations, failure_threshold)` needs a
|
|
116
|
+
# per-indicator threshold. `project_values` walks
|
|
117
|
+
# `entity.properties` and never consults the domain model, so the
|
|
118
|
+
# traverser has no threshold to give it.
|
|
119
|
+
# - Its output is remaining useful life — a time-to-threshold.
|
|
120
|
+
# `ProjectedValue` carries value / confidence / horizon_s / model,
|
|
121
|
+
# a *value at a horizon*. There is no field for a RUL and adding
|
|
122
|
+
# one is a schema decision, not a wiring.
|
|
123
|
+
# - The capability is already live elsewhere: wired it into
|
|
124
|
+
# the full system, which does have thresholds.
|
|
125
|
+
#
|
|
126
|
+
# So this is a parameter for a capability that belongs to another
|
|
127
|
+
# surface, not a producer waiting to be connected. Wiring it here
|
|
128
|
+
# would need threshold plumbing plus a `ProjectedValue` field; if that
|
|
129
|
+
# is ever wanted, it is a new CD, not a restored parameter.
|
|
130
|
+
trend_projector: Optional[TrendProjection] = None,
|
|
131
|
+
axiom_params: Optional[AxiomParameters] = None,
|
|
132
|
+
):
|
|
133
|
+
self.topology = topology
|
|
134
|
+
self.history = observation_history
|
|
135
|
+
self.trend_projector = trend_projector
|
|
136
|
+
# global fallback for the CONSERVATION flow-balance residual
|
|
137
|
+
# thresholds (per-entity overrides win over these).
|
|
138
|
+
self.axiom_params = axiom_params or AxiomParameters()
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
# Core traversal
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def traverse(self, request: TraversalRequest) -> TraversalResult:
|
|
145
|
+
"""Core BFS traversal parameterized by TraversalRequest."""
|
|
146
|
+
start_time = time.monotonic()
|
|
147
|
+
result = TraversalResult()
|
|
148
|
+
visited: Set[str] = set()
|
|
149
|
+
|
|
150
|
+
# BFS queue: (entity_id, hop, cum_prob, cum_delay, path)
|
|
151
|
+
queue: deque = deque()
|
|
152
|
+
for start_id in request.start_nodes:
|
|
153
|
+
queue.append((start_id, 0, 1.0, 0.0, [start_id]))
|
|
154
|
+
|
|
155
|
+
while queue:
|
|
156
|
+
current_id, hop, cum_prob, cum_delay, path = queue.popleft()
|
|
157
|
+
|
|
158
|
+
if current_id in visited:
|
|
159
|
+
continue
|
|
160
|
+
visited.add(current_id)
|
|
161
|
+
|
|
162
|
+
node = self.topology.get_node(current_id)
|
|
163
|
+
|
|
164
|
+
# Node not in topology -> gap
|
|
165
|
+
if node is None:
|
|
166
|
+
if request.collect_gaps:
|
|
167
|
+
gap = TopologyGap(
|
|
168
|
+
gap_type=GapType.MISSING_NODE,
|
|
169
|
+
location=current_id,
|
|
170
|
+
description=(
|
|
171
|
+
f"Entity {current_id} not found in topology"
|
|
172
|
+
),
|
|
173
|
+
discovered_during="traverse",
|
|
174
|
+
)
|
|
175
|
+
result.gaps_discovered.append(gap)
|
|
176
|
+
result.questions_generated.append(TopologyQuestion(
|
|
177
|
+
gap=gap,
|
|
178
|
+
question_text=gap.question,
|
|
179
|
+
priority=self._compute_priority(
|
|
180
|
+
gap, hop, cum_prob
|
|
181
|
+
),
|
|
182
|
+
context_path=list(path),
|
|
183
|
+
))
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
# Get property values based on value_mode
|
|
187
|
+
values = self._get_values(node, request)
|
|
188
|
+
|
|
189
|
+
# Evaluate axiom states
|
|
190
|
+
step_violations: List[Problem] = []
|
|
191
|
+
if request.collect_axiom_violations:
|
|
192
|
+
step_violations, attempted = self._evaluate_axioms(node, values)
|
|
193
|
+
result.problems_detected.extend(step_violations)
|
|
194
|
+
# Accumulated inside the `collect_axiom_violations`
|
|
195
|
+
# guard on purpose: with collection off nothing is evaluated,
|
|
196
|
+
# and the denominator must say zero rather than report the
|
|
197
|
+
# states the builder seeded.
|
|
198
|
+
result.axiom_evaluations_attempted += attempted
|
|
199
|
+
|
|
200
|
+
# Record step
|
|
201
|
+
step = TraversalStep(
|
|
202
|
+
node_id=current_id,
|
|
203
|
+
hop=hop,
|
|
204
|
+
cumulative_probability=cum_prob,
|
|
205
|
+
cumulative_delay_s=cum_delay,
|
|
206
|
+
path=list(path),
|
|
207
|
+
axiom_violations=step_violations,
|
|
208
|
+
)
|
|
209
|
+
result.steps.append(step)
|
|
210
|
+
|
|
211
|
+
# Get edges based on direction
|
|
212
|
+
if hop >= request.max_hops:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
# BIDIRECTIONAL traversal derives next_id per source
|
|
216
|
+
# collection. Pre-fix the loop used ``edge.target_id`` for
|
|
217
|
+
# everything except direction=REVERSE, so reverse_edges
|
|
218
|
+
# contributions collapsed to next_id == current_id (because
|
|
219
|
+
# reverse_edges[X] holds edges with target=X) and were
|
|
220
|
+
# silently filtered by the ``in visited`` check. The
|
|
221
|
+
# reverse-direction expansion was lost; only spurious self-self
|
|
222
|
+
# conservation evidence fired.
|
|
223
|
+
edges_with_next: List[Tuple[TwinEdge, str]] = []
|
|
224
|
+
if request.direction in (
|
|
225
|
+
TraversalDirection.FORWARD,
|
|
226
|
+
TraversalDirection.BIDIRECTIONAL,
|
|
227
|
+
):
|
|
228
|
+
for e in self.topology.edges.get(current_id, []):
|
|
229
|
+
edges_with_next.append((e, e.target_id))
|
|
230
|
+
if request.direction in (
|
|
231
|
+
TraversalDirection.REVERSE,
|
|
232
|
+
TraversalDirection.BIDIRECTIONAL,
|
|
233
|
+
):
|
|
234
|
+
for e in self.topology.reverse_edges.get(current_id, []):
|
|
235
|
+
edges_with_next.append((e, e.source_id))
|
|
236
|
+
|
|
237
|
+
for edge, next_id in edges_with_next:
|
|
238
|
+
# Edge filters
|
|
239
|
+
if request.edge_filter and edge.direction not in request.edge_filter:
|
|
240
|
+
continue
|
|
241
|
+
if request.flow_filter and edge.flow_type not in request.flow_filter:
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
if next_id in visited:
|
|
245
|
+
# BIDIRECTIONAL: check for FLOW cycle
|
|
246
|
+
if (request.direction == TraversalDirection.BIDIRECTIONAL
|
|
247
|
+
and edge.direction == EdgeDirection.FLOW):
|
|
248
|
+
conservation_problems = self._check_flow_balance(
|
|
249
|
+
path + [next_id]
|
|
250
|
+
)
|
|
251
|
+
result.conservation_violations.extend(
|
|
252
|
+
conservation_problems
|
|
253
|
+
)
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
new_prob = cum_prob * edge.propagation_probability
|
|
257
|
+
new_delay = cum_delay + edge.propagation_delay_s
|
|
258
|
+
|
|
259
|
+
# Pruning
|
|
260
|
+
if new_prob < request.min_probability:
|
|
261
|
+
continue
|
|
262
|
+
if new_delay > request.max_delay_s:
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
# Check if next node is missing
|
|
266
|
+
if next_id not in self.topology.nodes:
|
|
267
|
+
if request.collect_gaps:
|
|
268
|
+
gap = TopologyGap(
|
|
269
|
+
gap_type=GapType.MISSING_NODE,
|
|
270
|
+
location=next_id,
|
|
271
|
+
description=(
|
|
272
|
+
f"Edge {current_id}->{next_id} "
|
|
273
|
+
f"points to unknown entity"
|
|
274
|
+
),
|
|
275
|
+
discovered_during="traverse",
|
|
276
|
+
)
|
|
277
|
+
result.gaps_discovered.append(gap)
|
|
278
|
+
result.questions_generated.append(TopologyQuestion(
|
|
279
|
+
gap=gap,
|
|
280
|
+
question_text=gap.question,
|
|
281
|
+
priority=self._compute_priority(
|
|
282
|
+
gap, hop + 1, new_prob
|
|
283
|
+
),
|
|
284
|
+
context_path=path + [next_id],
|
|
285
|
+
))
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
queue.append((
|
|
289
|
+
next_id, hop + 1, new_prob, new_delay,
|
|
290
|
+
path + [next_id],
|
|
291
|
+
))
|
|
292
|
+
|
|
293
|
+
# Build DownstreamImpact for forward traversals
|
|
294
|
+
if request.direction in (
|
|
295
|
+
TraversalDirection.FORWARD,
|
|
296
|
+
TraversalDirection.BIDIRECTIONAL,
|
|
297
|
+
):
|
|
298
|
+
severity = self._decay_severity(Severity.HIGH, hop + 1)
|
|
299
|
+
result.impacts_predicted.append(DownstreamImpact(
|
|
300
|
+
entity_id=next_id,
|
|
301
|
+
hop_distance=hop + 1,
|
|
302
|
+
probability=new_prob,
|
|
303
|
+
expected_delay_s=new_delay,
|
|
304
|
+
severity=severity,
|
|
305
|
+
path=path + [next_id],
|
|
306
|
+
))
|
|
307
|
+
|
|
308
|
+
result.total_nodes_visited = len(visited)
|
|
309
|
+
result.traversal_time_ms = (time.monotonic() - start_time) * 1000
|
|
310
|
+
self.topology.last_traversal_at = datetime.utcnow()
|
|
311
|
+
|
|
312
|
+
# the established pattern native 6th sub-cluster callsite-wire.
|
|
313
|
+
# Defensive: substrate-unavailable / gate-off → no-op silently.
|
|
314
|
+
# the established pattern bootstrap-aware preserves kernel contract.
|
|
315
|
+
try:
|
|
316
|
+
from arbiter_engine.twin.traverser_production import (
|
|
317
|
+
record_production_traversal,
|
|
318
|
+
)
|
|
319
|
+
record_production_traversal(
|
|
320
|
+
start_node=(
|
|
321
|
+
request.start_nodes[0] if request.start_nodes else "_empty"
|
|
322
|
+
),
|
|
323
|
+
direction=(
|
|
324
|
+
request.direction.value
|
|
325
|
+
if hasattr(request.direction, "value")
|
|
326
|
+
else str(request.direction)
|
|
327
|
+
),
|
|
328
|
+
value_mode=(
|
|
329
|
+
request.value_mode.value
|
|
330
|
+
if hasattr(request.value_mode, "value")
|
|
331
|
+
else str(request.value_mode)
|
|
332
|
+
),
|
|
333
|
+
hop_count=int(result.total_nodes_visited),
|
|
334
|
+
gap_count=int(len(result.gaps_discovered)),
|
|
335
|
+
)
|
|
336
|
+
except Exception: # noqa: BLE001 — substrate not deployed
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
# Convenience methods
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def find_root_causes(
|
|
346
|
+
self,
|
|
347
|
+
anomalous_ids: Set[str],
|
|
348
|
+
max_roots: int = 10,
|
|
349
|
+
min_coverage: float = 1.0,
|
|
350
|
+
) -> RootCauseResult:
|
|
351
|
+
"""Reverse walk + greedy set cover.
|
|
352
|
+
|
|
353
|
+
: candidate collection + set-cover assembly delegate to
|
|
354
|
+
module-level helpers in ``arbiter_engine/propagation/root_cause.py`` so
|
|
355
|
+
``RootCauseIdentifier.identify`` and this method share the same
|
|
356
|
+
canonical implementation. The per-candidate forward ``traverse()``
|
|
357
|
+
invocation stays here because it depends on the TwinEdge graph
|
|
358
|
+
abstraction (propagation_probability per edge); root_cause.py's
|
|
359
|
+
equivalent uses the simpler RelationshipGraph + weights dict.
|
|
360
|
+
"""
|
|
361
|
+
if not anomalous_ids:
|
|
362
|
+
return RootCauseResult()
|
|
363
|
+
|
|
364
|
+
anomalies = frozenset(anomalous_ids)
|
|
365
|
+
|
|
366
|
+
candidates = collect_upstream_candidates(
|
|
367
|
+
anomalies,
|
|
368
|
+
lambda eid: (
|
|
369
|
+
e.source_id for e in self.topology.reverse_edges.get(eid, [])
|
|
370
|
+
),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Forward propagation footprint for each candidate via traverse().
|
|
374
|
+
footprints: Dict[str, Set[str]] = {}
|
|
375
|
+
footprint_probs: Dict[str, float] = {}
|
|
376
|
+
footprint_hops: Dict[str, float] = {}
|
|
377
|
+
|
|
378
|
+
for cid in candidates:
|
|
379
|
+
result = self.traverse(TraversalRequest(
|
|
380
|
+
start_nodes=[cid],
|
|
381
|
+
direction=TraversalDirection.FORWARD,
|
|
382
|
+
max_hops=4,
|
|
383
|
+
min_probability=0.05,
|
|
384
|
+
collect_axiom_violations=False,
|
|
385
|
+
collect_gaps=False,
|
|
386
|
+
))
|
|
387
|
+
covered = {
|
|
388
|
+
s.node_id for s in result.steps
|
|
389
|
+
if s.node_id in anomalies
|
|
390
|
+
}
|
|
391
|
+
if cid in anomalies:
|
|
392
|
+
covered.add(cid)
|
|
393
|
+
if covered:
|
|
394
|
+
footprints[cid] = covered
|
|
395
|
+
probs = [
|
|
396
|
+
s.cumulative_probability for s in result.steps
|
|
397
|
+
if s.node_id in covered
|
|
398
|
+
]
|
|
399
|
+
hops_list = [
|
|
400
|
+
float(s.hop) for s in result.steps
|
|
401
|
+
if s.node_id in covered
|
|
402
|
+
]
|
|
403
|
+
footprint_probs[cid] = (
|
|
404
|
+
sum(probs) / len(probs) if probs else 0.0
|
|
405
|
+
)
|
|
406
|
+
footprint_hops[cid] = (
|
|
407
|
+
sum(hops_list) / len(hops_list) if hops_list else 0.0
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
return select_root_causes_via_set_cover(
|
|
411
|
+
footprints=footprints,
|
|
412
|
+
footprint_probs=footprint_probs,
|
|
413
|
+
footprint_hops=footprint_hops,
|
|
414
|
+
anomalies=anomalies,
|
|
415
|
+
max_roots=max_roots,
|
|
416
|
+
min_coverage=min_coverage,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def predict_impact(
|
|
420
|
+
self,
|
|
421
|
+
problem: Problem,
|
|
422
|
+
horizon_s: float = 3600.0,
|
|
423
|
+
) -> ImpactForecast:
|
|
424
|
+
"""Forward walk from problem source."""
|
|
425
|
+
result = self.traverse(TraversalRequest(
|
|
426
|
+
start_nodes=[problem.entity_id],
|
|
427
|
+
direction=TraversalDirection.FORWARD,
|
|
428
|
+
value_mode=ValueMode.CURRENT,
|
|
429
|
+
max_hops=4,
|
|
430
|
+
min_probability=0.05,
|
|
431
|
+
collect_axiom_violations=False,
|
|
432
|
+
))
|
|
433
|
+
sorted_impacts = sorted(
|
|
434
|
+
result.impacts_predicted,
|
|
435
|
+
key=lambda i: i.probability,
|
|
436
|
+
reverse=True,
|
|
437
|
+
)
|
|
438
|
+
max_hop = max(
|
|
439
|
+
(i.hop_distance for i in sorted_impacts), default=0
|
|
440
|
+
)
|
|
441
|
+
return ImpactForecast(
|
|
442
|
+
source_problem=problem,
|
|
443
|
+
downstream_impacts=sorted_impacts,
|
|
444
|
+
total_affected=len(sorted_impacts),
|
|
445
|
+
max_hop_distance=max_hop,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
def check_conservation(self, node_id: str) -> List[Problem]:
|
|
449
|
+
"""Bidirectional flow-edge cycle detection and balance check."""
|
|
450
|
+
cycles = self.topology.get_flow_cycles(node_id)
|
|
451
|
+
problems: List[Problem] = []
|
|
452
|
+
for cycle in cycles:
|
|
453
|
+
cycle_ids = [cycle[0].source_id] + [e.target_id for e in cycle]
|
|
454
|
+
problems.extend(self._check_flow_balance(cycle_ids))
|
|
455
|
+
return problems
|
|
456
|
+
|
|
457
|
+
def check_connectivity(
|
|
458
|
+
self,
|
|
459
|
+
node_id: str,
|
|
460
|
+
target_type: Optional[str] = None,
|
|
461
|
+
min_cardinality: int = 1,
|
|
462
|
+
) -> List[Problem]:
|
|
463
|
+
"""Forward reachability / adjacency check."""
|
|
464
|
+
node = self.topology.get_node(node_id)
|
|
465
|
+
if not node:
|
|
466
|
+
return []
|
|
467
|
+
outgoing = self.topology.edges.get(node_id, [])
|
|
468
|
+
if target_type:
|
|
469
|
+
matching = [
|
|
470
|
+
e for e in outgoing
|
|
471
|
+
if self.topology.get_node(e.target_id)
|
|
472
|
+
and self.topology.get_node(e.target_id).entity.type.lower()
|
|
473
|
+
== target_type.lower()
|
|
474
|
+
]
|
|
475
|
+
else:
|
|
476
|
+
matching = outgoing
|
|
477
|
+
if len(matching) < min_cardinality:
|
|
478
|
+
return [Problem.from_entity(
|
|
479
|
+
entity=node.entity,
|
|
480
|
+
problem_type='missing_connectivity',
|
|
481
|
+
severity=Severity.HIGH,
|
|
482
|
+
reason=(
|
|
483
|
+
f"Expected {min_cardinality} connections to "
|
|
484
|
+
f"{target_type or 'any'}, found {len(matching)}"
|
|
485
|
+
),
|
|
486
|
+
axiom=Axiom.CONNECTIVITY,
|
|
487
|
+
source_layer=DetectionLayer.ONTOLOGY,
|
|
488
|
+
evidence={
|
|
489
|
+
'expected_min': min_cardinality,
|
|
490
|
+
'actual_count': len(matching),
|
|
491
|
+
'target_type': target_type,
|
|
492
|
+
},
|
|
493
|
+
)]
|
|
494
|
+
return []
|
|
495
|
+
|
|
496
|
+
def simulate_what_if(
|
|
497
|
+
self,
|
|
498
|
+
overrides: Dict[str, Dict[str, Any]],
|
|
499
|
+
horizon_s: float = 3600.0,
|
|
500
|
+
) -> TraversalResult:
|
|
501
|
+
"""Perturb nodes and forward-propagate."""
|
|
502
|
+
return self.traverse(TraversalRequest(
|
|
503
|
+
start_nodes=list(overrides.keys()),
|
|
504
|
+
direction=TraversalDirection.FORWARD,
|
|
505
|
+
value_mode=ValueMode.HYPOTHETICAL,
|
|
506
|
+
overrides=overrides,
|
|
507
|
+
max_hops=4,
|
|
508
|
+
min_probability=0.05,
|
|
509
|
+
))
|
|
510
|
+
|
|
511
|
+
def discover_gaps(self, start_node: str) -> List[TopologyQuestion]:
|
|
512
|
+
"""Traverse until blocked, collect all gaps as questions."""
|
|
513
|
+
result = self.traverse(TraversalRequest(
|
|
514
|
+
start_nodes=[start_node],
|
|
515
|
+
direction=TraversalDirection.FORWARD,
|
|
516
|
+
value_mode=ValueMode.CURRENT,
|
|
517
|
+
stop_on_gap=False,
|
|
518
|
+
collect_gaps=True,
|
|
519
|
+
collect_axiom_violations=False,
|
|
520
|
+
max_hops=4,
|
|
521
|
+
))
|
|
522
|
+
return sorted(
|
|
523
|
+
result.questions_generated,
|
|
524
|
+
key=lambda q: q.priority,
|
|
525
|
+
reverse=True,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
def project_values(self, horizon_s: float = 3600.0,
|
|
529
|
+
window: Optional[timedelta] = None) -> int:
|
|
530
|
+
"""Populate ``TwinNode.projected_values`` from observed history.
|
|
531
|
+
|
|
532
|
+
This is the producer PREDICT mode never had. ``_get_values``
|
|
533
|
+
has always overlaid ``node.projected_values`` under
|
|
534
|
+
``ValueMode.PROJECTED``, but **nothing constructed a
|
|
535
|
+
``ProjectedValue``** anywhere outside a test — the source said so
|
|
536
|
+
itself at ``arbiter_engine/residual/predict_vs_mirror.py``. So PROJECTED
|
|
537
|
+
silently collapsed to CURRENT, and ``predict_all`` below traversed
|
|
538
|
+
with present values while reporting future violations.
|
|
539
|
+
|
|
540
|
+
It also explains two of the three dead constructor parameters
|
|
541
|
+
flagged: ``trend_projector`` and ``history`` were assigned and never
|
|
542
|
+
read *because* this step was missing. They are read here now, which is
|
|
543
|
+
what they were accepted for.
|
|
544
|
+
|
|
545
|
+
Returns the number of (node, property) projections written, so a
|
|
546
|
+
caller can tell "projected nothing" from "projected and found
|
|
547
|
+
nothing" — the same distinction drew for the checkers.
|
|
548
|
+
"""
|
|
549
|
+
if self.history is None:
|
|
550
|
+
return 0
|
|
551
|
+
projector = self.trend_projector or TrendProjection()
|
|
552
|
+
lookback = window or timedelta(hours=24)
|
|
553
|
+
written = 0
|
|
554
|
+
|
|
555
|
+
for node in self.topology.nodes.values():
|
|
556
|
+
entity = node.entity
|
|
557
|
+
for prop_name, current in entity.properties.items():
|
|
558
|
+
if not isinstance(current, (int, float)) or isinstance(current, bool):
|
|
559
|
+
continue
|
|
560
|
+
try:
|
|
561
|
+
values = self.history.get_values(
|
|
562
|
+
entity.id, prop_name, lookback)
|
|
563
|
+
except Exception: # noqa: BLE001 — a bad history must not
|
|
564
|
+
continue # take down a projection pass
|
|
565
|
+
if len(values) < 3:
|
|
566
|
+
# Below any sensible fit; leaving the property unprojected
|
|
567
|
+
# means PROJECTED falls back to its current value for it,
|
|
568
|
+
# which is honest — a projection was not made.
|
|
569
|
+
continue
|
|
570
|
+
try:
|
|
571
|
+
trend = projector.project(values, horizon_s)
|
|
572
|
+
except Exception: # noqa: BLE001
|
|
573
|
+
trend = None
|
|
574
|
+
if trend is None:
|
|
575
|
+
continue
|
|
576
|
+
node.projected_values[prop_name] = ProjectedValue(
|
|
577
|
+
value=float(trend.predicted_value),
|
|
578
|
+
confidence=float(getattr(trend, "r_squared", 0.0) or 0.0),
|
|
579
|
+
horizon_s=float(horizon_s),
|
|
580
|
+
model=getattr(getattr(trend, "model", None), "value",
|
|
581
|
+
str(getattr(trend, "model", ""))),
|
|
582
|
+
)
|
|
583
|
+
written += 1
|
|
584
|
+
return written
|
|
585
|
+
|
|
586
|
+
def predict_all(self, horizon_s: float = 3600.0) -> List[Problem]:
|
|
587
|
+
"""Project all node trends, traverse forward, find future violations.
|
|
588
|
+
|
|
589
|
+
the projection step is now performed. Before this, the method
|
|
590
|
+
traversed in PROJECTED mode over nodes whose ``projected_values`` were
|
|
591
|
+
always empty, so it returned present-tense findings under a
|
|
592
|
+
future-tense name.
|
|
593
|
+
"""
|
|
594
|
+
self.project_values(horizon_s=horizon_s)
|
|
595
|
+
problems: List[Problem] = []
|
|
596
|
+
for node_id in self.topology.nodes:
|
|
597
|
+
result = self.traverse(TraversalRequest(
|
|
598
|
+
start_nodes=[node_id],
|
|
599
|
+
direction=TraversalDirection.FORWARD,
|
|
600
|
+
value_mode=ValueMode.PROJECTED,
|
|
601
|
+
max_hops=2,
|
|
602
|
+
min_probability=0.1,
|
|
603
|
+
collect_axiom_violations=True,
|
|
604
|
+
collect_gaps=False,
|
|
605
|
+
))
|
|
606
|
+
problems.extend(result.problems_detected)
|
|
607
|
+
return problems
|
|
608
|
+
|
|
609
|
+
# ------------------------------------------------------------------
|
|
610
|
+
# Private helpers
|
|
611
|
+
# ------------------------------------------------------------------
|
|
612
|
+
|
|
613
|
+
def _get_values(
|
|
614
|
+
self, node: TwinNode, request: TraversalRequest,
|
|
615
|
+
) -> Dict[str, Any]:
|
|
616
|
+
"""Get property values based on value_mode."""
|
|
617
|
+
if request.value_mode == ValueMode.CURRENT:
|
|
618
|
+
return dict(node.entity.properties)
|
|
619
|
+
elif request.value_mode == ValueMode.PROJECTED:
|
|
620
|
+
values = dict(node.entity.properties)
|
|
621
|
+
for prop_name, pv in node.projected_values.items():
|
|
622
|
+
values[prop_name] = pv.value
|
|
623
|
+
return values
|
|
624
|
+
elif request.value_mode == ValueMode.HYPOTHETICAL:
|
|
625
|
+
values = dict(node.entity.properties)
|
|
626
|
+
overrides = request.overrides.get(node.entity.id, {})
|
|
627
|
+
values.update(overrides)
|
|
628
|
+
return values
|
|
629
|
+
return dict(node.entity.properties)
|
|
630
|
+
|
|
631
|
+
def _evaluate_axioms(
|
|
632
|
+
self, node: TwinNode, values: Dict[str, Any],
|
|
633
|
+
) -> Tuple[List[Problem], int]:
|
|
634
|
+
"""Evaluate axiom states against current/projected values.
|
|
635
|
+
|
|
636
|
+
Returns the problems found AND the number of evaluations attempted.
|
|
637
|
+
the count is returned rather than reconstructed by the caller,
|
|
638
|
+
because the conditions under which an evaluation happens live in this
|
|
639
|
+
body — a state is skipped when its property is absent from the values,
|
|
640
|
+
when the value is not numeric, and when the axiom is not BOUNDEDNESS.
|
|
641
|
+
Any caller counting `axiom_states` instead is counting declarations the
|
|
642
|
+
builder seeded, which is how a walk that evaluated one invariant came
|
|
643
|
+
to report four.
|
|
644
|
+
|
|
645
|
+
**BOUNDEDNESS only.** The line this docstring used to carry — "other
|
|
646
|
+
axioms delegate to registered checkers if available" — described an
|
|
647
|
+
intention, not the code: the body has only ever handled BOUNDEDNESS,
|
|
648
|
+
and the `axiom_checkers` constructor argument it referred to was
|
|
649
|
+
stored and never read. An internal ruling removed the argument and corrected this
|
|
650
|
+
sentence rather than leaving a docstring promising dispatch that does
|
|
651
|
+
not happen.
|
|
652
|
+
|
|
653
|
+
Callers wanting the full axiom set want `UnifiedAxiomReasoner`, which
|
|
654
|
+
owns the dispatch table. Note that traversal separately performs a
|
|
655
|
+
structural CONSERVATION check (`_check_flow_balance`) that consults no
|
|
656
|
+
declaration at all —.
|
|
657
|
+
"""
|
|
658
|
+
problems: List[Problem] = []
|
|
659
|
+
attempted = 0
|
|
660
|
+
for key, axiom_state in node.axiom_states.items():
|
|
661
|
+
prop_name = axiom_state.indicator_name
|
|
662
|
+
if not prop_name or prop_name not in values:
|
|
663
|
+
continue
|
|
664
|
+
value = values.get(prop_name)
|
|
665
|
+
if value is None or not isinstance(value, (int, float)):
|
|
666
|
+
continue
|
|
667
|
+
|
|
668
|
+
# Check BOUNDEDNESS thresholds from evidence
|
|
669
|
+
if axiom_state.axiom == Axiom.BOUNDEDNESS:
|
|
670
|
+
# Counted HERE and not at the top of the loop: the two
|
|
671
|
+
# `continue`s above skip states that were never evaluated, and
|
|
672
|
+
# a non-BOUNDEDNESS state reaching this line is not evaluated
|
|
673
|
+
# either. The denominator has to mean attempted, so it is
|
|
674
|
+
# incremented at the point an attempt actually begins.
|
|
675
|
+
attempted += 1
|
|
676
|
+
warning = axiom_state.evidence.get('warning')
|
|
677
|
+
critical = axiom_state.evidence.get('critical')
|
|
678
|
+
if critical is not None and value > critical:
|
|
679
|
+
problems.append(Problem.from_entity(
|
|
680
|
+
entity=node.entity,
|
|
681
|
+
problem_type=f'twin_boundedness:{prop_name}',
|
|
682
|
+
severity=Severity.CRITICAL,
|
|
683
|
+
reason=(
|
|
684
|
+
f"{prop_name}={value} exceeds "
|
|
685
|
+
f"critical={critical}"
|
|
686
|
+
),
|
|
687
|
+
axiom=Axiom.BOUNDEDNESS,
|
|
688
|
+
source_layer=DetectionLayer.ONTOLOGY,
|
|
689
|
+
evidence={
|
|
690
|
+
'property': prop_name,
|
|
691
|
+
'value': value,
|
|
692
|
+
'critical': critical,
|
|
693
|
+
},
|
|
694
|
+
))
|
|
695
|
+
elif warning is not None and value > warning:
|
|
696
|
+
problems.append(Problem.from_entity(
|
|
697
|
+
entity=node.entity,
|
|
698
|
+
problem_type=f'twin_boundedness:{prop_name}',
|
|
699
|
+
severity=Severity.WARNING,
|
|
700
|
+
reason=(
|
|
701
|
+
f"{prop_name}={value} exceeds "
|
|
702
|
+
f"warning={warning}"
|
|
703
|
+
),
|
|
704
|
+
axiom=Axiom.BOUNDEDNESS,
|
|
705
|
+
source_layer=DetectionLayer.ONTOLOGY,
|
|
706
|
+
evidence={
|
|
707
|
+
'property': prop_name,
|
|
708
|
+
'value': value,
|
|
709
|
+
'warning': warning,
|
|
710
|
+
},
|
|
711
|
+
))
|
|
712
|
+
return problems, attempted
|
|
713
|
+
|
|
714
|
+
def _compute_priority(
|
|
715
|
+
self, gap: TopologyGap, hop: int, probability: float,
|
|
716
|
+
) -> float:
|
|
717
|
+
"""Higher = more blocking."""
|
|
718
|
+
hop_factor = 1.0 / (1 + hop)
|
|
719
|
+
type_weight = {
|
|
720
|
+
GapType.MISSING_NODE: 1.0,
|
|
721
|
+
GapType.MISSING_EDGE: 0.8,
|
|
722
|
+
GapType.MISSING_PROPERTY: 0.6,
|
|
723
|
+
GapType.MISSING_THRESHOLD: 0.4,
|
|
724
|
+
GapType.MISSING_DYNAMICS: 0.2,
|
|
725
|
+
}
|
|
726
|
+
return hop_factor * probability * type_weight.get(gap.gap_type, 0.5)
|
|
727
|
+
|
|
728
|
+
def _check_flow_balance(self, cycle_path: List[str]) -> List[Problem]:
|
|
729
|
+
"""Verify conservation around a flow cycle."""
|
|
730
|
+
problems: List[Problem] = []
|
|
731
|
+
for node_id in cycle_path[:-1]:
|
|
732
|
+
node = self.topology.get_node(node_id)
|
|
733
|
+
if not node:
|
|
734
|
+
continue
|
|
735
|
+
props = node.entity.properties
|
|
736
|
+
# token-matched, not substring-matched. bool is a
|
|
737
|
+
# subclass of int, so it is excluded explicitly: a flag named
|
|
738
|
+
# engage_human_in_loop would otherwise contribute 1 to inflow.
|
|
739
|
+
flow_in = sum(
|
|
740
|
+
v for k, v in props.items()
|
|
741
|
+
if classify_flow_direction(k) == 'in'
|
|
742
|
+
and isinstance(v, (int, float))
|
|
743
|
+
and not isinstance(v, bool)
|
|
744
|
+
)
|
|
745
|
+
flow_out = sum(
|
|
746
|
+
v for k, v in props.items()
|
|
747
|
+
if classify_flow_direction(k) == 'out'
|
|
748
|
+
and isinstance(v, (int, float))
|
|
749
|
+
and not isinstance(v, bool)
|
|
750
|
+
)
|
|
751
|
+
if flow_in > 0:
|
|
752
|
+
deficit_ratio = abs(flow_in - flow_out) / flow_in
|
|
753
|
+
# resolve per-entity overrides for the
|
|
754
|
+
# CONSERVATION flow-balance residual; fall back to the global
|
|
755
|
+
# AxiomParameters defaults (0.05 warn / 0.20 high).
|
|
756
|
+
warn_threshold = resolve_axiom_threshold(
|
|
757
|
+
node.entity, "flow_balance", "CONSERVATION",
|
|
758
|
+
fallback=self.axiom_params.conservation_flow_deficit_warn,
|
|
759
|
+
bound="warn",
|
|
760
|
+
)
|
|
761
|
+
high_threshold = resolve_axiom_threshold(
|
|
762
|
+
node.entity, "flow_balance", "CONSERVATION",
|
|
763
|
+
fallback=self.axiom_params.conservation_flow_deficit_high,
|
|
764
|
+
bound="critical",
|
|
765
|
+
)
|
|
766
|
+
if deficit_ratio > warn_threshold:
|
|
767
|
+
severity = (
|
|
768
|
+
Severity.HIGH if deficit_ratio > high_threshold
|
|
769
|
+
else Severity.MEDIUM
|
|
770
|
+
)
|
|
771
|
+
problems.append(Problem.from_entity(
|
|
772
|
+
entity=node.entity,
|
|
773
|
+
problem_type='conservation_violation',
|
|
774
|
+
severity=severity,
|
|
775
|
+
reason=(
|
|
776
|
+
f"Flow imbalance: in={flow_in:.1f}, "
|
|
777
|
+
f"out={flow_out:.1f}, "
|
|
778
|
+
f"deficit={deficit_ratio:.1%}"
|
|
779
|
+
),
|
|
780
|
+
axiom=Axiom.CONSERVATION,
|
|
781
|
+
source_layer=DetectionLayer.ONTOLOGY,
|
|
782
|
+
evidence={
|
|
783
|
+
'flow_in': flow_in,
|
|
784
|
+
'flow_out': flow_out,
|
|
785
|
+
'deficit_ratio': deficit_ratio,
|
|
786
|
+
'cycle_path': cycle_path,
|
|
787
|
+
},
|
|
788
|
+
))
|
|
789
|
+
return problems
|
|
790
|
+
|
|
791
|
+
@staticmethod
|
|
792
|
+
def _decay_severity(base: Severity, hops: int) -> Severity:
|
|
793
|
+
current = base
|
|
794
|
+
for _ in range(hops):
|
|
795
|
+
current = _SEVERITY_DECAY.get(current, Severity.INFO)
|
|
796
|
+
return current
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
# ---------------------------------------------------------------------------
|
|
800
|
+
# NL -> TraversalRequest translation
|
|
801
|
+
# ---------------------------------------------------------------------------
|
|
802
|
+
|
|
803
|
+
class NLTraversalTranslator:
|
|
804
|
+
"""Thin NL -> TraversalRequest translation layer.
|
|
805
|
+
|
|
806
|
+
Rule-based 7-pattern primary path is the only implementation today.
|
|
807
|
+
Maps common natural-language question patterns to TraversalRequest
|
|
808
|
+
configurations via PATTERNS class-attribute keyword sets; unmapped
|
|
809
|
+
questions return None (hard-fail at the caller's discretion).
|
|
810
|
+
|
|
811
|
+
LLM-fallback architecture decided 2026-05-25
|
|
812
|
+
(add-LLM-fallback option chosen; see decision doc
|
|
813
|
+
the internal notes). Wiring follow-ups
|
|
814
|
+
pending: translate() llm_fallback kwarg + shared.llm.get_llm_client()
|
|
815
|
+
integration + audit-faithfulness gate + LLM-fallback pin tests.
|
|
816
|
+
Until those follow-up CDs land, this class is rule-based only; LLM
|
|
817
|
+
delegation is NOT yet active.
|
|
818
|
+
|
|
819
|
+
Substrate-discovery surface for partners: GET /nl-query the established pattern
|
|
820
|
+
endpoint enumerates the 7 keyword-pattern set + 3-direction +
|
|
821
|
+
3-value-mode vocabularies + decision-doc cross-link.
|
|
822
|
+
"""
|
|
823
|
+
|
|
824
|
+
PATTERNS = [
|
|
825
|
+
({"root cause", "why", "caused"},
|
|
826
|
+
TraversalDirection.REVERSE, ValueMode.CURRENT, {}),
|
|
827
|
+
({"impact", "affect", "downstream"},
|
|
828
|
+
TraversalDirection.FORWARD, ValueMode.CURRENT, {}),
|
|
829
|
+
({"predict", "forecast", "will", "future"},
|
|
830
|
+
TraversalDirection.FORWARD, ValueMode.PROJECTED, {}),
|
|
831
|
+
({"what if", "simulate", "hypothetical"},
|
|
832
|
+
TraversalDirection.FORWARD, ValueMode.HYPOTHETICAL, {}),
|
|
833
|
+
({"conservation", "balance", "flow"},
|
|
834
|
+
TraversalDirection.BIDIRECTIONAL, ValueMode.CURRENT,
|
|
835
|
+
{"edge_filter": {EdgeDirection.FLOW}}),
|
|
836
|
+
({"gap", "missing", "unknown"},
|
|
837
|
+
TraversalDirection.FORWARD, ValueMode.CURRENT,
|
|
838
|
+
{"stop_on_gap": False, "collect_gaps": True}),
|
|
839
|
+
({"connected", "connectivity", "reachable"},
|
|
840
|
+
TraversalDirection.FORWARD, ValueMode.CURRENT, {}),
|
|
841
|
+
]
|
|
842
|
+
|
|
843
|
+
def translate(
|
|
844
|
+
self,
|
|
845
|
+
question: str,
|
|
846
|
+
entity_ids: Optional[List[str]] = None,
|
|
847
|
+
) -> Optional[TraversalRequest]:
|
|
848
|
+
"""Translate a natural-language question to a TraversalRequest.
|
|
849
|
+
|
|
850
|
+
Returns None if the question cannot be mapped.
|
|
851
|
+
"""
|
|
852
|
+
question_lower = question.lower()
|
|
853
|
+
for keywords, direction, value_mode, extra in self.PATTERNS:
|
|
854
|
+
if any(kw in question_lower for kw in keywords):
|
|
855
|
+
config: Dict[str, Any] = {
|
|
856
|
+
'start_nodes': entity_ids or [],
|
|
857
|
+
'direction': direction,
|
|
858
|
+
'value_mode': value_mode,
|
|
859
|
+
'max_hops': 4,
|
|
860
|
+
'min_probability': 0.05,
|
|
861
|
+
}
|
|
862
|
+
config.update(extra)
|
|
863
|
+
return TraversalRequest(**config)
|
|
864
|
+
return None
|
|
865
|
+
|
|
866
|
+
async def translate_with_llm_fallback(
|
|
867
|
+
self,
|
|
868
|
+
question: str,
|
|
869
|
+
entity_ids: Optional[List[str]] = None,
|
|
870
|
+
*,
|
|
871
|
+
audit_entity_ids: Optional[List[str]] = None,
|
|
872
|
+
) -> Optional[TraversalRequest]:
|
|
873
|
+
""" (follow-up #1/#2/#3 chain head): async wrapper around
|
|
874
|
+
sync translate() that adds LLM-fallback path for unmapped questions.
|
|
875
|
+
|
|
876
|
+
Flow decision:
|
|
877
|
+
1. Try sync rule-based translate() (current 7-pattern path).
|
|
878
|
+
2. If matched, return TraversalRequest (existing behavior).
|
|
879
|
+
3. If unmapped + NL_LLM_FALLBACK_ENABLED env true + an LLM client
|
|
880
|
+
is available → call LLM with structured prompt naming the 3
|
|
881
|
+
direction enums + 3 value-mode enums + JSON output shape.
|
|
882
|
+
4. Apply 3-heuristic audit-faithfulness check (adapted from
|
|
883
|
+
the full system shape): substring + capitalized-token +
|
|
884
|
+
coverage applied to the supplied entity_ids list.
|
|
885
|
+
5. Return TraversalRequest on audit-pass OR None on audit-fail.
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
question: Natural-language question.
|
|
889
|
+
entity_ids: Start nodes for the traversal (also the audit baseline).
|
|
890
|
+
audit_entity_ids: Optional override for the audit-baseline list
|
|
891
|
+
(defaults to entity_ids). Lets callers audit against a
|
|
892
|
+
broader set than the start nodes.
|
|
893
|
+
|
|
894
|
+
Returns None when (a) rule-based unmapped + env-gate off, OR
|
|
895
|
+
(b) rule-based unmapped + LLM call fails, OR (c) audit-faithfulness
|
|
896
|
+
gate fails on LLM output. an established pattern env-gate.
|
|
897
|
+
"""
|
|
898
|
+
rule_based = self.translate(question, entity_ids=entity_ids)
|
|
899
|
+
if rule_based is not None:
|
|
900
|
+
return rule_based
|
|
901
|
+
|
|
902
|
+
import os
|
|
903
|
+
if os.environ.get("NL_LLM_FALLBACK_ENABLED", "false").lower() != "true":
|
|
904
|
+
return None
|
|
905
|
+
|
|
906
|
+
try:
|
|
907
|
+
llm_output = await self._call_llm_for_traversal(question, entity_ids)
|
|
908
|
+
except Exception: # noqa: BLE001 — defensive LLM-call wrapper
|
|
909
|
+
return None
|
|
910
|
+
if llm_output is None:
|
|
911
|
+
return None
|
|
912
|
+
|
|
913
|
+
audit_pool = audit_entity_ids if audit_entity_ids is not None else (entity_ids or [])
|
|
914
|
+
if not self._audit_llm_traversal_output(llm_output, audit_pool):
|
|
915
|
+
return None
|
|
916
|
+
|
|
917
|
+
return self._parse_llm_json_to_traversal_request(llm_output, entity_ids)
|
|
918
|
+
|
|
919
|
+
async def _call_llm_for_traversal(
|
|
920
|
+
self,
|
|
921
|
+
question: str,
|
|
922
|
+
entity_ids: Optional[List[str]],
|
|
923
|
+
) -> Optional[Dict[str, Any]]:
|
|
924
|
+
"""lazy-imports shared.llm.get_llm_client() + structured prompt.
|
|
925
|
+
|
|
926
|
+
Tests mock this method directly (no real LLM call needed). Returns
|
|
927
|
+
a JSON-decoded dict with keys 'direction' + 'value_mode' +
|
|
928
|
+
'start_nodes' OR None on LLM failure.
|
|
929
|
+
"""
|
|
930
|
+
try:
|
|
931
|
+
# -- the root package is DERIVED, not written. This used to
|
|
932
|
+
# name the originating distribution literally, and the extraction
|
|
933
|
+
# deliberately leaves such a statement alone so the import fails in
|
|
934
|
+
# the standalone engine. It did fail, correctly -- and shipped that
|
|
935
|
+
# name into a public tree to do it, as the one string in the whole
|
|
936
|
+
# artifact identifying where it came from.
|
|
937
|
+
#
|
|
938
|
+
# Deriving the root keeps both behaviours and neither cost: in the
|
|
939
|
+
# full tree it resolves to the real client, and in the engine it
|
|
940
|
+
# resolves to a sibling that was never extracted and raises below.
|
|
941
|
+
import importlib
|
|
942
|
+
_root = (__package__ or __name__).split(".")[0]
|
|
943
|
+
get_llm_client = importlib.import_module(
|
|
944
|
+
f"{_root}.shared.llm").get_llm_client
|
|
945
|
+
except (ImportError, AttributeError) as exc:
|
|
946
|
+
# item 5. Fails loudly rather than degrading, unlike the
|
|
947
|
+
# sibling guard at propagation/root_cause.py:45 -- the caller asked
|
|
948
|
+
# for a translation and there is no honest way to return one.
|
|
949
|
+
raise RuntimeError(
|
|
950
|
+
"natural-language traversal needs an LLM client, and none is "
|
|
951
|
+
"available in this distribution. The deterministic translate() "
|
|
952
|
+
"path needs no client and is unaffected. In the standalone "
|
|
953
|
+
"engine the client is deliberately not shipped: it is "
|
|
954
|
+
"operations, and NLTraversalTranslator is a deep path that is "
|
|
955
|
+
"importable but explicitly unsupported."
|
|
956
|
+
) from exc
|
|
957
|
+
|
|
958
|
+
client = get_llm_client()
|
|
959
|
+
directions = [d.value for d in TraversalDirection]
|
|
960
|
+
value_modes = [v.value for v in ValueMode]
|
|
961
|
+
system_prompt = (
|
|
962
|
+
"You compile natural-language questions about Digital Twin "
|
|
963
|
+
"topology into TraversalRequest JSON. Output ONLY valid JSON "
|
|
964
|
+
"with keys: direction (one of " + ", ".join(directions) + "), "
|
|
965
|
+
"value_mode (one of " + ", ".join(value_modes) + "), "
|
|
966
|
+
"start_nodes (list of entity ID strings, MUST be a subset of "
|
|
967
|
+
"the supplied entity_ids). NO prose."
|
|
968
|
+
)
|
|
969
|
+
user_prompt = (
|
|
970
|
+
"question: " + question + "\n"
|
|
971
|
+
"entity_ids: " + str(entity_ids or [])
|
|
972
|
+
)
|
|
973
|
+
raw = await client.complete(system=system_prompt, user=user_prompt)
|
|
974
|
+
import json as _json
|
|
975
|
+
try:
|
|
976
|
+
return _json.loads(raw)
|
|
977
|
+
except (ValueError, TypeError):
|
|
978
|
+
return None
|
|
979
|
+
|
|
980
|
+
@staticmethod
|
|
981
|
+
def _audit_llm_traversal_output(
|
|
982
|
+
llm_output: Dict[str, Any],
|
|
983
|
+
audit_entity_ids: List[str],
|
|
984
|
+
) -> bool:
|
|
985
|
+
""" (follow-up #3): 3-heuristic audit-faithfulness check
|
|
986
|
+
adapted from the full system shape, applied to LLM-emitted
|
|
987
|
+
traversal config.
|
|
988
|
+
|
|
989
|
+
Heuristics (all 3 must pass):
|
|
990
|
+
(1) Substring: every entity_id in llm_output['start_nodes'] must
|
|
991
|
+
appear as a substring of some entry in audit_entity_ids.
|
|
992
|
+
(2) Capitalized-token: capitalized tokens in start_nodes must
|
|
993
|
+
appear in audit_entity_ids (no novel capitalized identifiers).
|
|
994
|
+
(3) Coverage: at least one audit_entity_ids entry must appear in
|
|
995
|
+
start_nodes when audit_entity_ids is non-empty.
|
|
996
|
+
|
|
997
|
+
Returns True iff all 3 pass.
|
|
998
|
+
"""
|
|
999
|
+
start_nodes = llm_output.get("start_nodes")
|
|
1000
|
+
if not isinstance(start_nodes, list):
|
|
1001
|
+
return False
|
|
1002
|
+
|
|
1003
|
+
# (1) Substring check.
|
|
1004
|
+
audit_blob = " ".join(audit_entity_ids)
|
|
1005
|
+
for node in start_nodes:
|
|
1006
|
+
if not isinstance(node, str):
|
|
1007
|
+
return False
|
|
1008
|
+
if audit_entity_ids and node not in audit_blob:
|
|
1009
|
+
return False
|
|
1010
|
+
|
|
1011
|
+
# (2) Capitalized-token check.
|
|
1012
|
+
import re as _re
|
|
1013
|
+
cap_pattern = _re.compile(r"\b[A-Z][A-Za-z0-9]*\b")
|
|
1014
|
+
audit_caps = set()
|
|
1015
|
+
for eid in audit_entity_ids:
|
|
1016
|
+
audit_caps.update(cap_pattern.findall(eid))
|
|
1017
|
+
for node in start_nodes:
|
|
1018
|
+
for tok in cap_pattern.findall(node):
|
|
1019
|
+
if tok not in audit_caps:
|
|
1020
|
+
return False
|
|
1021
|
+
|
|
1022
|
+
# (3) Coverage check (only when audit_entity_ids is non-empty).
|
|
1023
|
+
if audit_entity_ids and not any(eid in start_nodes for eid in audit_entity_ids):
|
|
1024
|
+
return False
|
|
1025
|
+
|
|
1026
|
+
return True
|
|
1027
|
+
|
|
1028
|
+
@staticmethod
|
|
1029
|
+
def _parse_llm_json_to_traversal_request(
|
|
1030
|
+
llm_output: Dict[str, Any],
|
|
1031
|
+
entity_ids: Optional[List[str]],
|
|
1032
|
+
) -> Optional[TraversalRequest]:
|
|
1033
|
+
"""parse audit-passed LLM JSON to TraversalRequest. Returns
|
|
1034
|
+
None if any required field is missing or has invalid value.
|
|
1035
|
+
"""
|
|
1036
|
+
direction_str = llm_output.get("direction")
|
|
1037
|
+
value_mode_str = llm_output.get("value_mode")
|
|
1038
|
+
start_nodes = llm_output.get("start_nodes")
|
|
1039
|
+
|
|
1040
|
+
try:
|
|
1041
|
+
direction = TraversalDirection(direction_str)
|
|
1042
|
+
value_mode = ValueMode(value_mode_str)
|
|
1043
|
+
except (ValueError, TypeError):
|
|
1044
|
+
return None
|
|
1045
|
+
|
|
1046
|
+
if not isinstance(start_nodes, list):
|
|
1047
|
+
return None
|
|
1048
|
+
|
|
1049
|
+
try:
|
|
1050
|
+
return TraversalRequest(
|
|
1051
|
+
start_nodes=start_nodes if start_nodes else (entity_ids or []),
|
|
1052
|
+
direction=direction,
|
|
1053
|
+
value_mode=value_mode,
|
|
1054
|
+
max_hops=4,
|
|
1055
|
+
min_probability=0.05,
|
|
1056
|
+
)
|
|
1057
|
+
except (TypeError, ValueError):
|
|
1058
|
+
return None
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
# ---------------------------------------------------------------------------
|
|
1062
|
+
#: 3-tier escalation decision
|
|
1063
|
+
# ---------------------------------------------------------------------------
|
|
1064
|
+
|
|
1065
|
+
# the established pattern canonical-invariant (promotion).
|
|
1066
|
+
# Tier 1 = rule-based or LLM-tool-use auto-translate.
|
|
1067
|
+
# Tier 2 = LLM-ambiguity-resolution (present 2-3 candidate TraversalRequests).
|
|
1068
|
+
# Tier 3 = operator-confirmation (cross-tenant OR HIGH+ severity OR overrides).
|
|
1069
|
+
|
|
1070
|
+
TRAVERSE_TOPOLOGY_TOOL_DEF: Dict[str, Any] = {
|
|
1071
|
+
"name": "traverse_topology",
|
|
1072
|
+
"description": (
|
|
1073
|
+
"Configure a Digital Twin TraversalRequest to answer the user's "
|
|
1074
|
+
"question. Returns TraversalResult with steps + problems + gaps + "
|
|
1075
|
+
"questions. Use FORWARD for impact/downstream, REVERSE for "
|
|
1076
|
+
"root-cause, BIDIRECTIONAL for conservation/cycle checks. Use "
|
|
1077
|
+
"CURRENT for what-is-now, PROJECTED for future-state, HYPOTHETICAL "
|
|
1078
|
+
"with overrides for what-if simulation."
|
|
1079
|
+
),
|
|
1080
|
+
"input_schema": {
|
|
1081
|
+
"type": "object",
|
|
1082
|
+
"properties": {
|
|
1083
|
+
"start_nodes": {
|
|
1084
|
+
"type": "array",
|
|
1085
|
+
"items": {"type": "string"},
|
|
1086
|
+
"description": "Entity IDs to start traversal from. Must be a subset of supplied entity_ids context.",
|
|
1087
|
+
},
|
|
1088
|
+
"direction": {
|
|
1089
|
+
"type": "string",
|
|
1090
|
+
"enum": ["forward", "reverse", "bidirectional"],
|
|
1091
|
+
"description": "Traversal direction.",
|
|
1092
|
+
},
|
|
1093
|
+
"value_mode": {
|
|
1094
|
+
"type": "string",
|
|
1095
|
+
"enum": ["current", "projected", "hypothetical"],
|
|
1096
|
+
"description": "Property value source.",
|
|
1097
|
+
},
|
|
1098
|
+
"edge_filter": {
|
|
1099
|
+
"type": "array",
|
|
1100
|
+
"items": {"type": "string"},
|
|
1101
|
+
"description": "Optional edge-direction filter (causal/flow/structural/temporal).",
|
|
1102
|
+
},
|
|
1103
|
+
"max_hops": {"type": "integer", "default": 4},
|
|
1104
|
+
"horizon_s": {"type": "number", "default": 3600.0},
|
|
1105
|
+
"overrides": {
|
|
1106
|
+
"type": "object",
|
|
1107
|
+
"description": "Per-node property overrides for HYPOTHETICAL mode.",
|
|
1108
|
+
},
|
|
1109
|
+
},
|
|
1110
|
+
"required": ["start_nodes", "direction", "value_mode"],
|
|
1111
|
+
},
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
@dataclass
|
|
1116
|
+
class NLTranslationResult:
|
|
1117
|
+
"""discriminated 3-tier outcome of NL → TraversalRequest.
|
|
1118
|
+
|
|
1119
|
+
Exactly one of (tier1_traversal_request, tier2_candidates,
|
|
1120
|
+
tier3_pending_confirmation) is non-None. Caller dispatches on
|
|
1121
|
+
`tier` field (1/2/3) for routing logic.
|
|
1122
|
+
|
|
1123
|
+
Per Decision Tier-3 fires when cross-tenant boundary
|
|
1124
|
+
detected OR HIGH+/CRITICAL severity-floor OR override-fields present.
|
|
1125
|
+
"""
|
|
1126
|
+
|
|
1127
|
+
tier: int
|
|
1128
|
+
nl_text: str
|
|
1129
|
+
tier1_traversal_request: Optional[TraversalRequest] = None
|
|
1130
|
+
tier2_candidates: List[TraversalRequest] = field(default_factory=list)
|
|
1131
|
+
tier3_pending_confirmation: Optional[TraversalRequest] = None
|
|
1132
|
+
tier3_reason: Optional[str] = None # "cross_tenant" / "high_severity" / "overrides_present"
|
|
1133
|
+
tier2_pick_hint: Optional[str] = None
|
|
1134
|
+
tier3_summary: Optional[str] = None
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def classify_escalation_tier_per_cd1280(
|
|
1138
|
+
traversal_request: TraversalRequest,
|
|
1139
|
+
tenant_id: Optional[str] = None,
|
|
1140
|
+
severity_floor: str = "MEDIUM",
|
|
1141
|
+
cross_tenant_start_nodes: Optional[List[str]] = None,
|
|
1142
|
+
) -> tuple:
|
|
1143
|
+
"""classify which tier a candidate TraversalRequest triggers.
|
|
1144
|
+
|
|
1145
|
+
Returns (tier, reason) where tier is 1/2/3 and reason is None or one of:
|
|
1146
|
+
- "cross_tenant" (start_nodes span multiple tenants —
|
|
1147
|
+
tenant_context_token boundary detection)
|
|
1148
|
+
- "high_severity" (severity_floor >= HIGH)
|
|
1149
|
+
- "overrides_present" (HYPOTHETICAL mode + non-empty overrides)
|
|
1150
|
+
"""
|
|
1151
|
+
# Tier 3 conditions (highest priority)
|
|
1152
|
+
if cross_tenant_start_nodes and len(set(cross_tenant_start_nodes)) > 1:
|
|
1153
|
+
return (3, "cross_tenant")
|
|
1154
|
+
if severity_floor.upper() in ("HIGH", "CRITICAL"):
|
|
1155
|
+
return (3, "high_severity")
|
|
1156
|
+
if (
|
|
1157
|
+
traversal_request.value_mode == ValueMode.HYPOTHETICAL
|
|
1158
|
+
and traversal_request.overrides
|
|
1159
|
+
):
|
|
1160
|
+
return (3, "overrides_present")
|
|
1161
|
+
return (1, None)
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
class NLTraversalTranslator3Tier:
|
|
1165
|
+
"""3-tier escalation wrapper for NLTraversalTranslator
|
|
1166
|
+
Decision. Companion class to existing NLTraversalTranslator
|
|
1167
|
+
rule-based + LLM-fallback shape; this class adds tier-classification +
|
|
1168
|
+
candidate presentation + operator-confirmation discriminated outcomes.
|
|
1169
|
+
|
|
1170
|
+
the established pattern canonical-invariant (rule/template + LLM-fallback
|
|
1171
|
+
+ human escape-hatch family — HTN/STRIPS/LLM + LLMClient
|
|
1172
|
+
fallback chain + NarrationInterface audit gate +
|
|
1173
|
+
LLMCounterfactual + this 5th).
|
|
1174
|
+
|
|
1175
|
+
Per Lever 4: "NLTraversalTranslator is the
|
|
1176
|
+
AI Agent's core skill — operator NL → TraversalRequest → result → NL
|
|
1177
|
+
summary. Domain-agnostic by construction."
|
|
1178
|
+
"""
|
|
1179
|
+
|
|
1180
|
+
def __init__(self, base_translator: Optional[NLTraversalTranslator] = None):
|
|
1181
|
+
self.base = base_translator or NLTraversalTranslator()
|
|
1182
|
+
|
|
1183
|
+
def translate_with_3_tier_escalation(
|
|
1184
|
+
self,
|
|
1185
|
+
nl_text: str,
|
|
1186
|
+
entity_ids: Optional[List[str]] = None,
|
|
1187
|
+
tenant_id: Optional[str] = None,
|
|
1188
|
+
severity_floor: str = "MEDIUM",
|
|
1189
|
+
cross_tenant_entity_ids: Optional[List[str]] = None,
|
|
1190
|
+
) -> NLTranslationResult:
|
|
1191
|
+
"""discriminated 3-tier translation Decision.
|
|
1192
|
+
|
|
1193
|
+
Returns NLTranslationResult with discriminated tier outcome:
|
|
1194
|
+
- Tier 1: tier1_traversal_request populated (auto-execute)
|
|
1195
|
+
- Tier 2: tier2_candidates populated (operator picks)
|
|
1196
|
+
- Tier 3: tier3_pending_confirmation populated + tier3_reason +
|
|
1197
|
+
tier3_summary (operator confirms before execute)
|
|
1198
|
+
|
|
1199
|
+
the established pattern canonical-invariant.
|
|
1200
|
+
"""
|
|
1201
|
+
rule_based = self.base.translate(nl_text, entity_ids=entity_ids)
|
|
1202
|
+
|
|
1203
|
+
if rule_based is not None:
|
|
1204
|
+
tier, reason = classify_escalation_tier_per_cd1280(
|
|
1205
|
+
rule_based,
|
|
1206
|
+
tenant_id=tenant_id,
|
|
1207
|
+
severity_floor=severity_floor,
|
|
1208
|
+
cross_tenant_start_nodes=cross_tenant_entity_ids,
|
|
1209
|
+
)
|
|
1210
|
+
if tier == 3:
|
|
1211
|
+
return NLTranslationResult(
|
|
1212
|
+
tier=3,
|
|
1213
|
+
nl_text=nl_text,
|
|
1214
|
+
tier3_pending_confirmation=rule_based,
|
|
1215
|
+
tier3_reason=reason,
|
|
1216
|
+
tier3_summary=self._summarize_for_operator(rule_based, reason),
|
|
1217
|
+
)
|
|
1218
|
+
return NLTranslationResult(
|
|
1219
|
+
tier=1,
|
|
1220
|
+
nl_text=nl_text,
|
|
1221
|
+
tier1_traversal_request=rule_based,
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
# Rule-based unmapped: Tier 2 candidate presentation as fallback
|
|
1225
|
+
# (instead of silent LLM-only fallback) Decision Why #3.
|
|
1226
|
+
# Caller may bypass to LLM via translate_with_llm_fallback() if
|
|
1227
|
+
# desired; the 3-tier shape's default is candidate-presentation.
|
|
1228
|
+
return NLTranslationResult(
|
|
1229
|
+
tier=2,
|
|
1230
|
+
nl_text=nl_text,
|
|
1231
|
+
tier2_candidates=[], # caller fills via LLM if NL_LLM_FALLBACK_ENABLED
|
|
1232
|
+
tier2_pick_hint=(
|
|
1233
|
+
"Rule-based 7-pattern path did not match. NL prompt is "
|
|
1234
|
+
"ambiguous. Operator: please rephrase with one of the "
|
|
1235
|
+
"vocabulary keywords (root cause / impact / predict / "
|
|
1236
|
+
"what if / conservation / gap / connected) OR provide a "
|
|
1237
|
+
"typed TraversalRequest directly."
|
|
1238
|
+
),
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
@staticmethod
|
|
1242
|
+
def _summarize_for_operator(
|
|
1243
|
+
request: TraversalRequest, reason: str
|
|
1244
|
+
) -> str:
|
|
1245
|
+
"""5-line operator-confirmation summary Decision Tier-3."""
|
|
1246
|
+
direction_str = (
|
|
1247
|
+
request.direction.value if hasattr(request.direction, "value")
|
|
1248
|
+
else str(request.direction)
|
|
1249
|
+
)
|
|
1250
|
+
value_mode_str = (
|
|
1251
|
+
request.value_mode.value if hasattr(request.value_mode, "value")
|
|
1252
|
+
else str(request.value_mode)
|
|
1253
|
+
)
|
|
1254
|
+
return (
|
|
1255
|
+
f"Traversal: {direction_str.upper()} from {request.start_nodes} "
|
|
1256
|
+
f"in {value_mode_str.upper()} mode (~{request.max_hops} hops). "
|
|
1257
|
+
f"Escalated to Tier 3 reason={reason}. "
|
|
1258
|
+
f"Per + attestation, operator confirmation "
|
|
1259
|
+
f"required before execute. Operator: confirm Y/N + optional redirect."
|
|
1260
|
+
)
|