dyon 0.7.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 (105) hide show
  1. dyon/__init__.py +16 -0
  2. dyon/_compat.py +66 -0
  3. dyon/autonomous/__init__.py +19 -0
  4. dyon/autonomous/base.py +29 -0
  5. dyon/autonomous/deployer.py +91 -0
  6. dyon/autonomous/gym_env.py +124 -0
  7. dyon/autonomous/ooda.py +253 -0
  8. dyon/autonomous/overseer.py +310 -0
  9. dyon/autonomous/planner.py +80 -0
  10. dyon/autonomous/trainer.py +67 -0
  11. dyon/cli/__init__.py +1 -0
  12. dyon/cli/main.py +285 -0
  13. dyon/collection/__init__.py +15 -0
  14. dyon/collection/aggregate.py +132 -0
  15. dyon/collection/base.py +87 -0
  16. dyon/collection/collection_twin.py +106 -0
  17. dyon/collection/composite.py +194 -0
  18. dyon/collection/network_twin.py +209 -0
  19. dyon/collection/orchestrator.py +56 -0
  20. dyon/connector/__init__.py +12 -0
  21. dyon/connector/api_connector.py +62 -0
  22. dyon/connector/base.py +71 -0
  23. dyon/connector/ditto_connector.py +109 -0
  24. dyon/connector/mqtt_connector.py +107 -0
  25. dyon/core/__init__.py +17 -0
  26. dyon/core/base.py +132 -0
  27. dyon/core/config.py +157 -0
  28. dyon/core/events.py +85 -0
  29. dyon/core/lifecycle.py +85 -0
  30. dyon/core/registry.py +55 -0
  31. dyon/core/types.py +57 -0
  32. dyon/data/__init__.py +13 -0
  33. dyon/data/management/__init__.py +4 -0
  34. dyon/data/management/health.py +47 -0
  35. dyon/data/management/pipeline.py +107 -0
  36. dyon/data/storage/__init__.py +16 -0
  37. dyon/data/storage/base.py +119 -0
  38. dyon/data/storage/influx.py +227 -0
  39. dyon/data/storage/minio_store.py +84 -0
  40. dyon/data/storage/mongo.py +117 -0
  41. dyon/data/storage/postgres.py +51 -0
  42. dyon/data/storage/provenance.py +124 -0
  43. dyon/data/storage/redis_store.py +88 -0
  44. dyon/data/text_ingestor.py +157 -0
  45. dyon/data/writer.py +103 -0
  46. dyon/infra/__init__.py +4 -0
  47. dyon/infra/docker.py +349 -0
  48. dyon/infra/health_check.py +100 -0
  49. dyon/intelligent/__init__.py +15 -0
  50. dyon/intelligent/agent.py +152 -0
  51. dyon/intelligent/base.py +42 -0
  52. dyon/intelligent/knowledge_graph.py +220 -0
  53. dyon/intelligent/mas.py +228 -0
  54. dyon/intelligent/tools.py +100 -0
  55. dyon/learning/__init__.py +64 -0
  56. dyon/learning/_util.py +62 -0
  57. dyon/learning/demonstrations.py +205 -0
  58. dyon/learning/features.py +182 -0
  59. dyon/learning/imitation_trainers.py +168 -0
  60. dyon/learning/irl_trainers.py +174 -0
  61. dyon/learning/maxent.py +180 -0
  62. dyon/learning/pipeline.py +296 -0
  63. dyon/learning/reward.py +126 -0
  64. dyon/ml/__init__.py +3 -0
  65. dyon/ml/corpus.py +102 -0
  66. dyon/network/__init__.py +4 -0
  67. dyon/network/ingestor.py +102 -0
  68. dyon/network/transport.py +107 -0
  69. dyon/notifications/__init__.py +3 -0
  70. dyon/notifications/notifier.py +104 -0
  71. dyon/physical/__init__.py +4 -0
  72. dyon/physical/adapters/__init__.py +1 -0
  73. dyon/physical/base.py +34 -0
  74. dyon/physical/simulator.py +141 -0
  75. dyon/reactive/__init__.py +16 -0
  76. dyon/reactive/actions.py +50 -0
  77. dyon/reactive/base.py +31 -0
  78. dyon/reactive/fsm_engine.py +205 -0
  79. dyon/reactive/pid.py +95 -0
  80. dyon/reactive/rule_engine.py +211 -0
  81. dyon/reactive/rule_repository.py +185 -0
  82. dyon/services/__init__.py +4 -0
  83. dyon/services/api/__init__.py +3 -0
  84. dyon/services/api/app.py +56 -0
  85. dyon/services/api/routes.py +94 -0
  86. dyon/services/api/streaming.py +64 -0
  87. dyon/services/base.py +66 -0
  88. dyon/services/composite.py +35 -0
  89. dyon/services/ditto/__init__.py +4 -0
  90. dyon/services/ditto/client.py +183 -0
  91. dyon/services/ditto/sync.py +87 -0
  92. dyon/session/__init__.py +3 -0
  93. dyon/session/context.py +175 -0
  94. dyon/simulation/__init__.py +16 -0
  95. dyon/simulation/base.py +22 -0
  96. dyon/simulation/discrete_event.py +61 -0
  97. dyon/simulation/forecaster.py +55 -0
  98. dyon/simulation/ode_model.py +77 -0
  99. dyon/simulation/runner.py +110 -0
  100. dyon/simulation/surrogate.py +79 -0
  101. dyon-0.7.0.dist-info/METADATA +419 -0
  102. dyon-0.7.0.dist-info/RECORD +105 -0
  103. dyon-0.7.0.dist-info/WHEEL +5 -0
  104. dyon-0.7.0.dist-info/entry_points.txt +3 -0
  105. dyon-0.7.0.dist-info/top_level.txt +1 -0
