fable-engine 1.3.1__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.
- fable_compressor.py +356 -0
- fable_engine/__init__.py +1 -0
- fable_engine/actions/__init__.py +291 -0
- fable_engine/actions/cas.py +182 -0
- fable_engine/actions/deliberation.py +523 -0
- fable_engine/actions/fleet.py +807 -0
- fable_engine/actions/lifecycle.py +298 -0
- fable_engine/actions/scrapers.py +116 -0
- fable_engine/actions/system3.py +815 -0
- fable_engine/browser.py +824 -0
- fable_engine/cas.py +974 -0
- fable_engine/fable_session.json +510 -0
- fable_engine/guards.py +283 -0
- fable_engine/schema.py +714 -0
- fable_engine/scrapers/__init__.py +32 -0
- fable_engine/scrapers/arxiv.py +115 -0
- fable_engine/scrapers/base.py +386 -0
- fable_engine/scrapers/github.py +129 -0
- fable_engine/scrapers/reddit.py +154 -0
- fable_engine/scrapers/web.py +120 -0
- fable_engine/scrapers/x.py +125 -0
- fable_engine/scrapers/youtube.py +132 -0
- fable_engine/server.py +414 -0
- fable_engine/session.py +1819 -0
- fable_engine/test_server.py +1362 -0
- fable_engine/updater.py +541 -0
- fable_engine-1.3.1.dist-info/LICENSE +22 -0
- fable_engine-1.3.1.dist-info/METADATA +173 -0
- fable_engine-1.3.1.dist-info/RECORD +104 -0
- fable_engine-1.3.1.dist-info/WHEEL +5 -0
- fable_engine-1.3.1.dist-info/entry_points.txt +5 -0
- fable_engine-1.3.1.dist-info/top_level.txt +6 -0
- fable_mode/__init__.py +3 -0
- fable_mode/__main__.py +4 -0
- fable_mode/adapters.py +1014 -0
- fable_mode/installer.py +553 -0
- fable_mode/launcher.py +437 -0
- fable_mode/manifest.py +142 -0
- fable_mode/resources.json +114 -0
- fable_mode/safety.py +103 -0
- fable_mode_entry.py +10 -0
- fable_v2/__init__.py +146 -0
- fable_v2/adapters.py +151 -0
- fable_v2/coder_fleet/__init__.py +100 -0
- fable_v2/coder_fleet/ast_tools.py +158 -0
- fable_v2/coder_fleet/compute.py +199 -0
- fable_v2/coder_fleet/design_engine.py +1316 -0
- fable_v2/coder_fleet/diagnostics.py +293 -0
- fable_v2/coder_fleet/fleet_dispatcher.py +214 -0
- fable_v2/coder_fleet/mock_auditor.py +306 -0
- fable_v2/coder_fleet/mutation.py +216 -0
- fable_v2/coder_fleet/property_oracle.py +260 -0
- fable_v2/coder_fleet/receipt_attestor.py +122 -0
- fable_v2/coder_fleet/red_team_swarm.py +908 -0
- fable_v2/coder_fleet/test_harness.py +198 -0
- fable_v2/coder_fleet/vector_engine.py +1287 -0
- fable_v2/coder_fleet/visual.py +357 -0
- fable_v2/coder_fleet/workspace.py +153 -0
- fable_v2/cortical/__init__.py +20 -0
- fable_v2/cortical/plasticity_engine.py +992 -0
- fable_v2/execution_broker.py +811 -0
- fable_v2/proof_engine.py +1141 -0
- fable_v2/protocol.py +485 -0
- fable_v2/runtime.py +1010 -0
- fable_v2/system3/__init__.py +204 -0
- fable_v2/system3/causal.py +558 -0
- fable_v2/system3/dialectical.py +577 -0
- fable_v2/system3/evolution.py +503 -0
- fable_v2/system3/executive.py +338 -0
- fable_v2/system3/free_energy.py +479 -0
- fable_v2/system3/hyperbolic.py +555 -0
- fable_v2/system3/induction.py +336 -0
- fable_v2/system3/kripke.py +548 -0
- fable_v2/system3/oracle.py +745 -0
- fable_v2/verifiers.py +72 -0
- tests/__init__.py +1 -0
- tests/test_anti_loop_circuit_breaker.py +64 -0
- tests/test_auto_updater.py +407 -0
- tests/test_coder_fleet.py +535 -0
- tests/test_delegation_compiler.py +54 -0
- tests/test_descriptor_boundaries.py +126 -0
- tests/test_design_engine.py +603 -0
- tests/test_epistemic_evidence_validator.py +66 -0
- tests/test_execution_broker.py +233 -0
- tests/test_fable_v2.py +406 -0
- tests/test_fleet_transitions.py +116 -0
- tests/test_fsm_redteam_evolution.py +406 -0
- tests/test_goal_rubric_and_pipeline.py +367 -0
- tests/test_hebbian_plasticity.py +585 -0
- tests/test_packaging_runtime.py +194 -0
- tests/test_proof_engine.py +259 -0
- tests/test_red_team_swarm.py +645 -0
- tests/test_redteam_remediation.py +169 -0
- tests/test_registration_transaction.py +375 -0
- tests/test_requested_regressions.py +467 -0
- tests/test_scrapers.py +370 -0
- tests/test_server_actions.py +93 -0
- tests/test_server_frontier_actions.py +269 -0
- tests/test_server_protocol.py +88 -0
- tests/test_stealth_browser.py +970 -0
- tests/test_system3.py +381 -0
- tests/test_system3_deep_integration.py +385 -0
- tests/test_system3_frontier.py +436 -0
- tests/test_vector_engine.py +608 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""System 3 Causal Deliberation & Pearl's Do-Calculus Engine.
|
|
2
|
+
|
|
3
|
+
Provides structural causal models (SCM), Directed Acyclic Graph (DAG) validation,
|
|
4
|
+
Pearl's do-calculus interventions (graph surgery), counterfactual branch isolation,
|
|
5
|
+
and multi-dimensional brittleness/sensitivity analysis. Zero external dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field, asdict
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
|
13
|
+
import copy
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CausalCycleError(Exception):
|
|
20
|
+
"""Raised when a cycle is detected in a causal graph that must be a DAG."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CausalNodeNotFoundError(Exception):
|
|
25
|
+
"""Raised when a referenced causal node does not exist."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CausalNodeType(str, Enum):
|
|
30
|
+
"""Classification of causal variables in the structural model."""
|
|
31
|
+
EXOGENOUS = "exogenous" # External constraint, input, or background parameter
|
|
32
|
+
ENDOGENOUS = "endogenous" # Internal computed state / intermediate variable
|
|
33
|
+
INTERVENTION = "intervention" # Variable explicitly set via do-operator
|
|
34
|
+
METRIC = "metric" # Observable KPI / outcome of interest
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class CausalEdge:
|
|
39
|
+
"""Directed causal dependency from source to target node."""
|
|
40
|
+
source: str
|
|
41
|
+
target: str
|
|
42
|
+
weight: float = 1.0
|
|
43
|
+
relation_type: str = "linear" # "linear", "inverse", "threshold", "probabilistic", "custom"
|
|
44
|
+
delay_ms: float = 0.0
|
|
45
|
+
description: str = ""
|
|
46
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
49
|
+
return asdict(self)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, data: Dict[str, Any]) -> "CausalEdge":
|
|
53
|
+
return cls(**data)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class CausalNode:
|
|
58
|
+
"""Variable node in the Causal DAG."""
|
|
59
|
+
node_id: str
|
|
60
|
+
name: str
|
|
61
|
+
node_type: CausalNodeType = CausalNodeType.ENDOGENOUS
|
|
62
|
+
value: float = 0.0
|
|
63
|
+
default_value: float = 0.0
|
|
64
|
+
min_value: Optional[float] = None
|
|
65
|
+
max_value: Optional[float] = None
|
|
66
|
+
equation_description: str = ""
|
|
67
|
+
description: str = ""
|
|
68
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
def clamp_value(self, val: float) -> float:
|
|
71
|
+
"""Clamp value to defined bounds if specified."""
|
|
72
|
+
if self.min_value is not None:
|
|
73
|
+
val = max(self.min_value, val)
|
|
74
|
+
if self.max_value is not None:
|
|
75
|
+
val = min(self.max_value, val)
|
|
76
|
+
return val
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
79
|
+
d = asdict(self)
|
|
80
|
+
d["node_type"] = self.node_type.value
|
|
81
|
+
return d
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dict(cls, data: Dict[str, Any]) -> "CausalNode":
|
|
85
|
+
data_copy = dict(data)
|
|
86
|
+
if "node_type" in data_copy and isinstance(data_copy["node_type"], str):
|
|
87
|
+
data_copy["node_type"] = CausalNodeType(data_copy["node_type"])
|
|
88
|
+
return cls(**data_copy)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class InterventionResult:
|
|
93
|
+
"""Result of applying Pearl's do-operator do(X=x)."""
|
|
94
|
+
interventions: Dict[str, float]
|
|
95
|
+
original_values: Dict[str, float]
|
|
96
|
+
counterfactual_values: Dict[str, float]
|
|
97
|
+
deltas: Dict[str, float]
|
|
98
|
+
severed_edges: List[Tuple[str, str]]
|
|
99
|
+
impacted_nodes: List[str]
|
|
100
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
103
|
+
return asdict(self)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class BrittlenessReport:
|
|
108
|
+
"""Sensitivity and single-point-of-failure analysis of the causal model."""
|
|
109
|
+
target_metric: str
|
|
110
|
+
overall_brittleness_score: float # [0.0, 1.0] (0 = resilient, 1 = hyper-fragile)
|
|
111
|
+
single_points_of_failure: List[str]
|
|
112
|
+
node_sensitivities: Dict[str, float] # Absolute delta in target per unit perturbation
|
|
113
|
+
critical_paths: List[List[str]]
|
|
114
|
+
recommendations: List[str]
|
|
115
|
+
|
|
116
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
117
|
+
return asdict(self)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class CausalDAG:
|
|
121
|
+
"""
|
|
122
|
+
Structural Causal Model (SCM) Directed Acyclic Graph.
|
|
123
|
+
Implements Pearl's do-calculus, cycle detection, topological sorting,
|
|
124
|
+
counterfactual branch isolation, and sensitivity analysis.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, name: str = "CausalModel"):
|
|
128
|
+
self.name = name
|
|
129
|
+
self.nodes: Dict[str, CausalNode] = {}
|
|
130
|
+
self.edges: List[CausalEdge] = []
|
|
131
|
+
self._adjacency: Dict[str, List[CausalEdge]] = {}
|
|
132
|
+
self._reverse_adjacency: Dict[str, List[CausalEdge]] = {}
|
|
133
|
+
self._custom_evaluators: Dict[str, Callable[[Dict[str, float]], float]] = {}
|
|
134
|
+
|
|
135
|
+
def add_node(
|
|
136
|
+
self,
|
|
137
|
+
node_id: str,
|
|
138
|
+
name: Optional[str] = None,
|
|
139
|
+
node_type: CausalNodeType = CausalNodeType.ENDOGENOUS,
|
|
140
|
+
value: float = 0.0,
|
|
141
|
+
default_value: Optional[float] = None,
|
|
142
|
+
min_value: Optional[float] = None,
|
|
143
|
+
max_value: Optional[float] = None,
|
|
144
|
+
description: str = "",
|
|
145
|
+
equation_description: str = "",
|
|
146
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
147
|
+
) -> CausalNode:
|
|
148
|
+
"""Add a causal variable node to the graph."""
|
|
149
|
+
clean_id = node_id.strip()
|
|
150
|
+
if not clean_id:
|
|
151
|
+
raise ValueError("Node ID cannot be empty.")
|
|
152
|
+
if clean_id in self.nodes:
|
|
153
|
+
raise ValueError(f"Node '{clean_id}' already exists in DAG.")
|
|
154
|
+
|
|
155
|
+
node = CausalNode(
|
|
156
|
+
node_id=clean_id,
|
|
157
|
+
name=name.strip() if name else clean_id,
|
|
158
|
+
node_type=node_type,
|
|
159
|
+
value=value,
|
|
160
|
+
default_value=value if default_value is None else default_value,
|
|
161
|
+
min_value=min_value,
|
|
162
|
+
max_value=max_value,
|
|
163
|
+
description=description,
|
|
164
|
+
equation_description=equation_description,
|
|
165
|
+
metadata=metadata or {},
|
|
166
|
+
)
|
|
167
|
+
self.nodes[clean_id] = node
|
|
168
|
+
self._adjacency[clean_id] = []
|
|
169
|
+
self._reverse_adjacency[clean_id] = []
|
|
170
|
+
return node
|
|
171
|
+
|
|
172
|
+
def add_edge(
|
|
173
|
+
self,
|
|
174
|
+
source: str,
|
|
175
|
+
target: str,
|
|
176
|
+
weight: float = 1.0,
|
|
177
|
+
relation_type: str = "linear",
|
|
178
|
+
delay_ms: float = 0.0,
|
|
179
|
+
description: str = "",
|
|
180
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
181
|
+
) -> CausalEdge:
|
|
182
|
+
"""Add a directed causal edge from source to target. Verifies acyclicity."""
|
|
183
|
+
if source not in self.nodes:
|
|
184
|
+
raise CausalNodeNotFoundError(f"Source node '{source}' not found.")
|
|
185
|
+
if target not in self.nodes:
|
|
186
|
+
raise CausalNodeNotFoundError(f"Target node '{target}' not found.")
|
|
187
|
+
if source == target:
|
|
188
|
+
raise CausalCycleError(f"Self-loop detected on node '{source}'.")
|
|
189
|
+
|
|
190
|
+
# Check for duplicate edge
|
|
191
|
+
for e in self.edges:
|
|
192
|
+
if e.source == source and e.target == target:
|
|
193
|
+
raise ValueError(f"Edge from '{source}' to '{target}' already exists.")
|
|
194
|
+
|
|
195
|
+
edge = CausalEdge(
|
|
196
|
+
source=source,
|
|
197
|
+
target=target,
|
|
198
|
+
weight=weight,
|
|
199
|
+
relation_type=relation_type,
|
|
200
|
+
delay_ms=delay_ms,
|
|
201
|
+
description=description,
|
|
202
|
+
metadata=metadata or {},
|
|
203
|
+
)
|
|
204
|
+
self.edges.append(edge)
|
|
205
|
+
self._adjacency[source].append(edge)
|
|
206
|
+
self._reverse_adjacency[target].append(edge)
|
|
207
|
+
|
|
208
|
+
# Validate that adding this edge preserves DAG acyclicity
|
|
209
|
+
is_dag, cycle_path = self.check_acyclicity()
|
|
210
|
+
if not is_dag:
|
|
211
|
+
# Rollback
|
|
212
|
+
self.edges.remove(edge)
|
|
213
|
+
self._adjacency[source].remove(edge)
|
|
214
|
+
self._reverse_adjacency[target].remove(edge)
|
|
215
|
+
raise CausalCycleError(
|
|
216
|
+
f"Adding edge '{source}' -> '{target}' creates a cycle: {' -> '.join(cycle_path)}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return edge
|
|
220
|
+
|
|
221
|
+
def register_evaluator(
|
|
222
|
+
self, node_id: str, evaluator_fn: Callable[[Dict[str, float]], float]
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Register a custom structural equation evaluator function for a node."""
|
|
225
|
+
if node_id not in self.nodes:
|
|
226
|
+
raise CausalNodeNotFoundError(f"Node '{node_id}' not found.")
|
|
227
|
+
self._custom_evaluators[node_id] = evaluator_fn
|
|
228
|
+
|
|
229
|
+
def get_parents(self, node_id: str) -> List[str]:
|
|
230
|
+
"""Return immediate causal parents of a node."""
|
|
231
|
+
if node_id not in self.nodes:
|
|
232
|
+
raise CausalNodeNotFoundError(f"Node '{node_id}' not found.")
|
|
233
|
+
return [e.source for e in self._reverse_adjacency.get(node_id, [])]
|
|
234
|
+
|
|
235
|
+
def get_children(self, node_id: str) -> List[str]:
|
|
236
|
+
"""Return immediate causal children of a node."""
|
|
237
|
+
if node_id not in self.nodes:
|
|
238
|
+
raise CausalNodeNotFoundError(f"Node '{node_id}' not found.")
|
|
239
|
+
return [e.target for e in self._adjacency.get(node_id, [])]
|
|
240
|
+
|
|
241
|
+
def get_ancestors(self, node_id: str) -> Set[str]:
|
|
242
|
+
"""Return all causal ancestor node IDs."""
|
|
243
|
+
ancestors: Set[str] = set()
|
|
244
|
+
queue = list(self.get_parents(node_id))
|
|
245
|
+
while queue:
|
|
246
|
+
curr = queue.pop(0)
|
|
247
|
+
if curr not in ancestors:
|
|
248
|
+
ancestors.add(curr)
|
|
249
|
+
queue.extend(self.get_parents(curr))
|
|
250
|
+
return ancestors
|
|
251
|
+
|
|
252
|
+
def get_descendants(self, node_id: str) -> Set[str]:
|
|
253
|
+
"""Return all causal descendant node IDs."""
|
|
254
|
+
descendants: Set[str] = set()
|
|
255
|
+
queue = list(self.get_children(node_id))
|
|
256
|
+
while queue:
|
|
257
|
+
curr = queue.pop(0)
|
|
258
|
+
if curr not in descendants:
|
|
259
|
+
descendants.add(curr)
|
|
260
|
+
queue.extend(self.get_children(curr))
|
|
261
|
+
return descendants
|
|
262
|
+
|
|
263
|
+
def check_acyclicity(self) -> Tuple[bool, List[str]]:
|
|
264
|
+
"""
|
|
265
|
+
Check if the graph is a valid DAG using DFS three-color cycle detection.
|
|
266
|
+
Returns (is_acyclic, cycle_nodes_if_any).
|
|
267
|
+
"""
|
|
268
|
+
visited: Dict[str, int] = {nid: 0 for nid in self.nodes} # 0: unvisited, 1: visiting, 2: visited
|
|
269
|
+
parent_map: Dict[str, Optional[str]] = {nid: None for nid in self.nodes}
|
|
270
|
+
cycle: List[str] = []
|
|
271
|
+
|
|
272
|
+
def dfs(u: str) -> bool:
|
|
273
|
+
visited[u] = 1
|
|
274
|
+
for edge in self._adjacency.get(u, []):
|
|
275
|
+
v = edge.target
|
|
276
|
+
if visited[v] == 1:
|
|
277
|
+
# Cycle found - reconstruct path
|
|
278
|
+
cycle.append(v)
|
|
279
|
+
curr = u
|
|
280
|
+
while curr != v and curr is not None:
|
|
281
|
+
cycle.append(curr)
|
|
282
|
+
curr = parent_map.get(curr)
|
|
283
|
+
cycle.append(v)
|
|
284
|
+
cycle.reverse()
|
|
285
|
+
return True
|
|
286
|
+
elif visited[v] == 0:
|
|
287
|
+
parent_map[v] = u
|
|
288
|
+
if dfs(v):
|
|
289
|
+
return True
|
|
290
|
+
visited[u] = 2
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
for node_id in self.nodes:
|
|
294
|
+
if visited[node_id] == 0:
|
|
295
|
+
if dfs(node_id):
|
|
296
|
+
return False, cycle
|
|
297
|
+
|
|
298
|
+
return True, []
|
|
299
|
+
|
|
300
|
+
def topological_sort(self) -> List[str]:
|
|
301
|
+
"""
|
|
302
|
+
Return nodes in topological order using Kahn's algorithm.
|
|
303
|
+
Raises CausalCycleError if graph contains a cycle.
|
|
304
|
+
"""
|
|
305
|
+
in_degree: Dict[str, int] = {nid: len(self._reverse_adjacency.get(nid, [])) for nid in self.nodes}
|
|
306
|
+
queue = [nid for nid, deg in in_degree.items() if deg == 0]
|
|
307
|
+
order: List[str] = []
|
|
308
|
+
|
|
309
|
+
while queue:
|
|
310
|
+
u = queue.pop(0)
|
|
311
|
+
order.append(u)
|
|
312
|
+
for edge in self._adjacency.get(u, []):
|
|
313
|
+
v = edge.target
|
|
314
|
+
in_degree[v] -= 1
|
|
315
|
+
if in_degree[v] == 0:
|
|
316
|
+
queue.append(v)
|
|
317
|
+
|
|
318
|
+
if len(order) != len(self.nodes):
|
|
319
|
+
raise CausalCycleError("Graph contains a cycle; topological sort impossible.")
|
|
320
|
+
|
|
321
|
+
return order
|
|
322
|
+
|
|
323
|
+
def compute_forward(self, initial_values: Optional[Dict[str, float]] = None) -> Dict[str, float]:
|
|
324
|
+
"""
|
|
325
|
+
Propagate values through the DAG along topological order.
|
|
326
|
+
For each endogenous node, calculates value from its parents via registered
|
|
327
|
+
evaluator or default weighted combination.
|
|
328
|
+
"""
|
|
329
|
+
order = self.topological_sort()
|
|
330
|
+
values: Dict[str, float] = {}
|
|
331
|
+
|
|
332
|
+
# Set initial / default values
|
|
333
|
+
for nid, node in self.nodes.items():
|
|
334
|
+
if initial_values and nid in initial_values:
|
|
335
|
+
values[nid] = node.clamp_value(float(initial_values[nid]))
|
|
336
|
+
else:
|
|
337
|
+
values[nid] = node.value
|
|
338
|
+
|
|
339
|
+
for nid in order:
|
|
340
|
+
node = self.nodes[nid]
|
|
341
|
+
parents = self.get_parents(nid)
|
|
342
|
+
|
|
343
|
+
if not parents:
|
|
344
|
+
# Exogenous root node keeps its assigned value
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
if nid in self._custom_evaluators:
|
|
348
|
+
# Custom registered structural equation
|
|
349
|
+
raw_val = self._custom_evaluators[nid](values)
|
|
350
|
+
values[nid] = node.clamp_value(raw_val)
|
|
351
|
+
else:
|
|
352
|
+
# Default structural aggregation: sum(weight * parent_val)
|
|
353
|
+
computed = 0.0
|
|
354
|
+
for edge in self._reverse_adjacency.get(nid, []):
|
|
355
|
+
p_val = values.get(edge.source, 0.0)
|
|
356
|
+
if edge.relation_type == "linear":
|
|
357
|
+
computed += edge.weight * p_val
|
|
358
|
+
elif edge.relation_type == "inverse":
|
|
359
|
+
computed += edge.weight / (p_val if abs(p_val) > 1e-6 else 1e-6)
|
|
360
|
+
elif edge.relation_type == "threshold":
|
|
361
|
+
computed += edge.weight if p_val >= 1.0 else 0.0
|
|
362
|
+
else:
|
|
363
|
+
computed += edge.weight * p_val
|
|
364
|
+
|
|
365
|
+
values[nid] = node.clamp_value(computed)
|
|
366
|
+
|
|
367
|
+
return values
|
|
368
|
+
|
|
369
|
+
def do_intervention(
|
|
370
|
+
self,
|
|
371
|
+
interventions: Dict[str, float],
|
|
372
|
+
base_values: Optional[Dict[str, float]] = None,
|
|
373
|
+
) -> InterventionResult:
|
|
374
|
+
"""
|
|
375
|
+
Pearl's Do-Calculus: do(X_1=x_1, X_2=x_2, ...).
|
|
376
|
+
Performs graph surgery:
|
|
377
|
+
1. Cuts all incoming edges to intervened nodes (severing parental dependencies).
|
|
378
|
+
2. Clamps intervened nodes to target values.
|
|
379
|
+
3. Propagates counterfactual consequences downstream.
|
|
380
|
+
4. Calculates exact counterfactual deltas without mutating original graph state.
|
|
381
|
+
"""
|
|
382
|
+
# Validate intervention keys
|
|
383
|
+
for nid in interventions:
|
|
384
|
+
if nid not in self.nodes:
|
|
385
|
+
raise CausalNodeNotFoundError(f"Intervention target '{nid}' not in DAG.")
|
|
386
|
+
|
|
387
|
+
# 1. Compute factual baseline
|
|
388
|
+
factual_values = self.compute_forward(base_values)
|
|
389
|
+
|
|
390
|
+
# 2. Build surgically modified sub-DAG
|
|
391
|
+
severed_edges: List[Tuple[str, str]] = []
|
|
392
|
+
impacted_nodes_set: Set[str] = set()
|
|
393
|
+
|
|
394
|
+
for nid in interventions:
|
|
395
|
+
impacted_nodes_set.add(nid)
|
|
396
|
+
impacted_nodes_set.update(self.get_descendants(nid))
|
|
397
|
+
for parent in self.get_parents(nid):
|
|
398
|
+
severed_edges.append((parent, nid))
|
|
399
|
+
|
|
400
|
+
# Create clone graph for counterfactual simulation
|
|
401
|
+
cloned_dag = CausalDAG.from_dict(self.to_dict())
|
|
402
|
+
|
|
403
|
+
# Sever incoming edges in the clone
|
|
404
|
+
cloned_dag.edges = [e for e in cloned_dag.edges if e.target not in interventions]
|
|
405
|
+
cloned_dag._rebuild_adjacency()
|
|
406
|
+
|
|
407
|
+
# Set fixed intervention values
|
|
408
|
+
cf_initial = dict(factual_values)
|
|
409
|
+
for nid, val in interventions.items():
|
|
410
|
+
cf_initial[nid] = val
|
|
411
|
+
if nid in cloned_dag.nodes:
|
|
412
|
+
cloned_dag.nodes[nid].value = val
|
|
413
|
+
|
|
414
|
+
# 3. Propagate in surgically altered DAG
|
|
415
|
+
counterfactual_values = cloned_dag.compute_forward(cf_initial)
|
|
416
|
+
|
|
417
|
+
# 4. Compute deltas
|
|
418
|
+
deltas: Dict[str, float] = {}
|
|
419
|
+
for nid in self.nodes:
|
|
420
|
+
f_val = factual_values.get(nid, 0.0)
|
|
421
|
+
cf_val = counterfactual_values.get(nid, 0.0)
|
|
422
|
+
deltas[nid] = round(cf_val - f_val, 6)
|
|
423
|
+
|
|
424
|
+
return InterventionResult(
|
|
425
|
+
interventions=interventions,
|
|
426
|
+
original_values=factual_values,
|
|
427
|
+
counterfactual_values=counterfactual_values,
|
|
428
|
+
deltas=deltas,
|
|
429
|
+
severed_edges=severed_edges,
|
|
430
|
+
impacted_nodes=sorted(list(impacted_nodes_set)),
|
|
431
|
+
metadata={"intervened_count": len(interventions), "total_nodes": len(self.nodes)},
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def evaluate_brittleness(
|
|
435
|
+
self,
|
|
436
|
+
target_metric: str,
|
|
437
|
+
perturbation_delta: float = 0.1,
|
|
438
|
+
critical_sensitivity_threshold: float = 1.5,
|
|
439
|
+
) -> BrittlenessReport:
|
|
440
|
+
"""
|
|
441
|
+
Evaluate structural brittleness & sensitivity of target metric.
|
|
442
|
+
Identifies single points of failure (nodes with sensitivity > threshold)
|
|
443
|
+
and computes normalized system brittleness score.
|
|
444
|
+
"""
|
|
445
|
+
if target_metric not in self.nodes:
|
|
446
|
+
raise CausalNodeNotFoundError(f"Target metric node '{target_metric}' not found.")
|
|
447
|
+
|
|
448
|
+
ancestors = self.get_ancestors(target_metric)
|
|
449
|
+
if not ancestors:
|
|
450
|
+
return BrittlenessReport(
|
|
451
|
+
target_metric=target_metric,
|
|
452
|
+
overall_brittleness_score=0.0,
|
|
453
|
+
single_points_of_failure=[],
|
|
454
|
+
node_sensitivities={},
|
|
455
|
+
critical_paths=[],
|
|
456
|
+
recommendations=["Target metric has no incoming causal dependencies; structurally isolated."],
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
baseline_values = self.compute_forward()
|
|
460
|
+
base_metric_val = baseline_values.get(target_metric, 0.0)
|
|
461
|
+
|
|
462
|
+
sensitivities: Dict[str, float] = {}
|
|
463
|
+
spof_nodes: List[str] = []
|
|
464
|
+
|
|
465
|
+
for anc in sorted(list(ancestors)):
|
|
466
|
+
orig_val = baseline_values.get(anc, 1.0)
|
|
467
|
+
perturbed_val = orig_val + perturbation_delta
|
|
468
|
+
interv_res = self.do_intervention({anc: perturbed_val}, base_values=baseline_values)
|
|
469
|
+
new_metric_val = interv_res.counterfactual_values.get(target_metric, base_metric_val)
|
|
470
|
+
|
|
471
|
+
# Sensitivity = abs(d_metric / d_input)
|
|
472
|
+
denom = max(abs(perturbation_delta), 1e-6)
|
|
473
|
+
sens = abs(new_metric_val - base_metric_val) / denom
|
|
474
|
+
sensitivities[anc] = round(sens, 4)
|
|
475
|
+
|
|
476
|
+
if sens >= critical_sensitivity_threshold:
|
|
477
|
+
spof_nodes.append(anc)
|
|
478
|
+
|
|
479
|
+
# Compute overall brittleness score [0.0, 1.0] using sigmoid of max sensitivity
|
|
480
|
+
max_sens = max(sensitivities.values()) if sensitivities else 0.0
|
|
481
|
+
avg_sens = sum(sensitivities.values()) / len(sensitivities) if sensitivities else 0.0
|
|
482
|
+
brittleness_score = round(1.0 - math.exp(-0.5 * (max_sens + avg_sens)), 4)
|
|
483
|
+
|
|
484
|
+
# Find critical paths to target metric
|
|
485
|
+
critical_paths: List[List[str]] = []
|
|
486
|
+
for spof in spof_nodes:
|
|
487
|
+
path = self._find_path(spof, target_metric)
|
|
488
|
+
if path:
|
|
489
|
+
critical_paths.append(path)
|
|
490
|
+
|
|
491
|
+
recommendations: List[str] = []
|
|
492
|
+
if spof_nodes:
|
|
493
|
+
recommendations.append(
|
|
494
|
+
f"Mitigate single points of failure: {', '.join(spof_nodes)} exhibit sensitivity > {critical_sensitivity_threshold}."
|
|
495
|
+
)
|
|
496
|
+
recommendations.append("Apply TRIZ Principle 24 (Intermediary) or Principle 1 (Segmentation) to decouple sensitive nodes.")
|
|
497
|
+
else:
|
|
498
|
+
recommendations.append("System demonstrates robust structural resilience under perturbation.")
|
|
499
|
+
|
|
500
|
+
return BrittlenessReport(
|
|
501
|
+
target_metric=target_metric,
|
|
502
|
+
overall_brittleness_score=brittleness_score,
|
|
503
|
+
single_points_of_failure=spof_nodes,
|
|
504
|
+
node_sensitivities=sensitivities,
|
|
505
|
+
critical_paths=critical_paths,
|
|
506
|
+
recommendations=recommendations,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def _find_path(self, start: str, end: str) -> List[str]:
|
|
510
|
+
"""Find a directed path from start to end using BFS."""
|
|
511
|
+
queue: List[List[str]] = [[start]]
|
|
512
|
+
visited: Set[str] = {start}
|
|
513
|
+
|
|
514
|
+
while queue:
|
|
515
|
+
path = queue.pop(0)
|
|
516
|
+
node = path[-1]
|
|
517
|
+
if node == end:
|
|
518
|
+
return path
|
|
519
|
+
for edge in self._adjacency.get(node, []):
|
|
520
|
+
next_node = edge.target
|
|
521
|
+
if next_node not in visited:
|
|
522
|
+
visited.add(next_node)
|
|
523
|
+
queue.append(path + [next_node])
|
|
524
|
+
return []
|
|
525
|
+
|
|
526
|
+
def _rebuild_adjacency(self) -> None:
|
|
527
|
+
"""Internal helper to rebuild adjacency indices."""
|
|
528
|
+
self._adjacency = {nid: [] for nid in self.nodes}
|
|
529
|
+
self._reverse_adjacency = {nid: [] for nid in self.nodes}
|
|
530
|
+
for edge in self.edges:
|
|
531
|
+
if edge.source in self._adjacency:
|
|
532
|
+
self._adjacency[edge.source].append(edge)
|
|
533
|
+
if edge.target in self._reverse_adjacency:
|
|
534
|
+
self._reverse_adjacency[edge.target].append(edge)
|
|
535
|
+
|
|
536
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
537
|
+
"""Serialize DAG to JSON-compatible dictionary."""
|
|
538
|
+
return {
|
|
539
|
+
"name": self.name,
|
|
540
|
+
"nodes": [node.to_dict() for node in self.nodes.values()],
|
|
541
|
+
"edges": [edge.to_dict() for edge in self.edges],
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
@classmethod
|
|
545
|
+
def from_dict(cls, data: Dict[str, Any]) -> "CausalDAG":
|
|
546
|
+
"""Deserialize DAG from dictionary."""
|
|
547
|
+
dag = cls(name=data.get("name", "CausalModel"))
|
|
548
|
+
for n_data in data.get("nodes", []):
|
|
549
|
+
node = CausalNode.from_dict(n_data)
|
|
550
|
+
dag.nodes[node.node_id] = node
|
|
551
|
+
dag._adjacency[node.node_id] = []
|
|
552
|
+
dag._reverse_adjacency[node.node_id] = []
|
|
553
|
+
for e_data in data.get("edges", []):
|
|
554
|
+
edge = CausalEdge.from_dict(e_data)
|
|
555
|
+
dag.edges.append(edge)
|
|
556
|
+
dag._adjacency[edge.source].append(edge)
|
|
557
|
+
dag._reverse_adjacency[edge.target].append(edge)
|
|
558
|
+
return dag
|