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