dyon/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Dyon: Domain-agnostic Python framework for digital twin architectures."""
2
+
3
+ __version__ = "0.6.0"
4
+
5
+ from dyon.core.base import AbstractDigitalTwin, LayerBase
6
+ from dyon.core.config import TwinConfig, SensorFieldSpec
7
+ from dyon.core.events import EventBus, DomainEvent
8
+
9
+ __all__ = [
10
+ "AbstractDigitalTwin",
11
+ "LayerBase",
12
+ "TwinConfig",
13
+ "SensorFieldSpec",
14
+ "EventBus",
15
+ "DomainEvent",
16
+ ]
dyon/_compat.py ADDED
@@ -0,0 +1,66 @@
1
+ """Compatibility support for the legacy ``dt_forge`` import name.
2
+
3
+ ``dt_forge`` was renamed to ``dyon``. :func:`install_alias` registers a meta-path
4
+ hook so that every ``dt_forge`` / ``dt_forge.*`` import is served by the matching
5
+ ``dyon`` module — the *same* module object, so identity, ``isinstance`` and
6
+ module-level singletons stay consistent across both names.
7
+
8
+ The logic lives here (shipped with ``dyon``) so the ``dt_forge`` shim package is a
9
+ trivial two-line forwarder with nothing to drift out of sync.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ import sys
16
+ import warnings
17
+ from importlib.abc import Loader, MetaPathFinder
18
+ from importlib.machinery import ModuleSpec
19
+
20
+ _OLD = "dt_forge"
21
+ _NEW = "dyon"
22
+
23
+
24
+ class _AliasLoader(Loader):
25
+ """Loads an old-name module by handing back the real ``dyon`` module object."""
26
+
27
+ def __init__(self, target: str) -> None:
28
+ self._target = target
29
+
30
+ def create_module(self, spec: ModuleSpec):
31
+ module = importlib.import_module(self._target)
32
+ sys.modules[spec.name] = module
33
+ return module
34
+
35
+ def exec_module(self, module) -> None: # already initialised as ``dyon.*``
36
+ pass
37
+
38
+
39
+ class _AliasFinder(MetaPathFinder):
40
+ """Redirects ``dt_forge`` / ``dt_forge.<sub>`` to ``dyon`` / ``dyon.<sub>``."""
41
+
42
+ def find_spec(self, fullname, path=None, target=None):
43
+ if fullname == _OLD or fullname.startswith(_OLD + "."):
44
+ return ModuleSpec(fullname, _AliasLoader(_NEW + fullname[len(_OLD):]))
45
+ return None
46
+
47
+
48
+ def install_alias(*, warn: bool = True) -> None:
49
+ """Make ``dt_forge`` resolve to ``dyon``. Idempotent; safe to call repeatedly."""
50
+ if not any(isinstance(f, _AliasFinder) for f in sys.meta_path):
51
+ # Insert ahead of the default path finder so submodules alias rather than
52
+ # re-import under a second name.
53
+ sys.meta_path.insert(0, _AliasFinder())
54
+
55
+ if warn:
56
+ warnings.warn(
57
+ "'dt_forge' has been renamed to 'dyon'. Update your imports to 'dyon' "
58
+ "(e.g. 'import dyon', 'from dyon.core.config import TwinConfig'). The "
59
+ "'dt_forge' compatibility alias will be removed in a future major release.",
60
+ DeprecationWarning,
61
+ stacklevel=3,
62
+ )
63
+
64
+ # Serve ``import dt_forge`` itself from the real package, so attribute access
65
+ # (``dt_forge.AbstractDigitalTwin``) resolves identically to ``dyon``.
66
+ sys.modules[_OLD] = importlib.import_module(_NEW)
@@ -0,0 +1,19 @@
1
+ from dyon.autonomous.base import AbstractAutonomousController
2
+ from dyon.autonomous.ooda import OODALoop
3
+ from dyon.autonomous.overseer import AutonomousOverseer, OverseerDecision
4
+ from dyon.autonomous.planner import GoalPlanner, Goal
5
+ from dyon.autonomous.gym_env import GenericTwinEnv
6
+ from dyon.autonomous.trainer import PolicyTrainer
7
+ from dyon.autonomous.deployer import PolicyDeployer
8
+
9
+ __all__ = [
10
+ "AbstractAutonomousController",
11
+ "OODALoop",
12
+ "AutonomousOverseer",
13
+ "OverseerDecision",
14
+ "GoalPlanner",
15
+ "Goal",
16
+ "GenericTwinEnv",
17
+ "PolicyTrainer",
18
+ "PolicyDeployer",
19
+ ]
@@ -0,0 +1,29 @@
1
+ """Abstract base for autonomous controllers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+
8
+ class AbstractAutonomousController(ABC):
9
+ """Base class for any autonomous control strategy."""
10
+
11
+ @abstractmethod
12
+ async def observe(self) -> dict:
13
+ """Collect a comprehensive situation snapshot."""
14
+ ...
15
+
16
+ @abstractmethod
17
+ async def orient(self, observation: dict) -> dict:
18
+ """Contextualise the situation against goals."""
19
+ ...
20
+
21
+ @abstractmethod
22
+ async def decide(self, observation: dict, assessment: dict) -> dict:
23
+ """Select an action plan."""
24
+ ...
25
+
26
+ @abstractmethod
27
+ async def act(self, plan: dict) -> None:
28
+ """Execute the decided plan."""
29
+ ...
@@ -0,0 +1,91 @@
1
+ """PolicyDeployer: live RL inference using a trained SB3 policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ import numpy as np
9
+
10
+ if TYPE_CHECKING:
11
+ from dyon.data.storage.base import TimeSeriesStore
12
+ from dyon.network.transport import MQTTTransport
13
+ from dyon.core.config import TwinConfig
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ class PolicyDeployer:
19
+ """
20
+ Loads a trained SB3 policy and applies it in a live twin loop.
21
+
22
+ On each step_once() call:
23
+ 1. Reads current observations from InfluxDB
24
+ 2. Runs the policy to get an action
25
+ 3. Publishes the control command via MQTT
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ policy_path: str | None,
31
+ config: "TwinConfig",
32
+ ts_store: "TimeSeriesStore",
33
+ mqtt_transport: "MQTTTransport",
34
+ obs_fields: list[str],
35
+ control_field: str,
36
+ ctrl_min: float,
37
+ ctrl_max: float,
38
+ algorithm: str = "SAC",
39
+ policy=None,
40
+ ):
41
+ from stable_baselines3 import SAC, TD3, PPO, A2C
42
+
43
+ self._cfg = config
44
+ self._ts = ts_store
45
+ self._mqtt = mqtt_transport
46
+ self._obs_fields = obs_fields
47
+ self._control_field = control_field
48
+ self._ctrl_min = ctrl_min
49
+ self._ctrl_max = ctrl_max
50
+
51
+ if policy is not None:
52
+ # An already-loaded policy/algorithm (e.g. a BC or imitation policy,
53
+ # or anything exposing .predict(obs, deterministic=...)).
54
+ self._policy = policy
55
+ log.info("PolicyDeployer using preloaded policy (%s)", algorithm)
56
+ else:
57
+ # Imitation/BC artifacts are saved in a PPO container, so "BC" loads
58
+ # through PPO.
59
+ _algo_map = {"SAC": SAC, "TD3": TD3, "PPO": PPO, "A2C": A2C, "BC": PPO}
60
+ AlgoClass = _algo_map.get(algorithm.upper(), SAC)
61
+ self._policy = AlgoClass.load(policy_path)
62
+ log.info("PolicyDeployer loaded '%s' (%s)", policy_path, algorithm)
63
+
64
+ async def _get_observation(self) -> np.ndarray:
65
+ # A field with no data falls back to its configured nominal value rather
66
+ # than a hard 0.0 — a 0.0 can sit outside the policy's observation bounds
67
+ # and misrepresents "no reading" as a genuine zero.
68
+ latest = await self._ts.aget_latest_fields(self._obs_fields)
69
+ specs = self._cfg.field_specs
70
+ obs = []
71
+ for f in self._obs_fields:
72
+ v = latest.get(f)
73
+ if v is None:
74
+ spec = specs.get(f)
75
+ v = spec.nominal if spec and spec.nominal is not None else 0.0
76
+ obs.append(v)
77
+ return np.array(obs, dtype=np.float32)
78
+
79
+ def _scale_action(self, action: np.ndarray) -> float:
80
+ a = float(np.clip(action[0], -1.0, 1.0))
81
+ return self._ctrl_min + (a + 1.0) / 2.0 * (self._ctrl_max - self._ctrl_min)
82
+
83
+ async def step_once(self) -> float:
84
+ obs = await self._get_observation()
85
+ action, _ = self._policy.predict(obs, deterministic=True)
86
+ ctrl = self._scale_action(action)
87
+ self._mqtt.publish(
88
+ self._cfg.topic_control,
89
+ {self._control_field: round(ctrl, 4)},
90
+ )
91
+ return ctrl
@@ -0,0 +1,124 @@
1
+ """GenericTwinEnv: Gymnasium environment wrapping any TwinModel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Callable, TYPE_CHECKING
7
+
8
+ import gymnasium as gym
9
+ import numpy as np
10
+ from gymnasium import spaces
11
+
12
+ if TYPE_CHECKING:
13
+ from dyon.simulation.base import TwinModel
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ class GenericTwinEnv(gym.Env):
19
+ """
20
+ Gymnasium environment that wraps any TwinModel.
21
+
22
+ Configuration:
23
+ model — TwinModel instance to use as the environment
24
+ control_field — name of the variable the agent adjusts
25
+ process_variable— name of the variable being regulated
26
+ target — desired value for process_variable
27
+ ctrl_min/max — valid range for control output
28
+ obs_fields — field names forming the observation
29
+ obs_low/high — bounds for observation space
30
+ reward_fn — optional custom reward function(obs, target) → float
31
+ max_steps — episode length
32
+ """
33
+
34
+ metadata = {"render_modes": []}
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ model: "TwinModel",
40
+ control_field: str,
41
+ process_variable: str,
42
+ target: float,
43
+ ctrl_min: float,
44
+ ctrl_max: float,
45
+ obs_fields: list[str],
46
+ obs_low: np.ndarray,
47
+ obs_high: np.ndarray,
48
+ reward_fn: Callable | None = None,
49
+ max_steps: int = 500,
50
+ ):
51
+ super().__init__()
52
+ self.model = model
53
+ self.control_field = control_field
54
+ self.process_variable = process_variable
55
+ self.target = target
56
+ self.ctrl_min = ctrl_min
57
+ self.ctrl_max = ctrl_max
58
+ self.obs_fields = obs_fields
59
+ self.reward_fn = reward_fn or self._default_reward
60
+ self.max_steps = max_steps
61
+ self._step_count = 0
62
+ self._last_obs: dict[str, float] = {}
63
+
64
+ self.observation_space = spaces.Box(
65
+ obs_low.astype(np.float32),
66
+ obs_high.astype(np.float32),
67
+ dtype=np.float32,
68
+ )
69
+ self.action_space = spaces.Box(
70
+ np.array([-1.0], dtype=np.float32),
71
+ np.array([1.0], dtype=np.float32),
72
+ shape=(1,),
73
+ dtype=np.float32,
74
+ )
75
+
76
+ def _default_reward(self, obs: dict, target: float) -> float:
77
+ # Prefer the simulator-prefixed value but fall back to the bare field.
78
+ # ``or`` chains would mis-fire when the value is a legitimate 0.0, so
79
+ # check membership / None explicitly.
80
+ pv = obs.get(f"sim_{self.process_variable}")
81
+ if pv is None:
82
+ pv = obs.get(self.process_variable)
83
+ if pv is None:
84
+ pv = 0.0
85
+ return -abs(pv - target)
86
+
87
+ def _scale_action(self, action: np.ndarray) -> float:
88
+ """Map [-1, 1] → [ctrl_min, ctrl_max]."""
89
+ a = float(np.clip(action[0], -1.0, 1.0))
90
+ return self.ctrl_min + (a + 1.0) / 2.0 * (self.ctrl_max - self.ctrl_min)
91
+
92
+ def _get_obs(self) -> np.ndarray:
93
+ return np.array(
94
+ [self._last_obs.get(f, 0.0) for f in self.obs_fields],
95
+ dtype=np.float32,
96
+ )
97
+
98
+ def reset(self, *, seed=None, options=None):
99
+ super().reset(seed=seed)
100
+ self.model.reset()
101
+ self._step_count = 0
102
+ # Prime the first observation from a real model step at the neutral
103
+ # action (0 → mid-range control), instead of returning an all-zeros
104
+ # vector: zeros ignore the model's actual initial state and can fall
105
+ # outside the observation Box bounds when obs_low > 0.
106
+ neutral_ctrl = self._scale_action(np.array([0.0], dtype=np.float32))
107
+ self._last_obs = self.model.step(
108
+ dt=1.0, inputs={self.control_field: neutral_ctrl}
109
+ )
110
+ obs = self._get_obs()
111
+ return obs, {}
112
+
113
+ def step(self, action: np.ndarray):
114
+ ctrl = self._scale_action(action)
115
+ outputs = self.model.step(dt=1.0, inputs={self.control_field: ctrl})
116
+ self._last_obs = outputs
117
+ obs = self._get_obs()
118
+ reward = self.reward_fn(outputs, self.target)
119
+ self._step_count += 1
120
+ terminated = self._step_count >= self.max_steps
121
+ return obs, reward, terminated, False, {}
122
+
123
+ def render(self): ...
124
+ def close(self): ...
@@ -0,0 +1,253 @@
1
+ """OODALoop: Observe-Orient-Decide-Act autonomous control layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from typing import TYPE_CHECKING
9
+
10
+ from dyon.autonomous.base import AbstractAutonomousController
11
+ from dyon.core.base import LayerBase
12
+ from dyon.core.events import DomainEvent
13
+
14
+ if TYPE_CHECKING:
15
+ from dyon.autonomous.deployer import PolicyDeployer
16
+ from dyon.autonomous.overseer import AutonomousOverseer
17
+ from dyon.autonomous.planner import GoalPlanner
18
+ from dyon.core.config import TwinConfig
19
+ from dyon.core.events import EventBus
20
+ from dyon.data.storage.base import CacheStore, DocumentStore, TimeSeriesStore
21
+ from dyon.intelligent.mas import MultiAgentSystem
22
+ from dyon.notifications.notifier import HumanNotifier
23
+ from dyon.reactive.rule_engine import ThresholdRuleEngine
24
+ from dyon.services.ditto.client import DittoClient
25
+ from dyon.simulation.base import TwinModel
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ # Maps the assessment/overseer risk vocabulary to the three log-event severity
31
+ # levels the framework uses (DocumentStore.log_event accepts info/warning/critical).
32
+ _RISK_TO_SEVERITY = {
33
+ "low": "info",
34
+ "medium": "info",
35
+ "high": "warning",
36
+ "critical": "critical",
37
+ }
38
+
39
+
40
+ class OODALoop(LayerBase, AbstractAutonomousController):
41
+ """
42
+ The OODA loop provides full situational awareness and autonomous control.
43
+
44
+ Observe — gather state from all lower layers
45
+ Orient — contextualise using models, history, goals
46
+ Decide — select action plan (rule-based, RL policy, goal planner)
47
+ Act — execute actions on lower layers or via connectors
48
+ """
49
+
50
+ layer_name = "autonomous"
51
+
52
+ def __init__(
53
+ self,
54
+ config: "TwinConfig",
55
+ event_bus: "EventBus",
56
+ *,
57
+ ts_store: "TimeSeriesStore",
58
+ cache: "CacheStore",
59
+ doc_store: "DocumentStore",
60
+ ditto_client: "DittoClient",
61
+ models: dict[str, "TwinModel"],
62
+ reactive: "ThresholdRuleEngine",
63
+ mas: "MultiAgentSystem",
64
+ connectors: list,
65
+ planner: "GoalPlanner",
66
+ policy: "PolicyDeployer | None" = None,
67
+ loop_interval: int = 5,
68
+ notifier: "HumanNotifier | None" = None,
69
+ overseer: "AutonomousOverseer | None" = None,
70
+ ):
71
+ super().__init__(config, event_bus)
72
+ self.ts = ts_store
73
+ self.cache = cache
74
+ self.doc = doc_store
75
+ self.ditto = ditto_client
76
+ self.models = models
77
+ self.reactive = reactive
78
+ self.mas = mas
79
+ self.connectors = connectors
80
+ self.planner = planner
81
+ self.policy = policy
82
+ self.loop_interval = loop_interval
83
+ self.notifier = notifier
84
+ # Optional LLM overseer — when set, its strategic decision is merged
85
+ # into the assessment dict the rule-based planner produces.
86
+ self.overseer = overseer
87
+
88
+ async def observe(self) -> dict:
89
+ base = {
90
+ "state": await self.cache.aget_state(),
91
+ "health": await self.cache.aget_latest_cached("health_score"),
92
+ "telemetry": await self.ts.aget_latest_fields(self.config.field_names),
93
+ "recent_events": await self.doc.aget_recent_events(5),
94
+ }
95
+ if self.mas:
96
+ base["mas_findings"] = {
97
+ a.agent_name: self.mas.get_agent_detail(a.agent_name)
98
+ for a in self.mas.agents
99
+ }
100
+ return base
101
+
102
+ async def direct_agent(self, agent_name: str, question: str) -> str:
103
+ """Send a targeted query to a named MAS agent from the autonomous layer."""
104
+ if self.mas is None:
105
+ return "MAS layer not available."
106
+ return await self.mas.ask_agent(agent_name, question)
107
+
108
+ async def orient(self, observation: dict) -> dict:
109
+ assessment = await self.planner.assess(observation)
110
+ if self.overseer is not None:
111
+ try:
112
+ decision = await self.overseer.run(observation)
113
+ # The overseer's strategic decision joins the rule-based
114
+ # assessment under explicit keys so safety constraints in
115
+ # decide() can still take precedence over it.
116
+ assessment["overseer_action"] = decision.action
117
+ assessment["overseer_reasoning"] = decision.reasoning
118
+ assessment["overseer_risk_level"] = decision.risk_level
119
+ assessment["overseer_goals_addressed"] = decision.goals_addressed
120
+ assessment["overseer_agent_queries"] = decision.agent_queries
121
+ # Audit trail for every overseer decision.
122
+ try:
123
+ await self.doc.alog_event(
124
+ "ooda_overseer_decision",
125
+ {
126
+ "action": decision.action,
127
+ "reasoning": decision.reasoning,
128
+ "risk_level": decision.risk_level,
129
+ "goals_addressed": decision.goals_addressed,
130
+ "agent_queries": decision.agent_queries,
131
+ },
132
+ severity="info",
133
+ )
134
+ except Exception as e:
135
+ self.log.debug("Overseer audit log failed: %s", e)
136
+ except Exception as e:
137
+ self.log.error("Overseer.run failed: %s", e)
138
+ return assessment
139
+
140
+ async def decide(self, observation: dict, assessment: dict) -> dict:
141
+ # Layer 1 — hard safety constraints from the rule-based planner.
142
+ # These override the overseer.
143
+ if assessment.get("requires_human_intervention"):
144
+ return {
145
+ "action": "request_human",
146
+ "reason": assessment.get("reason", "intervention required"),
147
+ }
148
+ if assessment.get("requires_shutdown"):
149
+ return {
150
+ "action": "shutdown",
151
+ "reason": assessment.get("reason", "autonomous shutdown"),
152
+ }
153
+ if assessment.get("needs_external_data"):
154
+ return {
155
+ "action": "query_peer",
156
+ "target_twin": assessment.get("target_twin", ""),
157
+ "query": assessment.get("query", {}),
158
+ }
159
+
160
+ # Layer 2 — strategic decision from the overseer (if configured).
161
+ # The overseer chooses among the actions declared at construction time.
162
+ overseer_action = assessment.get("overseer_action")
163
+ if overseer_action and overseer_action != "no_action":
164
+ return {
165
+ "action": overseer_action,
166
+ "reason": assessment.get("overseer_reasoning", ""),
167
+ "risk_level": assessment.get("overseer_risk_level", "low"),
168
+ "source": "overseer",
169
+ }
170
+
171
+ # Layer 3 — RL tactical optimisation when conditions are nominal.
172
+ if self.policy and assessment.get("risk_level") == "low":
173
+ return {"action": "rl_control"}
174
+ return {"action": "maintain_current"}
175
+
176
+ async def act(self, plan: dict) -> None:
177
+ action = plan["action"]
178
+
179
+ if action == "request_human":
180
+ await self.doc.alog_event(
181
+ "human_intervention_requested", plan, severity="critical"
182
+ )
183
+ await self.bus.publish(
184
+ DomainEvent(
185
+ event_type="autonomous.human_requested",
186
+ source_layer="autonomous",
187
+ source_asset=self.config.asset_id,
188
+ payload=plan,
189
+ severity="critical",
190
+ )
191
+ )
192
+ self.log.warning("Human intervention requested: %s", plan.get("reason"))
193
+ if self.notifier:
194
+ await self.notifier.send(plan.get("reason", "intervention required"), plan)
195
+
196
+ elif action == "shutdown":
197
+ self.reactive.shutdown_asset() # type: ignore[attr-defined]
198
+ await self.doc.alog_event("autonomous_shutdown", plan, severity="critical")
199
+
200
+ elif action == "query_peer":
201
+ for conn in self.connectors:
202
+ if conn.can_reach(plan.get("target_twin", "")):
203
+ result = await conn.query(plan["target_twin"], plan.get("query", {}))
204
+ await self.doc.alog_event("peer_query", {"result": result}, severity="info")
205
+ break
206
+
207
+ elif action == "rl_control":
208
+ if self.policy:
209
+ await self.policy.step_once()
210
+
211
+ elif plan.get("source") == "overseer":
212
+ # Overseer-defined action: publish an event so domain code can act
213
+ # on it. Subclasses that know the overseer's action vocabulary
214
+ # should override act() to drive their own actuators.
215
+ severity = _RISK_TO_SEVERITY.get(plan.get("risk_level", "low"), "info")
216
+ await self.bus.publish(
217
+ DomainEvent(
218
+ event_type=f"autonomous.{action}",
219
+ source_layer="autonomous",
220
+ source_asset=self.config.asset_id,
221
+ payload=plan,
222
+ severity=severity,
223
+ )
224
+ )
225
+ await self.doc.alog_event(
226
+ f"autonomous_{action}",
227
+ plan,
228
+ severity=severity,
229
+ )
230
+
231
+ async def start(self) -> None:
232
+ self._running = True
233
+ self.log.info("OODA autonomous loop started (interval=%ds)", self.loop_interval)
234
+ while self._running:
235
+ try:
236
+ obs = await self.observe()
237
+ assessment = await self.orient(obs)
238
+ plan = await self.decide(obs, assessment)
239
+ await self.act(plan)
240
+ try:
241
+ # Publish a compact summary for the dashboard and monitoring tools.
242
+ # Wrapped in try/except so a Redis outage cannot kill the control loop.
243
+ await self.cache.aset_latest("ooda_last_cycle", {
244
+ "action": plan.get("action", "unknown"),
245
+ "reason": plan.get("reason", ""),
246
+ "risk_level": assessment.get("risk_level", ""),
247
+ "ts_s": int(time.time()),
248
+ })
249
+ except Exception:
250
+ pass
251
+ except Exception as e:
252
+ self.log.error("OODA loop error: %s", e)
253
+ await asyncio.sleep(self.loop_interval)