Skip to content

Commit c4802b0

Browse files
Merge pull request #283 from marvin-hansen/main
feat(deep_causality): Implemented Adaptive Reasoning
2 parents 0b7cb1b + 5a645f4 commit c4802b0

File tree

4 files changed

+309
-11
lines changed

4 files changed

+309
-11
lines changed

deep_causality/src/traits/causable_graph/graph_reasoning/mod.rs

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ where
4747
/// the output effect of a parent node becomes the input effect for its child node.
4848
/// The traversal continues as long as no `CausalityError` is returned.
4949
///
50+
/// ## Adaptive Reasoning
51+
///
52+
/// If a `Causaloid` returns a `PropagatingEffect::RelayTo(target_index, inner_effect)`,
53+
/// the BFS traversal dynamically jumps to `target_index`, and `inner_effect` becomes
54+
/// the new input for the relayed path. This enables *adaptive reasoning* conditional to the deciding
55+
/// causaloid. To illustrate adaptive reasoning, an example clinical patent risk model may operate
56+
/// very differently for patients with normal blood pressure compared to high blood pressure patients.
57+
/// Therefore, two highly specialized models are defined and a dedicated dispatch causaloid.
58+
/// The dispatch causaloid analyses blood pressure and then, conditional on its finding, dispatches
59+
/// further reasoning to the matching model i.e. a dedicated sub-graph. Ensure that all possible
60+
/// values of target_index exists in the graph before implementing adaptive reasoning.
61+
/// For more details, see section 5.10.3 Adaptive Reasoning in The EPP reference paper:
62+
/// https://github.com/deepcausality-rs/papers/blob/main/effect_propagation_process/epp.pdf
63+
///
5064
/// # Arguments
5165
///
5266
/// * `start_index` - The index of the node to start the traversal from.
@@ -57,7 +71,7 @@ where
5771
/// * `Ok(PropagatingEffect)`: The final `PropagatingEffect` from the last successfully evaluated node
5872
/// in the main traversal path. `Deterministic(false)` now propagates and does not implicitly halt propagation.
5973
/// Only a `Causaloid` returning a `CausalityError` will abort the traversal.
60-
/// * `Err(CausalityError)` if the graph is not frozen, a node is missing, or an evaluation fails.
74+
/// * `Err(CausalityError)` if the graph is not frozen, a node is missing, a RelayTo target cannot be found or an evaluation fails.
6175
fn evaluate_subgraph_from_cause(
6276
&self,
6377
start_index: usize,
@@ -100,13 +114,41 @@ where
100114
// This ensures the function returns the effect of the last node on the path.
101115
last_propagated_effect = result_effect.clone();
102116

103-
// Only a CausalityError returned from cause.evaluate() will abort the traversal.
104-
let children = self.get_graph().outbound_edges(current_index)?;
105-
for child_index in children {
106-
if !visited[child_index] {
107-
visited[child_index] = true;
108-
// Pass the result_effect of the current node to its children.
109-
queue.push_back((child_index, result_effect.clone()));
117+
match result_effect {
118+
// Adaptive reasoning:
119+
// The Causaloid itself determines the next step in the reasoning process
120+
// conditional on its reasoning outcome. Based on its own internal logic,
121+
// a Causaloid then dynamically dispatches the flow of causality
122+
// to another Causaloid in the graph, enabling adaptive reasoning.
123+
PropagatingEffect::RelayTo(target_index, inner_effect) => {
124+
// If a RelayTo effect is returned, clear the queue and add the target_index
125+
// with the inner_effect as the new starting point for traversal.
126+
queue.clear();
127+
128+
// Validate target_index before proceeding
129+
if !self.contains_causaloid(target_index) {
130+
return Err(CausalityError(format!(
131+
"RelayTo target causaloid with index {target_index} not found in graph."
132+
)));
133+
}
134+
135+
if !visited[target_index] {
136+
visited[target_index] = true;
137+
queue.push_back((target_index, *inner_effect));
138+
}
139+
// Update last_propagated_effect to reflect the effect of the relayed node.
140+
// This is already handled by the line above: last_propagated_effect = result_effect.clone();
141+
}
142+
_ => {
143+
// Only a CausalityError returned from cause.evaluate() will abort the traversal.
144+
let children = self.get_graph().outbound_edges(current_index)?;
145+
for child_index in children {
146+
if !visited[child_index] {
147+
visited[child_index] = true;
148+
// Pass the result_effect of the current node to its children.
149+
queue.push_back((child_index, result_effect.clone()));
150+
}
151+
}
110152
}
111153
}
112154
}
@@ -121,6 +163,15 @@ where
121163
/// one causaloid becomes the input for the next causaloid in the path. If any node
122164
/// fails evaluation or returns a non-propagating effect that prunes the path, the reasoning stops.
123165
///
166+
/// If a `Causaloid` returns a `PropagatingEffect::RelayTo(target_index, inner_effect)`,
167+
/// the shortest path traversal is immediately interrupted, and the `RelayTo` effect
168+
/// is returned to the caller, signaling a dynamic redirection. The runtime behavior differs
169+
/// from `evaluate_subgraph_from_cause` because a shortest path is assumed to be a fixed path
170+
/// and thus RelayTo is not supposed to happen in the middle of the path. Therefore, the
171+
/// call-site must handle the occurrence i.e. when its a known final effect.
172+
/// For more details, see section 5.10.3 Adaptive Reasoning in The EPP reference paper:
173+
/// https://github.com/deepcausality-rs/papers/blob/main/effect_propagation_process/epp.pdf
174+
///
124175
/// # Arguments
125176
///
126177
/// * `start_index` - The index of the start cause.
@@ -130,6 +181,7 @@ where
130181
/// # Returns
131182
///
132183
/// * `Ok(PropagatingEffect)`: The final `PropagatingEffect` from the last evaluated node on the path.
184+
/// If a `RelayTo` effect is encountered, that effect is returned immediately.
133185
/// * `Err(CausalityError)` if the path cannot be found or an evaluation fails.
134186
fn evaluate_shortest_path_between_causes(
135187
&self,
@@ -164,8 +216,14 @@ where
164216
// Evaluate the current cause with the effect propagated from the previous node.
165217
// Then, overwrite the current_effect with the result of the evaluation, which then
166218
// serves as the input for the next node.
167-
// Only a CausalityError returned from cause.evaluate() will abort the traversal.
219+
// For normal traversal, a CausalityError returned from cause.evaluate() will abort the traversal.
168220
current_effect = cause.evaluate(&current_effect)?;
221+
222+
// If a RelayTo effect is returned, stop the shortest path traversal and return it
223+
// because it breaks the assumption of a fixed shortest path.
224+
if let PropagatingEffect::RelayTo(_, _) = current_effect {
225+
return Ok(current_effect);
226+
}
169227
}
170228

171229
// If the loop completes, all nodes on the path were successfully evaluated.

deep_causality/src/types/reasoning_types/propagating_effect/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ pub enum PropagatingEffect {
4141
Map(HashMap<IdentificationValue, Box<PropagatingEffect>>),
4242
/// A graph of effects, for passing complex relational data.
4343
Graph(Arc<EffectGraph>),
44-
/// A dispatch command that directs a reasoning engine to jump to a specific
45-
/// next causaloid with the specified id (`usize`) and provide it with the encapsulated effect as its new input.
44+
/// A dispatch command that directs the reasoning engine to dynamically jump to a specific
45+
/// causaloid within the graph. The `usize` is the target causaloid's index, and the `Box<PropagatingEffect>`
46+
/// is the effect to be passed as input to that target causaloid. This enables adaptive reasoning.
4647
RelayTo(usize, Box<PropagatingEffect>),
4748
}
4849

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
#![allow(clippy::too_many_arguments)]
2+
3+
/*
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) "2025" . The DeepCausality Authors and Contributors. All Rights Reserved.
6+
*/
7+
8+
use deep_causality::utils_test::test_utils;
9+
use deep_causality::*;
10+
11+
#[test]
12+
fn test_evaluate_subgraph_from_cause_with_relay_to_simple() {
13+
// Graph: Root (0) -> A (1) -> B (2) -> C (3)
14+
// A will relay to C
15+
let mut g = CausaloidGraph::new(0);
16+
17+
let root_causaloid = test_utils::get_test_causaloid_deterministic_true();
18+
let root_index = g.add_root_causaloid(root_causaloid).unwrap();
19+
20+
// Causaloid A: Relays to C (index 3) with a specific effect
21+
let causaloid_a_id = 10;
22+
let causaloid_a_description = "Causaloid A relays to node 3 with Numerical(100.0)";
23+
let causaloid_a_fn =
24+
|_effect: &PropagatingEffect| -> Result<PropagatingEffect, CausalityError> {
25+
Ok(PropagatingEffect::RelayTo(
26+
3,
27+
Box::new(PropagatingEffect::Deterministic(false)),
28+
))
29+
};
30+
let causaloid_a = Causaloid::new(causaloid_a_id, causaloid_a_fn, causaloid_a_description);
31+
let idx_a = g.add_causaloid(causaloid_a).unwrap();
32+
33+
// Causaloid B: Standard causaloid, should be skipped
34+
let causaloid_b = test_utils::get_test_causaloid_deterministic_input_output();
35+
let idx_b = g.add_causaloid(causaloid_b).unwrap();
36+
37+
// Causaloid C: Standard causaloid, should be the final evaluated node
38+
let causaloid_c = test_utils::get_test_causaloid_deterministic_input_output();
39+
let idx_c = g.add_causaloid(causaloid_c).unwrap();
40+
41+
// Link the graph: Root -> A -> B -> C
42+
g.add_edge(root_index, idx_a).unwrap();
43+
g.add_edge(idx_a, idx_b).unwrap();
44+
g.add_edge(idx_b, idx_c).unwrap();
45+
46+
g.freeze();
47+
48+
let initial_effect = PropagatingEffect::Deterministic(true);
49+
let res = g.evaluate_subgraph_from_cause(root_index, &initial_effect);
50+
51+
dbg!(&res);
52+
assert!(res.is_ok());
53+
// Expected: Root (true) -> A (Relay to C with Deterministic(false))
54+
// C (input Deterministic(false)) -> Deterministic(true) (C just inverts the input)
55+
assert_eq!(res.unwrap(), PropagatingEffect::Deterministic(true));
56+
}
57+
58+
#[test]
59+
fn test_evaluate_subgraph_from_cause_with_relay_to_visited_node() {
60+
// Graph: Root (0) -> A (1) -> B (2)
61+
// B will relay back to Root (0)
62+
let mut g = CausaloidGraph::new(0);
63+
64+
let root_causaloid = test_utils::get_test_causaloid_deterministic_true();
65+
let root_index = g.add_root_causaloid(root_causaloid).unwrap();
66+
67+
let causaloid_a = test_utils::get_test_causaloid_deterministic_input_output();
68+
let idx_a = g.add_causaloid(causaloid_a).unwrap();
69+
70+
// Causaloid B: Relays back to Root (index 0)
71+
let causaloid_b_id = 11;
72+
let causaloid_b_description = "Causaloid B relays to node 0 with Numerical(50.0)";
73+
let causaloid_b_fn =
74+
|_effect: &PropagatingEffect| -> Result<PropagatingEffect, CausalityError> {
75+
Ok(PropagatingEffect::RelayTo(
76+
0,
77+
Box::new(PropagatingEffect::Numerical(50.0)),
78+
))
79+
};
80+
let causaloid_b = Causaloid::new(causaloid_b_id, causaloid_b_fn, causaloid_b_description);
81+
let idx_b = g.add_causaloid(causaloid_b).unwrap();
82+
83+
// Link the graph: Root -> A -> B
84+
g.add_edge(root_index, idx_a).unwrap();
85+
g.add_edge(idx_a, idx_b).unwrap();
86+
87+
g.freeze();
88+
89+
let initial_effect = PropagatingEffect::Deterministic(true);
90+
let res = g.evaluate_subgraph_from_cause(root_index, &initial_effect);
91+
92+
assert!(res.is_ok());
93+
// Expected: Root (true) -> A (false) -> B (Relay to Root with Numerical(50.0))
94+
// The traversal should jump back to Root, but since Root is already visited, it won't be re-added.
95+
// The last propagated effect will be the RelayTo effect from B.
96+
assert_eq!(
97+
res.unwrap(),
98+
PropagatingEffect::RelayTo(0, Box::new(PropagatingEffect::Numerical(50.0)))
99+
);
100+
}
101+
102+
#[test]
103+
fn test_evaluate_shortest_path_between_causes_with_relay_interrupt() {
104+
// Graph: Root (0) -> A (1) -> B (2) -> C (3)
105+
// A will relay, interrupting the shortest path from Root to C
106+
let mut g = CausaloidGraph::new(0);
107+
108+
let root_causaloid = test_utils::get_test_causaloid_deterministic_true();
109+
let root_index = g.add_root_causaloid(root_causaloid).unwrap();
110+
111+
// Causaloid A: Relays to C (index 3) with a specific effect
112+
let causaloid_a_id = 12;
113+
let causaloid_a_description = "Causaloid A relays to node 3 with Numerical(200.0)";
114+
let causaloid_a_fn =
115+
|_effect: &PropagatingEffect| -> Result<PropagatingEffect, CausalityError> {
116+
Ok(PropagatingEffect::RelayTo(
117+
3,
118+
Box::new(PropagatingEffect::Numerical(200.0)),
119+
))
120+
};
121+
let causaloid_a = Causaloid::new(causaloid_a_id, causaloid_a_fn, causaloid_a_description);
122+
let idx_a = g.add_causaloid(causaloid_a).unwrap();
123+
124+
// Causaloid B: Standard causaloid, should not be reached
125+
let causaloid_b = test_utils::get_test_causaloid_deterministic_input_output();
126+
let idx_b = g.add_causaloid(causaloid_b).unwrap();
127+
128+
// Causaloid C: Standard causaloid
129+
let causaloid_c = test_utils::get_test_causaloid_deterministic_input_output();
130+
let idx_c = g.add_causaloid(causaloid_c).unwrap();
131+
132+
// Link the graph: Root -> A -> B -> C
133+
g.add_edge(root_index, idx_a).unwrap();
134+
g.add_edge(idx_a, idx_b).unwrap();
135+
g.add_edge(idx_b, idx_c).unwrap();
136+
137+
g.freeze();
138+
139+
let initial_effect = PropagatingEffect::Deterministic(true);
140+
// Attempt to find shortest path from Root (0) to C (3)
141+
let res = g.evaluate_shortest_path_between_causes(root_index, idx_c, &initial_effect);
142+
143+
assert!(res.is_ok());
144+
// Expected: The RelayTo effect from A should interrupt the path and be returned.
145+
assert_eq!(
146+
res.unwrap(),
147+
PropagatingEffect::RelayTo(3, Box::new(PropagatingEffect::Numerical(200.0)))
148+
);
149+
}
150+
151+
#[test]
152+
fn test_evaluate_shortest_path_between_causes_with_relay_at_end() {
153+
// Graph: Root (0) -> A (1) -> B (2)
154+
// B will relay, as the last node on the shortest path
155+
let mut g = CausaloidGraph::new(0);
156+
157+
let root_causaloid = test_utils::get_test_causaloid_deterministic_true();
158+
let root_index = g.add_root_causaloid(root_causaloid).unwrap();
159+
160+
let causaloid_a = test_utils::get_test_causaloid_deterministic_input_output();
161+
let idx_a = g.add_causaloid(causaloid_a).unwrap();
162+
163+
// Causaloid B: Relays to Root (index 0)
164+
let causaloid_b_id = 13;
165+
let causaloid_b_description = "Causaloid B relays to node 0 with Numerical(50.0)";
166+
let causaloid_b_fn =
167+
|_effect: &PropagatingEffect| -> Result<PropagatingEffect, CausalityError> {
168+
Ok(PropagatingEffect::RelayTo(
169+
0,
170+
Box::new(PropagatingEffect::Numerical(50.0)),
171+
))
172+
};
173+
let causaloid_b = Causaloid::new(causaloid_b_id, causaloid_b_fn, causaloid_b_description);
174+
let idx_b = g.add_causaloid(causaloid_b).unwrap();
175+
176+
// Link the graph: Root -> A -> B
177+
g.add_edge(root_index, idx_a).unwrap();
178+
g.add_edge(idx_a, idx_b).unwrap();
179+
180+
g.freeze();
181+
182+
let initial_effect = PropagatingEffect::Deterministic(true);
183+
// Attempt to find shortest path from Root (0) to B (2)
184+
let res = g.evaluate_shortest_path_between_causes(root_index, idx_b, &initial_effect);
185+
186+
assert!(res.is_ok());
187+
// Expected: The RelayTo effect from B should be returned.
188+
assert_eq!(
189+
res.unwrap(),
190+
PropagatingEffect::RelayTo(0, Box::new(PropagatingEffect::Numerical(50.0)))
191+
);
192+
}
193+
194+
#[test]
195+
fn test_evaluate_subgraph_from_cause_relay_to_non_existent_node() {
196+
// Graph: Root (0) -> A (1)
197+
// A will relay to a non-existent node (e.g., index 99)
198+
let mut g = CausaloidGraph::new(0);
199+
200+
let root_causaloid = test_utils::get_test_causaloid_deterministic_true();
201+
let root_index = g.add_root_causaloid(root_causaloid).unwrap();
202+
203+
// Causaloid A: Relays to a non-existent node (index 99)
204+
let non_existent_target_index = 124;
205+
let causaloid_a_id = 14;
206+
let causaloid_a_description = format!(
207+
"Causaloid A relays to non-existent node {}",
208+
non_existent_target_index
209+
);
210+
let causaloid_a_fn =
211+
|_effect: &PropagatingEffect| -> Result<PropagatingEffect, CausalityError> {
212+
Ok(PropagatingEffect::RelayTo(
213+
124, // non_existent_target_index
214+
Box::new(PropagatingEffect::Numerical(1.0)),
215+
))
216+
};
217+
218+
let causaloid_a = Causaloid::new(causaloid_a_id, causaloid_a_fn, &causaloid_a_description);
219+
let idx_a = g.add_causaloid(causaloid_a).unwrap();
220+
221+
// Link the graph: Root -> A
222+
g.add_edge(root_index, idx_a).unwrap();
223+
224+
g.freeze();
225+
226+
let initial_effect = PropagatingEffect::Deterministic(true);
227+
let res = g.evaluate_subgraph_from_cause(root_index, &initial_effect);
228+
229+
assert!(res.is_err());
230+
assert_eq!(
231+
res.unwrap_err().to_string(),
232+
format!(
233+
"CausalityError: RelayTo target causaloid with index {} not found in graph.",
234+
non_existent_target_index
235+
)
236+
);
237+
}

deep_causality/tests/types/causal_types/causaloid_graph/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ mod causality_graph_freeze_tests;
1212
#[cfg(test)]
1313
mod causality_graph_nodes_tests;
1414
#[cfg(test)]
15+
mod causality_graph_reasoning_adaptive_tests;
16+
#[cfg(test)]
1517
mod causality_graph_reasoning_all_tests;
1618
#[cfg(test)]
1719
mod causality_graph_reasoning_imbalanced_tests;

0 commit comments

Comments
 (0)