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.
Files changed (104) hide show
  1. fable_compressor.py +356 -0
  2. fable_engine/__init__.py +1 -0
  3. fable_engine/actions/__init__.py +291 -0
  4. fable_engine/actions/cas.py +182 -0
  5. fable_engine/actions/deliberation.py +523 -0
  6. fable_engine/actions/fleet.py +807 -0
  7. fable_engine/actions/lifecycle.py +298 -0
  8. fable_engine/actions/scrapers.py +116 -0
  9. fable_engine/actions/system3.py +815 -0
  10. fable_engine/browser.py +824 -0
  11. fable_engine/cas.py +974 -0
  12. fable_engine/fable_session.json +510 -0
  13. fable_engine/guards.py +283 -0
  14. fable_engine/schema.py +714 -0
  15. fable_engine/scrapers/__init__.py +32 -0
  16. fable_engine/scrapers/arxiv.py +115 -0
  17. fable_engine/scrapers/base.py +386 -0
  18. fable_engine/scrapers/github.py +129 -0
  19. fable_engine/scrapers/reddit.py +154 -0
  20. fable_engine/scrapers/web.py +120 -0
  21. fable_engine/scrapers/x.py +125 -0
  22. fable_engine/scrapers/youtube.py +132 -0
  23. fable_engine/server.py +414 -0
  24. fable_engine/session.py +1819 -0
  25. fable_engine/test_server.py +1362 -0
  26. fable_engine/updater.py +541 -0
  27. fable_engine-1.3.1.dist-info/LICENSE +22 -0
  28. fable_engine-1.3.1.dist-info/METADATA +173 -0
  29. fable_engine-1.3.1.dist-info/RECORD +104 -0
  30. fable_engine-1.3.1.dist-info/WHEEL +5 -0
  31. fable_engine-1.3.1.dist-info/entry_points.txt +5 -0
  32. fable_engine-1.3.1.dist-info/top_level.txt +6 -0
  33. fable_mode/__init__.py +3 -0
  34. fable_mode/__main__.py +4 -0
  35. fable_mode/adapters.py +1014 -0
  36. fable_mode/installer.py +553 -0
  37. fable_mode/launcher.py +437 -0
  38. fable_mode/manifest.py +142 -0
  39. fable_mode/resources.json +114 -0
  40. fable_mode/safety.py +103 -0
  41. fable_mode_entry.py +10 -0
  42. fable_v2/__init__.py +146 -0
  43. fable_v2/adapters.py +151 -0
  44. fable_v2/coder_fleet/__init__.py +100 -0
  45. fable_v2/coder_fleet/ast_tools.py +158 -0
  46. fable_v2/coder_fleet/compute.py +199 -0
  47. fable_v2/coder_fleet/design_engine.py +1316 -0
  48. fable_v2/coder_fleet/diagnostics.py +293 -0
  49. fable_v2/coder_fleet/fleet_dispatcher.py +214 -0
  50. fable_v2/coder_fleet/mock_auditor.py +306 -0
  51. fable_v2/coder_fleet/mutation.py +216 -0
  52. fable_v2/coder_fleet/property_oracle.py +260 -0
  53. fable_v2/coder_fleet/receipt_attestor.py +122 -0
  54. fable_v2/coder_fleet/red_team_swarm.py +908 -0
  55. fable_v2/coder_fleet/test_harness.py +198 -0
  56. fable_v2/coder_fleet/vector_engine.py +1287 -0
  57. fable_v2/coder_fleet/visual.py +357 -0
  58. fable_v2/coder_fleet/workspace.py +153 -0
  59. fable_v2/cortical/__init__.py +20 -0
  60. fable_v2/cortical/plasticity_engine.py +992 -0
  61. fable_v2/execution_broker.py +811 -0
  62. fable_v2/proof_engine.py +1141 -0
  63. fable_v2/protocol.py +485 -0
  64. fable_v2/runtime.py +1010 -0
  65. fable_v2/system3/__init__.py +204 -0
  66. fable_v2/system3/causal.py +558 -0
  67. fable_v2/system3/dialectical.py +577 -0
  68. fable_v2/system3/evolution.py +503 -0
  69. fable_v2/system3/executive.py +338 -0
  70. fable_v2/system3/free_energy.py +479 -0
  71. fable_v2/system3/hyperbolic.py +555 -0
  72. fable_v2/system3/induction.py +336 -0
  73. fable_v2/system3/kripke.py +548 -0
  74. fable_v2/system3/oracle.py +745 -0
  75. fable_v2/verifiers.py +72 -0
  76. tests/__init__.py +1 -0
  77. tests/test_anti_loop_circuit_breaker.py +64 -0
  78. tests/test_auto_updater.py +407 -0
  79. tests/test_coder_fleet.py +535 -0
  80. tests/test_delegation_compiler.py +54 -0
  81. tests/test_descriptor_boundaries.py +126 -0
  82. tests/test_design_engine.py +603 -0
  83. tests/test_epistemic_evidence_validator.py +66 -0
  84. tests/test_execution_broker.py +233 -0
  85. tests/test_fable_v2.py +406 -0
  86. tests/test_fleet_transitions.py +116 -0
  87. tests/test_fsm_redteam_evolution.py +406 -0
  88. tests/test_goal_rubric_and_pipeline.py +367 -0
  89. tests/test_hebbian_plasticity.py +585 -0
  90. tests/test_packaging_runtime.py +194 -0
  91. tests/test_proof_engine.py +259 -0
  92. tests/test_red_team_swarm.py +645 -0
  93. tests/test_redteam_remediation.py +169 -0
  94. tests/test_registration_transaction.py +375 -0
  95. tests/test_requested_regressions.py +467 -0
  96. tests/test_scrapers.py +370 -0
  97. tests/test_server_actions.py +93 -0
  98. tests/test_server_frontier_actions.py +269 -0
  99. tests/test_server_protocol.py +88 -0
  100. tests/test_stealth_browser.py +970 -0
  101. tests/test_system3.py +381 -0
  102. tests/test_system3_deep_integration.py +385 -0
  103. tests/test_system3_frontier.py +436 -0
  104. 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